feat(arap)!: documents read their figures from standing lines; control accounts
check / check (push) Canceled after 0s

refuse hand entries (P4b S1, part A)

Plan P4b (D-45), slice S1, the engine half: rules.md B-38.

- ledger-core: every receivables or payables line carries its document
  (Ledger.line_docs: DocRef, Movement, DocTag), written by the document
  commands in the step that posts it, derived on replay from the existing
  document events (the role's account as it stood then; payments by the
  ordinal rule), copied onto a reversal's mirror. Ledger::stands_on;
  doc_state / invoice_state / bill_state (total, paid, outstanding,
  status Open/Partial/Paid/WrittenOff/Void, overdue days) as of any day;
  ar_aging / ap_aging read them, so reversed payments and voids are right.
  The control guard (add_line, add_line_fx, post_entry,
  register_recurring) refuses untagged lines on receivables/payables,
  exempting a reversal's mirror of a legacy line; replay never refuses.
  Bounds (kernel::running_bounds, bounds_kept) on payments and reversals;
  a document reversal never dated before its entry; deposit/payer accounts
  must be cash-like and hold no role. New API: receive_payment_with /
  pay_bill_with (PaymentDetails: instrument, reference), void_document /
  check_void_document, control_check, payments_recorded,
  standing_payments, replay_reverse_entry. Invoice.amount_paid/status
  remain only as derived caches for the MCP layer until part B.
  cashflow bill_splits reads payments from the tags.
- ledger-store: journal feature document_standing (D-39 gated) for a
  payment accepted only because an earlier one was reversed;
  receive_payment_with / pay_bill_with (instrument journaled as
  entry.instrument_set, reference in the description), void_document;
  replay uses replay_reverse_entry; schema 15 drops the projection's
  amount_paid/status. Fixture p4-arap-before + golden figures written by
  the version before S1, adjudicated in arap_standing.rs.
- ledger-gui: party card, cash "coming up" and company owed totals read
  doc_state as of today (late from the derived state).
- Lean Documents.lean (control_agrees and friends); Kani
  bounds_check_matches_definition,
bounds_kept_is_the_definition_on_books_in_bounds;
  rules B-38, B-18/B-23 amended; bookspec `older = true` for legacy hand
  lines; tests that hand-post to 1200/2000 post as replay or renumbered.

Review fixes (both reviews of part A):
- control_role_of guards only an account the role is assigned to or one
  holding its documents' lines (a chart's own 1200 Equipment or card at
  2000 is free until a document posts there); payment accounts check
  other roles by holds_role (type-checked). The MCP tests that failed on
  the guard (oracle_mcp, spec_projects) pass unchanged.
- standing_payments leaves out reversals (sorted, deduped); replayed
  tags match the movement's side; payments_recorded counts every payment
  event (tagged or not, Ledger.payment_events) and refuses overflow.
- The record caches settled (paid + discounted + written off) and owed
  (Invoice/Bill.owed, never serialized): a void never reads as paid.
- assign_role: receivables/payables refuse moving onto an account with
  untagged lines (RoleAccountHasHandLines) and, on their control account
  holding a balance, say DocumentRoleHoldsBalance (no hand entry named);
  B-23 reworded. The reversal-mirror exemption only takes a hand line
  off (even chain depth); putting it back is refused.
- DocStatus Overpaid and Unattached; aging leaves out credit balances;
  ControlCheck.attention lists out-of-bounds and unattached documents.
- stands_on counts a chain in order; indexes (doc_index, doc_accounts,
  order_pos, reversed_by) keep the derived figures linear.
- Viewer reads a post-dated document as of its own date; one state per
  row. bounds_kept's side-crossing documented as intended.
- Tests: ten new spec_documents scenarios, stronger proptests, store
  proptest arap_props (live = replay = snapshot at every date, closed
  months), fuzz ops void/payments with details, a GUI post-dated test;
  Lean Step.assign / Step.release with unattached_zero kept; model
  limits stated in docs/testing.md and B-38.
Review fixes (CPA and engineering lenses): only an assigned role's
account or one holding document lines is a control account (a chart's
own 1200 asset stays free); a role can't move onto untagged lines or off
document lines (Lean Step.assign); a void's cache never reads as paid;
no Decimal sum panics; per-document indexes keep figures linear; a
replayed tag matches its side; the feature gate counts payment events;
a payment's reversal is never listed as standing; an overpaid document
has its own status; the viewer keeps post-dated documents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-24 09:17:42 -06:00
1 parent 7741e96eb3
commit d7fa16e974
54 files changed
+5997 -649

No files matched your search

+5
View File
@@ -251,6 +251,11 @@ the side gives the direction.
- `instrument` on an event: the payment method (a `setup.instruments` - `instrument` on an event: the payment method (a `setup.instruments`
label) the entry was paid with. Neither `parties` nor `instrument` on a label) the entry was paid with. Neither `parties` nor `instrument` on a
reversal, which takes its original's, nor on a command. 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 - `close = { start, to, rounding? }` as an event: closes the period from
`start` through the event's `date` (rules.md B-11, B-13), `to` `start` through the event's `date` (rules.md B-11, B-13), `to`
`"members"` (split by share, in the order listed) or `"members"` (split by share, in the order listed) or
@@ -63,6 +63,7 @@ lines = [
[[events]] [[events]]
date = "2026-03-01" date = "2026-03-01"
description = "Dana Studio pays the company's invoice" description = "Dana Studio pays the company's invoice"
older = true
payee = "Dana Studio" payee = "Dana Studio"
lines = [ lines = [
{ account = "1000", side = "debit", amount = "450.00" }, { account = "1000", side = "debit", amount = "450.00" },
@@ -176,6 +176,7 @@ lines = [
[[events]] [[events]]
date = "2026-10-20" date = "2026-10-20"
description = "Bill" description = "Bill"
older = true
parties = [{ party = "Northwind Filings" }] parties = [{ party = "Northwind Filings" }]
lines = [ lines = [
{ account = "6000", side = "debit", amount = "80.00" }, { account = "6000", side = "debit", amount = "80.00" },
@@ -192,6 +193,7 @@ reverses = 7
[[events]] [[events]]
date = "2026-10-20" date = "2026-10-20"
description = "Invoice" description = "Invoice"
older = true
parties = [{ party = "Riley Chen" }] parties = [{ party = "Riley Chen" }]
lines = [ lines = [
{ account = "1200", side = "debit", amount = "120.00" }, { account = "1200", side = "debit", amount = "120.00" },
@@ -95,6 +95,7 @@ bill = { vendor = "Dana Illustration", lines = [
[[events]] [[events]]
date = "2026-03-20" date = "2026-03-20"
description = "Cedar pays invoice 1" description = "Cedar pays invoice 1"
older = true
lines = [ lines = [
{ account = "1000", side = "debit", amount = "3800.00" }, { account = "1000", side = "debit", amount = "3800.00" },
{ account = "1200", side = "credit", amount = "3800.00" }, { account = "1200", side = "credit", amount = "3800.00" },
@@ -103,6 +104,7 @@ lines = [
[[events]] [[events]]
date = "2026-03-30" date = "2026-03-30"
description = "Pay Dana's bill" description = "Pay Dana's bill"
older = true
payee = "Dana Illustration" payee = "Dana Illustration"
lines = [ lines = [
{ account = "2000", side = "debit", amount = "1250.00" }, { account = "2000", side = "debit", amount = "1250.00" },
@@ -33,7 +33,7 @@ code = "1000"
name = "Cash" name = "Cash"
type = "asset" type = "asset"
[[setup.accounts]] [[setup.accounts]]
code = "1200" code = "1500"
name = "Equipment" name = "Equipment"
type = "asset" type = "asset"
[[setup.accounts]] [[setup.accounts]]
@@ -93,7 +93,7 @@ lines = [
date = "2026-03-05" date = "2026-03-05"
description = "Test devices for Beta" description = "Test devices for Beta"
lines = [ 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" }, { account = "1000", side = "credit", amount = "900.00" },
] ]
[[events]] [[events]]
+370 -27
View File
@@ -4,8 +4,16 @@
//! //!
//! * `create_invoice`: Dr `1200` Accounts Receivable, Cr `4000` Revenue. //! * `create_invoice`: Dr `1200` Accounts Receivable, Cr `4000` Revenue.
//! * `create_bill`: Dr `6999` General Expenses, Cr `2000` Accounts Payable. //! * `create_bill`: Dr `6999` General Expenses, Cr `2000` Accounts Payable.
//! * `receive_payment`: Dr Cash, Cr A/R. Updates the invoice's paid amount. //! * `receive_payment`: Dr Cash, Cr A/R.
//! * `pay_bill`: Dr A/P, Cr Cash. Updates the bill's paid amount. //! * `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 //! The control accounts (`1200`, `2000`) and the default revenue/expense
//! accounts (`4000`, `6999`) are created on demand if missing. Invoices and //! 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)] #[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum DocumentStatus { pub enum DocumentStatus {
/// Nothing paid yet. /// Nothing paid yet.
@@ -82,6 +93,320 @@ pub enum DocumentStatus {
Paid, 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<UnattachedLine>,
/// balance - documents_total - the unattached lines: always zero (both
/// sides sum the same lines); anything else is an engine error.
pub difference: Decimal,
/// The role's documents needing attention as of the day: out of
/// bounds ([`DocState::out_of_bounds`]: a credit balance, which aging
/// leaves out, or more owed than the total) or unattached
/// ([`DocStatus::Unattached`]). Listed on the role's first account.
pub attention: Vec<DocAttention>,
}
/// A document the control check lists for attention ([`ControlCheck::attention`]).
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct DocAttention {
/// The document.
pub doc: DocRef,
/// Its status as of the day.
pub status: DocStatus,
/// Its total.
pub total: Decimal,
/// What it owes as of the day.
pub outstanding: Decimal,
}
/// How a payment is recorded beyond its amount (plan P4b.6, D-40): the
/// payment method it moved through, tagged on its entry in the same call
/// (so a card-paid bill is left out of 1099-NEC without a second call),
/// and the processor's or bank's own reference (a charge id, a trace or
/// check number), carried in the entry's description.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PaymentDetails {
/// The payment method, when known.
pub instrument: Option<crate::InstrumentId>,
/// The reference, when there is one: one line of text.
pub reference: Option<String>,
}
/// The longest payment reference taken, in characters.
pub const MAX_REFERENCE_CHARS: usize = 200;
impl PaymentDetails {
/// The payment entry's description: `base`, then the reference when
/// there is one (`base (ref R)`).
pub fn description(&self, base: &str) -> String {
match self.reference.as_deref().map(str::trim) {
Some(r) if !r.is_empty() => format!("{base} (ref {r})"),
_ => base.to_string(),
}
}
/// Refuse a reference that isn't one line of at most
/// [`MAX_REFERENCE_CHARS`] characters. A blank one is none. (A bank
/// trace number is fifteen digits, so no nine-digit scan here.)
pub fn check(&self) -> Result<(), LedgerError> {
if let Some(r) = &self.reference
&& (r.contains(['\n', '\r']) || r.chars().count() > MAX_REFERENCE_CHARS)
{
return Err(LedgerError::InvalidPayment(format!(
"a payment's reference is one line of at most {MAX_REFERENCE_CHARS} characters"
)));
}
Ok(())
}
}
/// One line on an invoice or bill. /// One line on an invoice or bill.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct DocumentLine { pub struct DocumentLine {
@@ -111,9 +436,22 @@ pub struct Invoice {
pub due_date: NaiveDate, pub due_date: NaiveDate,
/// Line items. /// Line items.
pub lines: Vec<DocumentLine>, pub lines: Vec<DocumentLine>,
/// 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, 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, pub status: DocumentStatus,
/// The auto-posted journal entry. /// The auto-posted journal entry.
pub entry_id: u64, pub entry_id: u64,
@@ -168,26 +506,18 @@ pub fn document_parts(
.collect() .collect()
} }
/// What is still owed on a document: its total less what was paid.
fn document_balance(total: Decimal, paid: Decimal, what: &str) -> Result<Decimal, LedgerError> {
total
.checked_sub(paid)
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("{what}'s balance")))
}
impl Invoice { impl Invoice {
/// Total of all lines ([`document_total`]). /// Total of all lines ([`document_total`]).
pub fn total(&self) -> Result<Decimal, LedgerError> { pub fn total(&self) -> Result<Decimal, LedgerError> {
document_total(&self.lines, &format!("invoice {}", self.id)) 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<Decimal, LedgerError> { pub fn balance(&self) -> Result<Decimal, LedgerError> {
document_balance( self.total()?;
self.total()?, Ok(self.owed)
self.amount_paid,
&format!("invoice {}", self.id),
)
} }
} }
@@ -204,9 +534,22 @@ pub struct Bill {
pub due_date: NaiveDate, pub due_date: NaiveDate,
/// Line items. /// Line items.
pub lines: Vec<DocumentLine>, pub lines: Vec<DocumentLine>,
/// 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, 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, pub status: DocumentStatus,
/// The auto-posted journal entry. /// The auto-posted journal entry.
pub entry_id: u64, pub entry_id: u64,
@@ -218,13 +561,12 @@ impl Bill {
document_total(&self.lines, &format!("bill {}", self.id)) 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<Decimal, LedgerError> { pub fn balance(&self) -> Result<Decimal, LedgerError> {
document_balance( self.total()?;
self.total()?, Ok(self.owed)
self.amount_paid,
&format!("bill {}", self.id),
)
} }
} }
@@ -263,7 +605,8 @@ pub fn validate_due_date(date: NaiveDate, due_date: NaiveDate) -> Result<(), Led
Ok(()) 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 { pub fn derive_status(paid: Decimal, total: Decimal) -> DocumentStatus {
if paid.is_zero() { if paid.is_zero() {
DocumentStatus::Open DocumentStatus::Open
+18 -10
View File
@@ -1030,7 +1030,8 @@ type KindOrder = (u8, u8, String);
/// that part, equipment first, to its payables lines and to its payments, /// 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 /// 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 /// 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)] #[derive(Default)]
struct BillSplits { struct BillSplits {
/// By (entry, line index): the investing part of that payables line, /// By (entry, line index): the investing part of that payables line,
@@ -1077,15 +1078,20 @@ fn bill_splits(roles: &Roles<'_>, chains: &Chains) -> Result<BillSplits, LedgerE
left -= part; left -= part;
} }
} }
// Each payment pays the equipment part first, as the bill records // Each payment pays the equipment part first, in the order paid:
// them, in order: the part its payables carried (none when the // the part its payables carried (none when the payable is
// payable is financing: its payments are repayments already). // financing: its payments are repayments already). A payment's
// A payment's own amount bounds what it paid of this bill. A // own amount bounds what it paid of this bill. A reversed payment
// reversed payment still counts against what later payments carry // paid nothing for good: its reversal unpays it on the reversal's
// (the bill keeps it as paid), but not against what is unpaid at a // date, and it leaves the equipment part for the payment that
// period's end after the reversal. // stands (P4b.2 gap 6: it used to keep it, as the bill did).
let mut owed = equipment - left; let mut owed = equipment - left;
for &(pay, amount) in ledger.bill_payments(bill.id) { let payments = ledger
.doc_lines(crate::DocRef::Bill(bill.id))
.into_iter()
.filter(|l| l.movement == crate::Movement::Payment && !l.reversal);
for pl in payments {
let (pay, amount) = (pl.entry, -pl.amount);
let Some(p) = ledger.get_entry(pay) else { let Some(p) = ledger.get_entry(pay) else {
continue; continue;
}; };
@@ -1111,7 +1117,9 @@ fn bill_splits(roles: &Roles<'_>, chains: &Chains) -> Result<BillSplits, LedgerE
undo = -undo; undo = -undo;
cur = r; cur = r;
} }
owed -= part; if ledger.in_effect(p.id()) {
owed -= part;
}
this -= part; this -= part;
} }
} }
+103 -3
View File
@@ -228,9 +228,10 @@ pub enum LedgerError {
role: String, role: String,
}, },
/// A balance sheet role (receivables, payables, withholdings, retained /// A balance sheet role (withholdings, retained earnings) can't move
/// earnings) can't move while its account holds a balance: the control /// while its account holds a balance: the control account must keep
/// account must keep agreeing with what it controls. /// agreeing with what it controls. Receivables and payables refuse
/// with [`Self::DocumentRoleHoldsBalance`] instead.
#[error( #[error(
"{role} is in account {code} \"{name}\", which holds {balance}; move that balance to the \ "{role} is in account {code} \"{name}\", which holds {balance}; move that balance to the \
new account with a journal entry first, then assign the role" new account with a journal entry first, then assign the role"
@@ -608,6 +609,105 @@ pub enum LedgerError {
#[error("payment of {0} exceeds remaining balance of {1}")] #[error("payment of {0} exceeds remaining balance of {1}")]
Overpayment(Decimal, Decimal), Overpayment(Decimal, Decimal),
/// A payment's details were refused (its reference): the message says
/// why.
#[error("{0}")]
InvalidPayment(String),
/// Receivables or payables can't move while their account holds a
/// balance (rules.md B-23, B-38): that balance is the documents', and
/// a hand entry moving it is refused by the control guard; it moves
/// with its documents (plan P4b S7).
#[error(
"{role} is in account {code} \"{name}\", which holds {balance} owed on its documents; \
the role can't move until that is settled (paid or voided) or until moving a balance \
with its documents is built: a hand entry to the account is refused"
)]
DocumentRoleHoldsBalance {
/// The role, e.g. "accounts receivable".
role: String,
/// The code of the account filling it now.
code: String,
/// That account's name.
name: String,
/// Its balance.
balance: rust_decimal::Decimal,
},
/// Receivables or payables can't move to an account holding lines
/// that are no document's (rules.md B-38 INVARIANT 1): they would
/// become hand lines on a control account.
#[error(
"account {code} \"{name}\" holds lines no invoice or bill carries (entry {entry} first), \
so it can't take the {role} role: give the role an account of its own"
)]
RoleAccountHasHandLines {
/// The role, e.g. "accounts receivable".
role: String,
/// The account's code.
code: String,
/// Its name.
name: String,
/// The first posted entry with such a line.
entry: u64,
},
/// A payment's deposit or payer account must be a postable asset or
/// liability that holds no role: a bank, a card, a member's payable
/// (plan P4b.3, "Payment accounts").
#[error(
"account {code} \"{name}\" can't take a payment: {why}; pay into or from a bank or card account (or a member's payable)"
)]
PaymentAccount {
/// The account's code.
code: String,
/// Its name.
name: String,
/// Why not, e.g. "it holds the accounts receivable role".
why: String,
},
/// A line on a receivables or payables control account carries no
/// document (rules.md B-23, B-38): the control account must always
/// equal its documents, so only the document commands post there.
#[error(
"account {account} is a control account (its balance is its invoices or bills): {at} can't be posted there by hand. Use books.create_invoice, books.receive_payment, books.create_bill or books.pay_bill; to void a document, books.reverse_entry of its entry"
)]
UntaggedControlLine {
/// The account's code and name, e.g. `1200 "Accounts Receivable"`.
account: String,
/// Which line, e.g. "line 1 of entry 14", "line 0 of the
/// recurring template".
at: String,
},
/// A reversal or payment would leave a document owing less than nothing
/// or more than its total at some date (rules.md B-38 INVARIANT 2): the
/// message says which document, when, and what to reverse first.
#[error("{0}")]
DocumentBounds(String),
/// A reversal of an entry an invoice or bill owns is dated before that
/// entry (rules.md B-9, B-38): a correction comes after what it
/// corrects.
#[error(
"entry {entry} belongs to {document} and is dated {original}: its reversal can't be dated before it, on {date}"
)]
DocumentReversalBeforeOriginal {
/// The entry reversed.
entry: u64,
/// The document, e.g. "invoice 7".
document: String,
/// The entry's date.
original: chrono::NaiveDate,
/// The reversal's date.
date: chrono::NaiveDate,
},
/// A document can't be voided as asked: the message says why.
#[error("{0}")]
InvalidVoid(String),
/// Invoice/bill due date is before its date. /// Invoice/bill due date is before its date.
#[error("due date {0} is before document date")] #[error("due date {0} is before document date")]
DueBeforeDate(String), DueBeforeDate(String),
+6 -3
View File
@@ -370,8 +370,9 @@ mod tests {
/// Hand-worked: A contributes 300 cash and a 700 laptop in one entry /// Hand-worked: A contributes 300 cash and a 700 laptop in one entry
/// (Dr 1000 300, Dr 1500 700 / Cr 3000 1000); B contributes a 200 /// (Dr 1000 300, Dr 1500 700 / Cr 3000 1000); B contributes a 200
/// receivable-backed promise (Dr 1200 200 / Cr 3001 200, cash by /// receivable-backed promise (Dr 1200 200 / Cr 3001 200, as an earlier
/// INVARIANT 3) and pays a 50 expense (Dr 6999 50 / Cr 3001 50). /// version could write it; cash by INVARIANT 3) and pays a 50 expense
/// (Dr 6999 50 / Cr 3001 50).
#[test] #[test]
fn contributions_split_into_cash_and_property() { fn contributions_split_into_cash_and_property() {
let mut l = Ledger::new(Currency::USD); let mut l = Ledger::new(Currency::USD);
@@ -402,7 +403,9 @@ mod tests {
(equip, Side::Debit, "700"), (equip, Side::Debit, "700"),
(a, Side::Credit, "1000"), (a, Side::Credit, "1000"),
]); ]);
post(&[(ar, Side::Debit, "200"), (b, Side::Credit, "200")]); // A hand line on receivables: only books an earlier version wrote
// hold one now (the control guard, rules.md B-38), so replayed.
crate::parties::replaying(|| post(&[(ar, Side::Debit, "200"), (b, Side::Credit, "200")]));
post(&[(exp, Side::Debit, "50"), (b, Side::Credit, "50")]); post(&[(exp, Side::Debit, "50"), (b, Side::Credit, "50")]);
let members = [ let members = [
MemberCapital { MemberCapital {
+112
View File
@@ -1512,6 +1512,118 @@ pub fn unrecovered_cost<T: Amount>(revenue: T, expenses: T) -> Option<T> {
Some(if gap > T::ZERO { gap } else { T::ZERO }) 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<T: Amount>(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<T: Amount>(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<T: Amount>(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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -48,8 +48,9 @@ pub mod tax_capital;
pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side}; pub use account::{Account, AccountId, AccountType, ChartOfAccounts, Side};
pub use arap::{ pub use arap::{
AgingRow, Bill, BillId, Customer, CustomerId, DocumentLine, DocumentStatus, Invoice, InvoiceId, AgingRow, Bill, BillId, ControlCheck, Customer, CustomerId, DocLine, DocRef, DocState,
Vendor, VendorId, DocStatus, DocTag, DocumentLine, DocumentStatus, Invoice, InvoiceId, Movement, PaymentDetails,
UnattachedLine, Vendor, VendorId,
}; };
pub use assets::{ pub use assets::{
Asset, AssetBasis, AssetCorrection, AssetId, AssetStatus, BasisRule, DepreciationMethod, Asset, AssetBasis, AssetCorrection, AssetId, AssetStatus, BasisRule, DepreciationMethod,
+8
View File
@@ -512,6 +512,14 @@ pub fn replaying<T>(f: impl FnOnce() -> T) -> T {
f() 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 /// The separators P2 (and every version before P3) read between a TIN's
/// digits: space, hyphen, dot, slash, underscore, the no-break space and /// digits: space, hyphen, dot, slash, underscore, the no-break space and
/// the dashes U+2010 to U+2014 and U+2212. /// the dashes U+2010 to U+2014 and U+2212.
+85
View File
@@ -1807,6 +1807,91 @@ fn unrecovered_cost_laws() {
kani::cover!(u == 0 && revenue > expenses, "repaid, and more"); 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 // The checker itself
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+34 -17
View File
@@ -383,6 +383,12 @@ struct Event {
/// The payment method (an `setup.instruments` label) it was paid with. /// The payment method (an `setup.instruments` label) it was paid with.
#[serde(default)] #[serde(default)]
instrument: Option<String>, instrument: Option<String>,
/// 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 /// 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" "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!( assert!(
e.payee.is_none() || kind == "lines", 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" "{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"); let day = || when.expect("checked: dated");
match kind { match kind {
"lines" => { "lines" => {
let entry = self.store.create_entry(day(), description)?; let post = |this: &mut Self| -> Result<(), StoreError> {
for (i, line) in e.lines.iter().enumerate() { let entry = this.store.create_entry(day(), description)?;
let side = match line.side.as_str() { for (i, line) in e.lines.iter().enumerate() {
"debit" => Side::Debit, let side = match line.side.as_str() {
"credit" => Side::Credit, "debit" => Side::Debit,
other => panic!("{what}: side {other:?}"), "credit" => Side::Credit,
}; other => panic!("{what}: side {other:?}"),
let amount = Money::new(money(&line.amount), self.currency); };
let account = self.id(&line.account); let amount = Money::new(money(&line.amount), this.currency);
self.store.add_line(entry, account, side, amount)?; let account = this.id(&line.account);
// Tagged as books.add_line tags it: after the line. this.store.add_line(entry, account, side, amount)?;
if let Some(p) = &line.project { // Tagged as books.add_line tags it: after the line.
let class = *self.projects.get(p).unwrap_or_else(|| { if let Some(p) = &line.project {
panic!("{what}: project {p:?} not in setup.projects") let class = *this.projects.get(p).unwrap_or_else(|| {
}); panic!("{what}: project {p:?} not in setup.projects")
self.store.set_line_class(entry, i, Some(class))?; });
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" => { "reverses" => {
let of = e.reverses.expect("checked: a reversal"); let of = e.reverses.expect("checked: a reversal");
+20 -10
View File
@@ -114,11 +114,17 @@ fn pay(
cr: AccountId, cr: AccountId,
amt: &str, amt: &str,
) -> EntryId { ) -> EntryId {
let e = l.create_entry(date, "payment"); // As replay posts it: money from a vendor on receivables (1200) by
let m = Money::new(dec(amt), Currency::USD); // hand, as books an earlier version wrote can hold it; the control
l.add_line(e, dr, Side::Debit, m).unwrap(); // guard (rules.md B-38) refuses that as new work.
l.add_line(e, cr, Side::Credit, m).unwrap(); let e = ledger_core::parties::replaying(|| {
l.post_entry(e).unwrap(); 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 { if let Some(v) = vendor {
l.set_entry_vendor(e, Some(v)).unwrap(); l.set_entry_vendor(e, Some(v)).unwrap();
} }
@@ -2433,11 +2439,15 @@ proptest! {
if dr == cr { if dr == cr {
continue; continue;
} }
let e = l.create_entry(d(2026, month, 10), "generated"); // As replay posts it (a hand line on receivables: see `pay`).
let m = Money::new(Decimal::new(i64::from(cents), 2), Currency::USD); let e = ledger_core::parties::replaying(|| {
l.add_line(e, accounts[dr], Side::Debit, m).unwrap(); let e = l.create_entry(d(2026, month, 10), "generated");
l.add_line(e, accounts[cr], Side::Credit, m).unwrap(); let m = Money::new(Decimal::new(i64::from(cents), 2), Currency::USD);
l.post_entry(e).unwrap(); 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(); l.set_entry_vendor(e, Some(vendors[v])).unwrap();
if let Some(i) = methods[method % methods.len()] { if let Some(i) = methods[method % methods.len()] {
l.set_entry_instrument(e, Some(i)).unwrap(); l.set_entry_instrument(e, Some(i)).unwrap();
+97 -38
View File
@@ -32,11 +32,17 @@ fn usd(amount: u64) -> Money {
/// Post a balanced entry in one call. Panics on programmer error: fixtures /// Post a balanced entry in one call. Panics on programmer error: fixtures
/// must be valid by construction. /// must be valid by construction.
fn post(ledger: &mut Ledger, date: NaiveDate, description: &str, lines: &[(AccountId, Side, u64)]) { fn post(ledger: &mut Ledger, date: NaiveDate, description: &str, lines: &[(AccountId, Side, u64)]) {
let id = ledger.create_entry(date, description); // As replay posts it: these books hold hand lines on receivables or
for (account, side, amount) in lines { // payables (1200, 2000), which books an earlier version wrote still
ledger.add_line(id, *account, *side, usd(*amount)).unwrap(); // hold and every report must read; the control guard (rules.md B-38)
} // refuses them as new work.
ledger.post_entry(id).unwrap(); 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 { struct Company {
@@ -649,21 +655,26 @@ fn random_balanced_journals_preserve_invariants() {
c.rent, c.rent,
]; ];
for i in 0..300u32 { // As replay posts them: random lines land on receivables and
let total = rng.next(100_000) + 100; // payables by hand, as books an earlier version wrote can hold them
let nd = (rng.next(3) + 1) as usize; // (the control guard, rules.md B-38, refuses them as new work).
let nc = (rng.next(3) + 1) as usize; ledger_core::parties::replaying(|| {
let id = ledger.create_entry(date(21), &format!("random {i}")); for i in 0..300u32 {
for part in split_total(&mut rng, total, nd) { let total = rng.next(100_000) + 100;
let acct = accounts[rng.next(accounts.len() as u64) as usize]; let nd = (rng.next(3) + 1) as usize;
ledger.add_line(id, acct, Side::Debit, usd(part)).unwrap(); 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. // Every invariant still holds after 300 random balanced entries.
let tb = trial_balance(&ledger); 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 /// Replay (for journaling stores) rebuilds invoice and bill records
/// exactly: ids follow the allocator, payments accumulate, vendors tag /// exactly: ids follow the allocator, each record tags its entry's control
/// the entries. A record replayed out of order is refused. /// line and each payment its entry's (rules.md B-38), vendors tag the
/// entries. A record replayed out of order is refused.
#[test] #[test]
fn replayed_documents_and_payments_rebuild_the_records() { fn replayed_documents_and_payments_rebuild_the_records() {
use ledger_core::DocumentStatus; use ledger_core::DocumentStatus;
let mut l = books_with("2000", "Accounts Payable", AccountType::Liability, false); 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 c = l.add_customer("Acme").unwrap();
let v = l.add_vendor("Vend").unwrap(); let v = l.add_vendor("Vend").unwrap();
let inv = l.next_invoice_id(); let inv = l.next_invoice_id();
let e = entry(&mut l, ar, revenue, 100);
assert_eq!( 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)) 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(); .unwrap();
// Each later record replays at the id the allocator now predicts. // Each later record replays at the id the allocator now predicts.
let mut ids = vec![inv]; let mut ids = vec![inv];
for entry in [13, 14] { for _ in 0..2 {
let next = l.next_invoice_id(); 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(); .unwrap();
ids.push(next); ids.push(next);
} }
@@ -1512,38 +1557,52 @@ fn replayed_documents_and_payments_rebuild_the_records() {
ids.dedup(); ids.dedup();
assert_eq!(ids.len(), 3); assert_eq!(ids.len(), 3);
assert!(ids.iter().all(|&i| l.get_invoice(i).is_some())); assert!(ids.iter().all(|&i| l.get_invoice(i).is_some()));
l.replay_invoice_payment(inv, Decimal::from(30), 11) for n in [30, 20] {
.unwrap(); let p = entry(&mut l, cash, ar, n);
l.replay_invoice_payment(inv, Decimal::from(20), 12) l.replay_invoice_payment(inv, Decimal::from(n), p).unwrap();
.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(); let i = l.get_invoice(inv).unwrap();
assert_eq!(i.amount_paid, Decimal::from(50)); assert_eq!(i.amount_paid, Decimal::from(50));
assert_eq!(i.balance().unwrap(), Decimal::from(50)); assert_eq!(i.balance().unwrap(), Decimal::from(50));
assert_eq!(i.status, DocumentStatus::Partial); 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 bill = l.next_bill_id();
let e = entry(&mut l, expense, ap, 100);
assert_eq!( 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)) Err(LedgerError::ReplayIdMismatch(bill + 1, bill))
); );
assert_eq!(l.entry_vendor(20), None); assert_eq!(l.entry_vendor(e), None);
l.replay_bill(bill, v, date(5), date(30), doc(100), 20) l.replay_bill(bill, v, date(5), date(30), doc(100), e)
.unwrap(); .unwrap();
assert_eq!(l.entry_vendor(20), Some(v)); assert_eq!(l.entry_vendor(e), Some(v));
let mut ids = vec![bill]; let mut ids = vec![bill];
for entry in [23, 24] { for _ in 0..2 {
let next = l.next_bill_id(); 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(); .unwrap();
ids.push(next); ids.push(next);
} }
ids.sort(); ids.sort();
ids.dedup(); ids.dedup();
assert_eq!(ids.len(), 3); assert_eq!(ids.len(), 3);
l.replay_bill_payment(bill, Decimal::from(30), 21).unwrap(); let mut last = 0;
l.replay_bill_payment(bill, Decimal::from(70), 22).unwrap(); 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(); let b = l.get_bill(bill).unwrap();
assert_eq!(b.amount_paid, Decimal::from(100)); assert_eq!(b.amount_paid, Decimal::from(100));
assert_eq!(b.status, DocumentStatus::Paid); assert_eq!(b.status, DocumentStatus::Paid);
assert_eq!(l.entry_vendor(22), Some(v)); assert_eq!(l.entry_vendor(last), Some(v));
} }
+4 -1
View File
@@ -360,7 +360,10 @@ impl Model {
// Commands // 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 /// New names: a free one, its own in other capitals and spacing, a
/// sibling's (the accounts are named for their codes), blank, nine /// sibling's (the accounts are named for their codes), blank, nine
/// digits, the engine's own name for retained earnings. /// digits, the engine's own name for retained earnings.
+7 -4
View File
@@ -173,9 +173,10 @@ fn net_for(lines: &[(AccountId, Side, Decimal)], account: AccountId) -> Decimal
// ------------------------------------------------- Invoice / Bill methods // ------------------------------------------------- Invoice / Bill methods
/// Oracle: Invoice doc: total() is "Total of all lines", balance() is /// 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] #[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 { let inv = Invoice {
id: 7, id: 7,
customer_id: 1, 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), due_date: d(2026, 3, 31),
lines: vec![line("a", "100.10"), line("b", "0.05"), line("c", "49.85")], lines: vec![line("a", "100.10"), line("b", "0.05"), line("c", "49.85")],
amount_paid: dec("60.00"), amount_paid: dec("60.00"),
owed: dec("90.00"),
status: DocumentStatus::Partial, status: DocumentStatus::Partial,
entry_id: 1, 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")); 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] #[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 { let bill = Bill {
id: 3, id: 3,
vendor_id: 2, 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), due_date: d(2026, 3, 1),
lines: vec![line("x", "19.99"), line("y", "0.01")], lines: vec![line("x", "19.99"), line("y", "0.01")],
amount_paid: dec("20.00"), amount_paid: dec("20.00"),
owed: Decimal::ZERO,
status: DocumentStatus::Paid, status: DocumentStatus::Paid,
entry_id: 9, entry_id: 9,
}; };
+10 -14
View File
@@ -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 /// A replayed payment is as journaled: no bound (rules.md B-28), and no
/// paid amount past it is refused, never wrapped). The command's bound is /// refusal: it tags the line of its entry that carries it, or (an entry
/// the balance (Overpayment). /// it doesn't match) nothing. The command's bound is what the invoice
/// owes (Overpayment).
#[test] #[test]
fn replayed_payments_are_as_journaled() { fn replayed_payments_are_as_journaled() {
let mut b = books(); 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), .receive_payment(invoice, d(2026, 1, 15), too_big(), b.cash),
Err(LedgerError::Overpayment(..)) Err(LedgerError::Overpayment(..))
)); ));
let before = b.ledger.clone();
b.ledger b.ledger
.replay_invoice_payment(invoice, too_big(), 98) .replay_invoice_payment(invoice, too_big(), 98)
.unwrap(); .unwrap();
b.ledger b.ledger
.replay_invoice_payment(invoice, Decimal::MAX - too_big(), 99) .replay_invoice_payment(invoice, Decimal::MAX - too_big(), 99)
.unwrap(); .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!( assert_eq!(
b.ledger.get_invoice(invoice).unwrap().amount_paid, b.ledger.get_invoice(invoice).unwrap().balance(),
Decimal::MAX Ok(Decimal::new(10000, 2))
);
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
); );
} }
+15 -9
View File
@@ -50,15 +50,21 @@ impl Books {
} }
fn post(&mut self, date: NaiveDate, desc: &str, lines: &[(&str, Side, &str)]) -> EntryId { fn post(&mut self, date: NaiveDate, desc: &str, lines: &[(&str, Side, &str)]) -> EntryId {
let e = self.l.create_entry(date, desc); // As replay posts it: these books hold hand lines on receivables
for &(code, side, amount) in lines { // or payables (1200, 2000), which books an earlier version wrote
let a = self.id(code); // still hold and every report must read; the control guard
self.l // (rules.md B-38) refuses them as new work.
.add_line(e, a, side, Money::new(x(amount), Currency::USD)) ledger_core::parties::replaying(|| {
.unwrap(); let e = self.l.create_entry(date, desc);
} for &(code, side, amount) in lines {
self.l.post_entry(e).unwrap(); let a = self.id(code);
e 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 { fn reverse(&mut self, e: EntryId, date: NaiveDate) -> EntryId {
@@ -201,10 +201,15 @@ fn reversing_a_tagged_document_takes_its_projects_to_zero() {
(t.revenue, t.expenses) (t.revenue, t.expenses)
}; };
assert_eq!(to_date(&b.l, z), (cents(300_000), cents(120_000))); 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) let pay =
.unwrap(); 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, z), (cents(300_000), cents(120_000)));
assert_eq!(to_date(&b.l, h), (cents(20_000), Decimal::ZERO)); 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 [ for e in [
b.l.get_invoice(inv).unwrap().entry_id, b.l.get_invoice(inv).unwrap().entry_id,
b.l.get_bill(bill).unwrap().entry_id, b.l.get_bill(bill).unwrap().entry_id,
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -2468,7 +2468,7 @@ proptest! {
let mut l = Ledger::new(Currency::USD); let mut l = Ledger::new(Currency::USD);
let ids: Vec<_> = [ let ids: Vec<_> = [
("1000", AccountType::Asset), ("1000", AccountType::Asset),
("2000", AccountType::Liability), ("2100", AccountType::Liability),
("4000", AccountType::Revenue), ("4000", AccountType::Revenue),
("6100", AccountType::Expense), ("6100", AccountType::Expense),
] ]
@@ -2564,7 +2564,7 @@ proptest! {
let mut l = Ledger::new(Currency::USD); let mut l = Ledger::new(Currency::USD);
let ids: Vec<_> = [ let ids: Vec<_> = [
("1000", AccountType::Asset), ("1000", AccountType::Asset),
("2000", AccountType::Liability), ("2100", AccountType::Liability),
("4000", AccountType::Revenue), ("4000", AccountType::Revenue),
("6100", AccountType::Expense), ("6100", AccountType::Expense),
] ]
+2 -2
View File
@@ -28,7 +28,7 @@ fn usd(c: i64) -> Money {
Money::new(cents(c), Currency::USD) 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 { struct Books {
l: Ledger, l: Ledger,
cash: u64, cash: u64,
@@ -42,7 +42,7 @@ fn books() -> Books {
let mut l = Ledger::new(Currency::USD); let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap(); let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let cash = add("1000", AccountType::Asset); 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 revenue = add("4000", AccountType::Revenue);
let contractors = add("6000", AccountType::Expense); let contractors = add("6000", AccountType::Expense);
let rent = add("6200", AccountType::Expense); let rent = add("6200", AccountType::Expense);
@@ -283,22 +283,43 @@ fn a_draft_of_decimals_largest_is_refused_where_summed() {
assert!(why.contains("too large to compute"), "{why}"); assert!(why.contains("too large to compute"), "{why}");
} }
/// A document is restored as journaled: its total and balance are exact, /// A document is restored as journaled: its total is exact, or refused
/// or refused past exact arithmetic; aging sums by party, checked. /// 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] #[test]
fn documents_and_aging_are_exact_or_refused() { fn documents_and_aging_are_exact_or_refused() {
let mut l = Ledger::new(Currency::USD); 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 customer = l.add_customer("Client").unwrap();
let line = |a: Decimal| DocumentLine { let line = |a: Decimal| DocumentLine {
description: "x".into(), description: "x".into(),
amount: a, amount: a,
class_id: None, 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![ let fine = vec![
line(Decimal::new(1, 11)), line(Decimal::new(1, 11)),
line("9.99999999999".parse().unwrap()), 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(); .unwrap();
let inv = l.get_invoice(1).unwrap(); let inv = l.get_invoice(1).unwrap();
assert_eq!(inv.total(), Ok(Decimal::TEN)); 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. // 62 days past due on June 1st: 61-90.
assert_eq!(rows[0].days_90, Decimal::TEN); assert_eq!(rows[0].days_90, Decimal::TEN);
assert_eq!(rows[0].total(), Ok(Decimal::TEN)); assert_eq!(rows[0].total(), Ok(Decimal::TEN));
// Two documents of half Decimal's largest each: each is exact, the // Two documents of half Decimal's largest each, their totals exact; no
// party's aging bucket is not. // line carries them (no entry could), so they owe nothing and age
// nowhere.
let half = Decimal::MAX / Decimal::TWO; let half = Decimal::MAX / Decimal::TWO;
for id in [2, 3] { for id in [2, 3] {
l.replay_invoice( l.replay_invoice(
@@ -317,40 +339,44 @@ fn documents_and_aging_are_exact_or_refused() {
d(2026, 3, 1), d(2026, 3, 1),
d(2026, 3, 31), d(2026, 3, 31),
vec![line(half)], vec![line(half)],
id, 100 + id,
) )
.unwrap(); .unwrap();
assert_eq!(l.get_invoice(id).unwrap().total(), Ok(half)); 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. // 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)); 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( l.replay_invoice(
4, 4,
customer, customer,
d(2026, 3, 1), d(2026, 3, 1),
d(2026, 3, 31), d(2026, 3, 31),
vec![line(Decimal::MAX), line(Decimal::MAX)], vec![line(Decimal::MAX), line(Decimal::MAX)],
4, 104,
) )
.unwrap(); .unwrap();
assert!(too_large(l.get_invoice(4).unwrap().total())); assert!(too_large(l.get_invoice(4).unwrap().total()));
assert!(too_large(l.get_invoice(4).unwrap().balance())); assert!(too_large(l.get_invoice(4).unwrap().balance()));
// A balance past exact arithmetic: a total of Decimal's largest paid assert!(too_large(l.ar_aging(d(2026, 6, 1))));
// a negative amount. assert!(too_large(l.invoice_state(4, d(2026, 6, 1))));
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()));
// Bills alike. // Bills alike.
let vendor = l.add_vendor("Supplier").unwrap(); let vendor = l.add_vendor("Supplier").unwrap();
l.replay_bill( l.replay_bill(
@@ -359,7 +385,7 @@ fn documents_and_aging_are_exact_or_refused() {
d(2026, 3, 1), d(2026, 3, 1),
d(2026, 3, 31), d(2026, 3, 31),
vec![line(Decimal::MAX), line(Decimal::ONE)], vec![line(Decimal::MAX), line(Decimal::ONE)],
6, 106,
) )
.unwrap(); .unwrap();
assert!(too_large(l.get_bill(1).unwrap().total())); assert!(too_large(l.get_bill(1).unwrap().total()));
+50 -32
View File
@@ -40,12 +40,18 @@ fn usd(cents: i64) -> Money {
} }
fn post(l: &mut Ledger, date: NaiveDate, desc: &str, lines: &[(AccountId, Side, i64)]) -> EntryId { fn post(l: &mut Ledger, date: NaiveDate, desc: &str, lines: &[(AccountId, Side, i64)]) -> EntryId {
let e = l.create_entry(date, desc); // As replay posts it: these books hold hand lines on receivables or
for &(a, s, cents) in lines { // payables (1200, 2000), which books an earlier version wrote still
l.add_line(e, a, s, usd(cents)).unwrap(); // hold and every report must read; the control guard (rules.md B-38)
} // refuses them as new work.
l.post_entry(e).unwrap(); ledger_core::parties::replaying(|| {
e 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 { 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_a = l.add_class("Alpha").unwrap();
let class_b = l.add_class("Beta").unwrap(); let class_b = l.add_class("Beta").unwrap();
let mut entries = Vec::new(); let mut entries = Vec::new();
for g in gens { // As replay posts it: these books hold hand lines on receivables or
let e = l.create_entry(g.date(), "gen"); // payables (1200, 2000), which books an earlier version wrote still
for (n, (i, side, cents)) in g.legs(scale).into_iter().enumerate() { // hold and every report must read; the control guard (rules.md B-38)
l.add_line(e, ids[i], side, usd(cents)).unwrap(); // refuses them as new work.
if tag { ledger_core::parties::replaying(|| {
let k = match g.classes[n] { for g in gens {
1 => Some(class_a), let e = l.create_entry(g.date(), "gen");
2 => Some(class_b), for (n, (i, side, cents)) in g.legs(scale).into_iter().enumerate() {
_ => None, l.add_line(e, ids[i], side, usd(cents)).unwrap();
}; if tag {
l.set_line_class(e, n, k).unwrap(); 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 { Books {
l, l,
ids, ids,
@@ -2000,12 +2012,15 @@ proptest! {
let mut b = build(&gens); let mut b = build(&gens);
let tb0 = tb_lines(&trial_balance_as_of(&b.l, day(as_of))); 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 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"); // As replay posts it (hand lines on 1200 or 2000: see `post`).
for (i, side, cents) in extra.legs(1) { ledger_core::parties::replaying(|| {
b.l.add_line(e, b.ids[i], side, usd(cents)).unwrap(); let e = b.l.create_entry(extra.date(), "extra");
} for (i, side, cents) in extra.legs(1) {
b.l.post_entry(e).unwrap(); b.l.add_line(e, b.ids[i], side, usd(cents)).unwrap();
b.l.reverse_entry(e, extra.date(), "undo").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); 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(); 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::<Vec<_>>()); prop_assert_eq!(sl_nonzero(&pl1.revenue), pl0.0.into_iter().filter(|x| !x.1.is_zero()).collect::<Vec<_>>());
@@ -2027,13 +2042,16 @@ proptest! {
let gl = gl_key(&general_ledger(&b.l, s0, e0).unwrap()); let gl = gl_key(&general_ledger(&b.l, s0, e0).unwrap());
let cf = cf_key(&cash_flow(&b.l, &cfp).unwrap()); let cf = cf_key(&cash_flow(&b.l, &cfp).unwrap());
let snap = snapshot(&b.l).unwrap(); let snap = snapshot(&b.l).unwrap();
for g in &drafts { // As replay adds them (hand lines on 1200 or 2000: see `post`).
let id = b.l.create_entry(g.date(), "draft"); ledger_core::parties::replaying(|| {
for (n, (i, side, cents)) in g.legs(1).into_iter().enumerate() { for g in &drafts {
if g.mode == 0 && n == 1 { continue; } // some drafts unbalanced let id = b.l.create_entry(g.date(), "draft");
b.l.add_line(id, b.ids[i], side, usd(cents)).unwrap(); 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!(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!(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); prop_assert_eq!(bs_key(&balance_sheet_as_of(&b.l, e0).unwrap()), bs);
+69 -14
View File
@@ -99,14 +99,20 @@ fn bal(l: &Ledger, id: AccountId) -> Decimal {
l.account_balance(id).expect("known account") 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 { fn post(l: &mut Ledger, date: NaiveDate, lines: &[(AccountId, Side, &str)]) -> EntryId {
let id = l.try_create_entry(date, "test entry").unwrap(); ledger_core::parties::replaying(|| {
for (a, s, amt) in lines { let id = l.try_create_entry(date, "test entry").unwrap();
l.add_line(id, *a, *s, usd(amt)).unwrap(); for (a, s, amt) in lines {
} l.add_line(id, *a, *s, usd(amt)).unwrap();
l.post_entry(id).unwrap(); }
id l.post_entry(id).unwrap();
id
})
} }
/// The (code, side, amount) lines of an entry, sorted. /// 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 old = acct(&l, "1200").expect("1200 created by the invoice");
let new = add(&mut l, "1100", "Trade Receivables", AccountType::Asset); let new = add(&mut l, "1100", "Trade Receivables", AccountType::Asset);
match refuses(&mut l, "receivables", Some(new)) { match refuses(&mut l, "receivables", Some(new)) {
LedgerError::RoleHoldsBalance { LedgerError::DocumentRoleHoldsBalance {
role, role,
code, code,
balance, balance,
@@ -798,15 +804,29 @@ fn receivables_cannot_move_while_holding_a_balance_but_can_once_moved_out() {
assert_eq!(code, "1200"); assert_eq!(code, "1200");
assert_eq!(balance, dec("120.00")); 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( post(
&mut l, &mut l,
d(2026, 3, 2), d(2026, 3, 2),
&[(new, Side::Debit, "120.00"), (old, Side::Credit, "120.00")], &[(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)); assert_eq!(l.role_account(&RECEIVABLES), Some(new));
// The open invoice is now controlled by 1100: its payment credits it. // The open invoice is now controlled by 1100: its payment credits it.
let p = l let p = l
@@ -845,6 +865,21 @@ fn every_balance_sheet_role_holds_while_its_account_has_a_balance() {
&format!("Target {i}"), &format!("Target {i}"),
c.account_type, 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!( assert!(
matches!( matches!(
refuses(&mut l, c.key, Some(target)), 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(); .unwrap();
assert!(matches!( assert!(matches!(
refuses(&mut l, "payables", None), refuses(&mut l, "payables", None),
LedgerError::RoleHoldsBalance { .. } LedgerError::DocumentRoleHoldsBalance { .. }
)); ));
let _ = cash; let _ = cash;
} }
@@ -1121,7 +1156,7 @@ fn pool_books() -> (Ledger, Vec<AccountId>) {
} }
/// The reasons the spec gives to refuse an assignment, worked from the /// 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( fn predicted_refusals(
l: &Ledger, l: &Ledger,
c: &ControlAccount, c: &ControlAccount,
@@ -1142,10 +1177,27 @@ fn predicted_refusals(
} }
let now = l.role_account(c); let now = l.role_account(c);
let next = target.map(|a| a.id); 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) if balance_sheet(c)
&& next != now && next != now
&& let Some(cur) = now && let Some(cur) = now
&& !bal(l, cur).is_zero() && !bal(l, cur).is_zero()
&& (!documents || l.control_role_of(cur).is_some_and(|r| r.key == c.key))
{ {
why.push("balance"); why.push("balance");
} }
@@ -1156,7 +1208,10 @@ fn reason(e: &LedgerError) -> &'static str {
match e { match e {
LedgerError::ControlAccountMismatch { .. } => "mismatch", LedgerError::ControlAccountMismatch { .. } => "mismatch",
LedgerError::RoleTaken { .. } => "taken", LedgerError::RoleTaken { .. } => "taken",
LedgerError::RoleHoldsBalance { .. } => "balance", LedgerError::RoleHoldsBalance { .. } | LedgerError::DocumentRoleHoldsBalance { .. } => {
"balance"
}
LedgerError::RoleAccountHasHandLines { .. } => "handlines",
_ => "other", _ => "other",
} }
} }
+11 -11
View File
@@ -9,9 +9,9 @@
//! | Jan 2 | owner contributes 10,000 | 1000 Cash | 3000 Capital | //! | Jan 2 | owner contributes 10,000 | 1000 Cash | 3000 Capital |
//! | Jan 15 | client pays 2,500 | 1000 Cash | 4000 Revenue | //! | Jan 15 | client pays 2,500 | 1000 Cash | 4000 Revenue |
//! | Jan 20 | rent 800 | 6100 Rent | 1000 Cash | //! | 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 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 chrono::NaiveDate;
use ledger_core::reports::{ use ledger_core::reports::{
@@ -57,7 +57,7 @@ fn books(close_january: bool) -> Books {
.add_account("1000", "Cash", AccountType::Asset, None, false) .add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap(); .unwrap();
let card = l let card = l
.add_account("2000", "Card", AccountType::Liability, None, false) .add_account("2050", "Card", AccountType::Liability, None, false)
.unwrap(); .unwrap();
let capital = l let capital = l
.add_account("3000", "Capital", AccountType::Equity, None, false) .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. // 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(); let mid = balance_sheet_as_of(&b.ledger, d(2, 15)).unwrap();
assert_eq!(mid.total_assets, dec(12_700)); 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.current_earnings, dec(2_580));
assert_eq!(mid.total_liabilities + mid.total_equity, dec(12_700)); 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 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(); 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. // 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]; let cash = &gl.accounts[0];
assert_eq!(cash.beginning_balance, dec(11_700)); assert_eq!(cash.beginning_balance, dec(11_700));
@@ -320,7 +320,7 @@ fn general_ledger_runs_balances_by_account() {
p, p,
[ [
(dec(1_000), dec(0), "4000 Revenue", dec(12_700)), (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)); 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) .add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap(); .unwrap();
let card = l let card = l
.add_account("2000", "Card", AccountType::Liability, None, false) .add_account("2050", "Card", AccountType::Liability, None, false)
.unwrap(); .unwrap();
let soft = l let soft = l
.add_account("6000", "Software", AccountType::Expense, None, false) .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. // The debit side has two other accounts; each credit has one.
assert_eq!(split_of("6000"), ("-Split-".to_string(), e)); assert_eq!(split_of("6000"), ("-Split-".to_string(), e));
assert_eq!(split_of("1000"), ("6000 Software".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; /// 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) .add_account("1100", "Receivable", AccountType::Asset, None, false)
.unwrap(); .unwrap();
let ap = l let ap = l
.add_account("2000", "Payable", AccountType::Liability, None, false) .add_account("2010", "Payable", AccountType::Liability, None, false)
.unwrap(); .unwrap();
let loan = l let loan = l
.add_account("2500", "Loan", AccountType::Liability, None, false) .add_account("2500", "Loan", AccountType::Liability, None, false)
@@ -1144,7 +1144,7 @@ fn cash_flow_labels_say_which_way_each_balance_moved() {
rec(&feb), rec(&feb),
[ [
(s("Increase in 1100 Receivable"), dec(-100)), (s("Increase in 1100 Receivable"), dec(-100)),
(s("Increase in 2000 Payable"), dec(50)), (s("Increase in 2010 Payable"), dec(50)),
] ]
); );
assert_eq!( assert_eq!(
@@ -1167,7 +1167,7 @@ fn cash_flow_labels_say_which_way_each_balance_moved() {
rec(&mar), rec(&mar),
[ [
(s("Decrease in 1100 Receivable"), dec(100)), (s("Decrease in 1100 Receivable"), dec(100)),
(s("Decrease in 2000 Payable"), dec(-50)), (s("Decrease in 2010 Payable"), dec(-50)),
] ]
); );
assert_eq!( assert_eq!(
+56 -5
View File
@@ -179,9 +179,10 @@ pub fn period_month(period: &str) -> Option<(i32, u32)> {
/// 3. invoiced_ytd = Σ Invoice::total() over its customer record's /// 3. invoiced_ytd = Σ Invoice::total() over its customer record's
/// invoices dated in the year; billed_ytd the same for its vendor /// invoices dated in the year; billed_ytd the same for its vendor
/// record's bills. /// record's bills.
/// 4. open_invoices = Σ Invoice::balance() over its customer record's /// 4. open_invoices = Σ what each of its customer record's invoices
/// invoices; open_bills = Σ Bill::balance() over its vendor record's /// owes today (Books::owed_today: read from the ledger lines tagged
/// bills (any date: what is still owed today). /// 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 /// 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 /// document is restored as an earlier version journaled it, of any
/// size: rules.md B-28), never a wrong number. /// size: rules.md B-28), never a wrong number.
@@ -1138,7 +1139,10 @@ impl Books {
.and_then(|p| out.get_mut(&p)) .and_then(|p| out.get_mut(&p))
{ {
info.invoices.push(id); 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) { if in_year(inv.date) {
add(&mut info.invoiced_ytd, inv.total()); add(&mut info.invoiced_ytd, inv.total());
} }
@@ -1151,7 +1155,10 @@ impl Books {
.and_then(|p| out.get_mut(&p)) .and_then(|p| out.get_mut(&p))
{ {
info.bills.push(id); 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) { if in_year(bill.date) {
add(&mut info.billed_ytd, bill.total()); add(&mut info.billed_ytd, bill.total());
} }
@@ -1309,6 +1316,38 @@ impl Books {
.collect() .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<Option<ledger_core::DocState>, 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<Decimal, ledger_core::LedgerError> {
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 subjects whose documents an entry's page shows: the entry, and
/// the invoice, bill, or payroll run it records (those have no page /// the invoice, bill, or payroll run it records (those have no page
/// of their own). /// of their own).
@@ -2193,6 +2232,18 @@ fn disposal_true_up(
.map_or(Decimal::ZERO, |t| t - runs) .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<Option<ledger_core::DocState>, ledger_core::LedgerError>,
) -> Option<Decimal> {
match state {
Ok(st) => Some(st.map_or(Decimal::ZERO, |st| st.outstanding)),
Err(_) => None,
}
}
/// Entry ids a journal line refers to. /// Entry ids a journal line refers to.
pub fn entry_refs(line: &JournalLine) -> Vec<EntryId> { pub fn entry_refs(line: &JournalLine) -> Vec<EntryId> {
let p = &line.payload; let p = &line.payload;
+7 -4
View File
@@ -328,7 +328,10 @@ fn coming_up(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let Some(inv) = b.ledger.get_invoice(id) else { let Some(inv) = b.ledger.get_invoice(id) else {
continue; 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()) { if balance.is_some_and(|x| x.is_zero()) {
continue; continue;
} }
@@ -337,7 +340,7 @@ fn coming_up(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
.get_customer(inv.customer_id) .get_customer(inv.customer_id)
.map(|c| c.name.clone()) .map(|c| c.name.clone())
.unwrap_or_default(); .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 { items.push(Upcoming {
date: inv.due_date, date: inv.due_date,
label: format!( 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 { let Some(bill) = b.ledger.get_bill(id) else {
continue; 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()) { if balance.is_some_and(|x| x.is_zero()) {
continue; continue;
} }
@@ -645,7 +648,7 @@ mod tests {
let mut b = Build::new("coming-up"); let mut b = Build::new("coming-up");
let (cash, capital, rent) = (b.cash, b.capital, b.rent); let (cash, capital, rent) = (b.cash, b.capital, b.rent);
let accrued = let accrued =
b.s.add_account("2000", "Accrued", AccountType::Liability, None, false) b.s.add_account("2100", "Accrued", AccountType::Liability, None, false)
.unwrap(); .unwrap();
b.post(d(2026, 1, 2), cash, capital, 1_000_000); b.post(d(2026, 1, 2), cash, capital, 1_000_000);
let usd = |c: i64| Money::new(cents(c), Currency::USD); let usd = |c: i64| Money::new(cents(c), Currency::USD);
+5 -1
View File
@@ -714,7 +714,11 @@ fn parties(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let owed = (1..b.ledger.next_invoice_id()) let owed = (1..b.ledger.next_invoice_id())
.filter_map(|i| b.ledger.get_invoice(i)) .filter_map(|i| b.ledger.get_invoice(i))
.filter(|i| i.customer_id == c.id) .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| { ui.horizontal_wrapped(|ui| {
name(ui, &c.name, b.ledger.party_of_customer(c.id)); name(ui, &c.name, b.ledger.party_of_customer(c.id));
ui.with_layout( ui.with_layout(
+22 -7
View File
@@ -1400,13 +1400,15 @@ fn documents_card(ui: &mut Ui, b: &Books, info: &PartyInfo, go: &mut Option<Rout
.iter() .iter()
.filter_map(|i| b.ledger.get_invoice(*i)) .filter_map(|i| b.ledger.get_invoice(*i))
.map(|i| { .map(|i| {
let state = b.doc_today(ledger_core::DocRef::Invoice(i.id));
let owed = crate::books::owed_of(&state);
( (
format!("Invoice {}", i.id), format!("Invoice {}", i.id),
i.date, i.date,
i.due_date, i.due_date,
i.status, state,
i.total().ok(), i.total().ok(),
i.balance().ok(), owed,
i.entry_id, i.entry_id,
) )
}); });
@@ -1415,20 +1417,33 @@ fn documents_card(ui: &mut Ui, b: &Books, info: &PartyInfo, go: &mut Option<Rout
.iter() .iter()
.filter_map(|i| b.ledger.get_bill(*i)) .filter_map(|i| b.ledger.get_bill(*i))
.map(|i| { .map(|i| {
let state = b.doc_today(ledger_core::DocRef::Bill(i.id));
let owed = crate::books::owed_of(&state);
( (
format!("Bill {}", i.id), format!("Bill {}", i.id),
i.date, i.date,
i.due_date, i.due_date,
i.status, state,
i.total().ok(), i.total().ok(),
i.balance().ok(), owed,
i.entry_id, i.entry_id,
) )
}); });
let mut docs: Vec<_> = invoices.chain(bills).collect(); let mut docs: Vec<_> = invoices.chain(bills).collect();
docs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); 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 { for (name, date, due, state, total, balance, entry) in docs {
let late = !balance.is_some_and(|x| x.is_zero()) && due < b.today; // 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( let resp = row(
ui, ui,
w, w,
@@ -1441,7 +1456,7 @@ fn documents_card(ui: &mut Ui, b: &Books, info: &PartyInfo, go: &mut Option<Rout
false, false,
if late { p.warning } else { p.text }, if late { p.warning } else { p.text },
), ),
(fmt::words(&format!("{status:?}")), 90.0, false, p.muted), (status.to_string(), 90.0, false, p.muted),
(fmt::money_or(total), amt, true, p.text), (fmt::money_or(total), amt, true, p.text),
(fmt::money_or(balance), amt, true, p.text), (fmt::money_or(balance), amt, true, p.text),
], ],
+46
View File
@@ -2060,3 +2060,49 @@ fn the_journal_says_what_each_1099_record_did() {
] ]
); );
} }
/// A document dated after today is read as of its own date (Books::doc_today):
/// a post-dated bill still owes its total in the party's open bills (and
/// in Cash's "Coming up"), and is never late; one dated today or before
/// reads as of today (review of P4b S1: the viewer dropped post-dated
/// documents).
#[test]
fn a_post_dated_bill_still_shows_what_it_will_owe() {
let dir = TempDir::new("post-dated");
let data = dir.0.join("books-data");
let mut s = Store::create(&data, Currency::USD).unwrap();
let bank = s
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let vendor = s.add_vendor("Tobin Harlow Supply").unwrap();
let later = s
.create_bill(vendor, d(2026, 11, 1), d(2026, 12, 1), doc(45_000))
.unwrap();
let now = s
.create_bill(vendor, d(2026, 9, 1), d(2026, 9, 30), doc(10_000))
.unwrap();
s.pay_bill(now, d(2026, 9, 10), cents(4_000), bank).unwrap();
drop(s);
let b = load(&dir.0);
let ahead = b
.doc_today(ledger_core::DocRef::Bill(later))
.unwrap()
.expect("read as of its own date");
assert_eq!((ahead.outstanding, ahead.overdue_days), (cents(45_000), 0));
let late = b
.doc_today(ledger_core::DocRef::Bill(now))
.unwrap()
.unwrap();
assert_eq!(late.outstanding, cents(6_000));
assert!(late.overdue_days > 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))
);
}
+1 -1
View File
@@ -67,7 +67,7 @@ fn fixture(tag: &str) -> Fixture {
let mut s = Store::create(&data_dir, Currency::USD).unwrap(); 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 mut add = |code: &str, name: &str, ty| s.add_account(code, name, ty, None, false).unwrap();
let cash = add("1000", "Cash", AccountType::Asset); 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 revenue = add("4000", "Design Services", AccountType::Revenue);
let contractors = add("6000", "Contractors", AccountType::Expense); let contractors = add("6000", "Contractors", AccountType::Expense);
let rent = add("6200", "Rent", AccountType::Expense); let rent = add("6200", "Rent", AccountType::Expense);
+13 -7
View File
@@ -82,13 +82,19 @@ fn build(plan: &Plan, tag: &str) -> (TestDir, Source, ledger_core::Ledger) {
.iter() .iter()
.map(|(c, n, t)| s.add_account(c, n, *t, None, false).unwrap()) .map(|(c, n, t)| s.add_account(c, n, *t, None, false).unwrap())
.collect(); .collect();
for &(back, dr, cr, cents) in &plan.entries { // As replay posts them: random lines land on receivables and payables
let e = s.create_entry(plan.today - Days::new(back), "x").unwrap(); // by hand, as books an earlier version wrote can hold them (the
let m = Money::new(Decimal::new(cents, 2), Currency::USD); // control guard, rules.md B-38, refuses them as new work), and the
s.add_line(e, ids[dr], Side::Debit, m).unwrap(); // overview must still read them.
s.add_line(e, ids[cr], Side::Credit, m).unwrap(); ledger_core::parties::replaying(|| {
s.post_entry(e).unwrap(); 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( journal_settings(
&mut s, &mut s,
&format!( &format!(
+2 -2
View File
@@ -67,7 +67,7 @@ const CHART: &[(&str, &str, AccountType, Option<&str>)] = &[
("1010", "Reserve: Savings", AccountType::Asset, Some("1000")), ("1010", "Reserve: Savings", AccountType::Asset, Some("1000")),
("1100", "Receivables", AccountType::Asset, None), ("1100", "Receivables", AccountType::Asset, None),
("1500", "Equipment", AccountType::Asset, None), ("1500", "Equipment", AccountType::Asset, None),
("2000", "Card", AccountType::Liability, None), ("2050", "Card", AccountType::Liability, None),
("2100", "Loan", AccountType::Liability, None), ("2100", "Loan", AccountType::Liability, None),
("3000", "Capital — Ada", AccountType::Equity, None), ("3000", "Capital — Ada", AccountType::Equity, None),
("3001", "Capital — Grace", 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.starts_with("10") => QbAccountType::Bank,
AccountType::Asset if a.code == "1500" => QbAccountType::FixedAsset, AccountType::Asset if a.code == "1500" => QbAccountType::FixedAsset,
AccountType::Asset => QbAccountType::OtherCurrentAsset, 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::Liability => QbAccountType::OtherCurrentLiability,
AccountType::Equity => QbAccountType::Equity, AccountType::Equity => QbAccountType::Equity,
AccountType::Revenue => QbAccountType::Income, AccountType::Revenue => QbAccountType::Income,
@@ -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
+45 -37
View File
@@ -1920,33 +1920,39 @@ fn the_readme_names_the_entries_on_receivables_and_payables() {
] { ] {
s.add_account(code, name, ty, None, false).unwrap(); s.add_account(code, name, ty, None, false).unwrap();
} }
let invoiced = post( // Hand lines on receivables and payables, as books an earlier version
&mut s, // wrote hold them (the control guard, rules.md B-38, refuses them as
"2026-04-01", // new work): posted as replay posts them.
"Invoice 7", let (invoiced, billed, netted) = ledger_core::parties::replaying(|| {
&[ let invoiced = post(
("1200", Side::Debit, "500.00"), &mut s,
("4000", Side::Credit, "500.00"), "2026-04-01",
], "Invoice 7",
); &[
let billed = post( ("1200", Side::Debit, "500.00"),
&mut s, ("4000", Side::Credit, "500.00"),
"2026-04-02", ],
"Bill 3", );
&[ let billed = post(
("6000", Side::Debit, "80.00"), &mut s,
("2000", Side::Credit, "80.00"), "2026-04-02",
], "Bill 3",
); &[
let netted = post( ("6000", Side::Debit, "80.00"),
&mut s, ("2000", Side::Credit, "80.00"),
"2026-04-03", ],
"Netted", );
&[ let netted = post(
("2000", Side::Debit, "80.00"), &mut s,
("1200", Side::Credit, "80.00"), "2026-04-03",
], "Netted",
); &[
("2000", Side::Debit, "80.00"),
("1200", Side::Credit, "80.00"),
],
);
(invoiced, billed, netted)
});
let company = company(false); let company = company(false);
let cash = cash(); let cash = cash();
let p = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap(); 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, false,
) )
.unwrap(); .unwrap();
let one = post( let one = ledger_core::parties::replaying(|| {
&mut s, post(
"2026-04-01", &mut s,
"Owed", "2026-04-01",
&[ "Owed",
("1200", Side::Debit, "5.00"), &[
("1000", Side::Credit, "5.00"), ("1200", Side::Debit, "5.00"),
], ("1000", Side::Credit, "5.00"),
); ],
)
});
let p = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap(); let p = build_package(&s, &settings(&company, &cash), &request(false, None)).unwrap();
assert!(text(&p, "README.txt").contains(&format!( assert!(text(&p, "README.txt").contains(&format!(
"lines to accounts receivable or payable, in entry {one}: " "lines to accounts receivable or payable, in entry {one}: "
@@ -2037,7 +2045,7 @@ fn random_ledger(
for (code, name, ty) in [ for (code, name, ty) in [
("1000", "Checking", AccountType::Asset), ("1000", "Checking", AccountType::Asset),
("1100", "Receivables", AccountType::Asset), ("1100", "Receivables", AccountType::Asset),
("2000", "Card", AccountType::Liability), ("2050", "Card", AccountType::Liability),
("3000", "Capital", AccountType::Equity), ("3000", "Capital", AccountType::Equity),
("4000", "Sales", AccountType::Revenue), ("4000", "Sales", AccountType::Revenue),
("6000", "Rent", AccountType::Expense), ("6000", "Rent", AccountType::Expense),
+46
View File
@@ -61,6 +61,7 @@ pub const KNOWN_FEATURES: &[&str] = &[
DEPRECIATION_CATCH_UP, DEPRECIATION_CATCH_UP,
PROJECTS, PROJECTS,
DOCUMENT_LINE_PROJECTS, DOCUMENT_LINE_PROJECTS,
DOCUMENT_STANDING,
]; ];
/// Feature: `period.closed` closes to members with `"rounding": /// Feature: `period.closed` closes to members with `"rounding":
@@ -163,6 +164,16 @@ pub const PROJECTS: &str = "projects";
/// consent again before a document names one. /// consent again before a document names one.
pub const DOCUMENT_LINE_PROJECTS: &str = "document_line_projects"; 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. /// The line type that declares the journal format.
pub const FORMAT_SET: &str = "journal.format_set"; 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 /// This version's format: the newest it reads, every feature it
/// knows, each once, sorted (what new books and an upgrade declare). /// knows, each once, sorted (what new books and an upgrade declare).
#[test] #[test]
+12 -1
View File
@@ -5,7 +5,7 @@
//! is the current version. //! is the current version.
/// Current schema 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`. /// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
pub const MIGRATIONS: &[&str] = &[ 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; SELECT asset_id, period, entry_id, amount FROM asset_depreciation;
DROP TABLE asset_depreciation; DROP TABLE asset_depreciation;
ALTER TABLE asset_depreciation_v13 RENAME TO 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;
"#, "#,
]; ];
+151 -77
View File
@@ -3079,9 +3079,9 @@ impl Store {
Ok(live) Ok(live)
} }
/// Record a customer payment against an invoice. The payment entry (Dr /// Record a customer payment against an invoice
/// cash, Cr A/R) is journaled through the standard entry flow first, /// ([`Self::receive_payment_with`], with no payment method or
/// then `invoice.payment_received`. /// reference).
pub fn receive_payment( pub fn receive_payment(
&mut self, &mut self,
invoice_id: InvoiceId, invoice_id: InvoiceId,
@@ -3089,15 +3089,56 @@ impl Store {
amount: Decimal, amount: Decimal,
cash_account: AccountId, cash_account: AccountId,
) -> Result<EntryId, StoreError> { ) -> Result<EntryId, StoreError> {
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<EntryId, StoreError> {
self.rehearse(|l| l.receive_payment_with(invoice_id, date, amount, cash_account, details))?;
self.approval_gate( 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, false,
)?; )?;
// The rehearsal above is the whole check: hand-written mirrors of // The rehearsal above is the whole check: hand-written mirrors of
// the engine's refusals drift from it (and were never reached). // 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 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) = let (entry_id, mut journaled) =
self.journal_auto_entry(date, &description, cash_account, ar, amount)?; self.journal_auto_entry(date, &description, cash_account, ar, amount)?;
journaled.push(self.append_journal( journaled.push(self.append_journal(
@@ -3110,9 +3151,15 @@ impl Store {
"entry_id": entry_id, "entry_id": entry_id,
}), }),
)?); )?);
let live = self if details.instrument.is_some() {
.ledger journaled.push(self.append_journal(
.receive_payment(invoice_id, date, amount, cash_account)?; "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"); debug_assert_eq!(live, entry_id, "id allocator diverged from journal");
for line in &journaled { for line in &journaled {
apply_line_to_sqlite(&self.conn, line)?; apply_line_to_sqlite(&self.conn, line)?;
@@ -3120,8 +3167,8 @@ impl Store {
Ok(live) Ok(live)
} }
/// Pay a vendor bill. The payment entry (Dr A/P, Cr cash) is journaled /// Pay a vendor bill ([`Self::pay_bill_with`], with no payment method
/// through the standard entry flow first, then `bill.paid`. /// or reference).
pub fn pay_bill( pub fn pay_bill(
&mut self, &mut self,
bill_id: BillId, bill_id: BillId,
@@ -3129,10 +3176,36 @@ impl Store {
amount: Decimal, amount: Decimal,
cash_account: AccountId, cash_account: AccountId,
) -> Result<EntryId, StoreError> { ) -> Result<EntryId, StoreError> {
self.rehearse(|l| l.pay_bill(bill_id, date, amount, cash_account))?; self.pay_bill_with(
self.approval_gate(|l| l.pay_bill(bill_id, date, amount, cash_account), false)?; 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<EntryId, StoreError> {
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 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) = let (entry_id, mut journaled) =
self.journal_auto_entry(date, &description, ap, cash_account, amount)?; self.journal_auto_entry(date, &description, ap, cash_account, amount)?;
journaled.push(self.append_journal( journaled.push(self.append_journal(
@@ -3145,7 +3218,15 @@ impl Store {
"entry_id": entry_id, "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"); debug_assert_eq!(live, entry_id, "id allocator diverged from journal");
for line in &journaled { for line in &journaled {
apply_line_to_sqlite(&self.conn, line)?; apply_line_to_sqlite(&self.conn, line)?;
@@ -3153,6 +3234,51 @@ impl Store {
Ok(live) 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<EntryId, StoreError> {
let (target, description) = self.ledger.check_void_document(doc, date, reason)?;
self.reverse_entry(target, date, &description)
}
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Foreign exchange // Foreign exchange
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -5137,7 +5263,7 @@ impl Store {
Some(orig) => { Some(orig) => {
// Reversals allocate the next id; replayed in id order the // Reversals allocate the next id; replayed in id order the
// allocator is in sync, so the id must match the row. // 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 { if got != id as u64 {
return Err(StoreError::ReplayMismatch(format!( return Err(StoreError::ReplayMismatch(format!(
"reversal id: sqlite says {id}, replay got {got}" "reversal id: sqlite says {id}, replay got {got}"
@@ -5809,7 +5935,9 @@ fn replay_line(
"entry.reversed" => { "entry.reversed" => {
let want = get_u64(p, "id")?; let want = get_u64(p, "id")?;
let orig = get_u64(p, "reverses")?; 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, orig,
parse_date(get_str(p, "date")?)?, parse_date(get_str(p, "date")?)?,
get_str(p, "description")?, get_str(p, "description")?,
@@ -6385,54 +6513,11 @@ fn replay_line(
Ok(()) 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<Value> = 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::<Decimal>()
.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 /// Apply one journal line to the SQLite projection: INSERTs plus the events
/// mirror, then advance `last_applied_seq`. The applier is a pure function /// mirror, then advance `last_applied_seq`. The applier is a pure function
/// of the journal line: INSERTs for new facts, targeted UPDATEs/DELETEs /// of the journal line: INSERTs for new facts, targeted UPDATEs/DELETEs
/// for lifecycle changes (receipt linking, disposal, period close, /// 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 /// The close target recorded in a `period.closed` line. Absent or
/// `"retained_earnings"`: Retained Earnings with no sweeps (every journal /// `"retained_earnings"`: Retained Earnings with no sweeps (every journal
/// written before targets existed). /// 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()) StoreError::JournalCorrupt("invoice.created without lines".into())
})?; })?;
conn.execute( 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![ params![
get_u64(p, "id")? as i64, get_u64(p, "id")? as i64,
get_u64(p, "customer_id")? as i64, get_u64(p, "customer_id")? as i64,
get_str(p, "date")?, get_str(p, "date")?,
get_str(p, "due_date")?, get_str(p, "due_date")?,
lines.to_string(), lines.to_string(),
"0",
"open",
get_u64(p, "entry_id")? as i64, get_u64(p, "entry_id")? as i64,
], ],
)?; )?;
@@ -7237,15 +7320,13 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
.get("lines") .get("lines")
.ok_or_else(|| StoreError::JournalCorrupt("bill.created without lines".into()))?; .ok_or_else(|| StoreError::JournalCorrupt("bill.created without lines".into()))?;
conn.execute( 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![ params![
get_u64(p, "id")? as i64, get_u64(p, "id")? as i64,
get_u64(p, "vendor_id")? as i64, get_u64(p, "vendor_id")? as i64,
get_str(p, "date")?, get_str(p, "date")?,
get_str(p, "due_date")?, get_str(p, "due_date")?,
lines.to_string(), lines.to_string(),
"0",
"open",
get_u64(p, "entry_id")? as i64, get_u64(p, "entry_id")? as i64,
], ],
)?; )?;
@@ -7257,17 +7338,10 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
], ],
)?; )?;
} }
"invoice.payment_received" => { // A payment moves no document row: what a document is paid is read
let amount: Decimal = get_str(p, "amount")? // from the ledger lines tagged with it (schema v15, rules.md B-38).
.parse() "invoice.payment_received" => {}
.map_err(|_| StoreError::JournalCorrupt("bad amount".into()))?;
apply_document_payment(conn, "invoices", get_u64(p, "invoice_id")?, amount)?;
}
"bill.paid" => { "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( conn.execute(
"INSERT OR REPLACE INTO entry_vendors(entry_id, vendor_id) \ "INSERT OR REPLACE INTO entry_vendors(entry_id, vendor_id) \
SELECT ?, vendor_id FROM bills WHERE id = ?", SELECT ?, vendor_id FROM bills WHERE id = ?",
@@ -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)]
+302
View File
@@ -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<Value = Op> {
prop_oneof![
3 => (any::<bool>(), 0u8..80, 1i64..50_000).prop_map(|(i, n, c)| Op::Create(i, n, c)),
4 => (0usize..8, 0u8..90, 1i64..60_000, any::<bool>(), any::<bool>())
.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::<bool>(), 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<DocRef>) -> 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<u64> = 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<NaiveDate> {
let mut v: Vec<NaiveDate> = 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)?;
}
}
+613
View File
@@ -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<Value> = 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<Value> = 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::<Vec<_>>(),
[
(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()));
}
@@ -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"
}
@@ -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"}
+71 -12
View File
@@ -1006,6 +1006,12 @@ mod refusals {
Bill(u64, u32, u32, i64), Bill(u64, u32, u32, i64),
ReceivePayment(u64, u32, i64, u64), ReceivePayment(u64, u32, i64, u64),
PayBill(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<u64>, u8),
/// Void an invoice (true) or a bill: id, day, reason (good, blank).
Void(bool, u64, u32, bool),
AddClass(u8), AddClass(u8),
SetLineClass(u64, usize, Option<u32>), SetLineClass(u64, usize, Option<u32>),
SetEntryVendor(u64, Option<u64>), SetEntryVendor(u64, Option<u64>),
@@ -1101,6 +1107,18 @@ mod refusals {
.prop_map(|(i, d, a, c)| Op::ReceivePayment(i, d, a, c)), .prop_map(|(i, d, a, c)| Op::ReceivePayment(i, d, a, c)),
(0u64..4, 0u32..120, arb_cents(), 0u64..10) (0u64..4, 0u32..120, arb_cents(), 0u64..10)
.prop_map(|(b, d, a, c)| Op::PayBill(b, d, a, c)), .prop_map(|(b, d, a, c)| Op::PayBill(b, d, a, c)),
(
any::<bool>(),
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::<bool>(), 0u64..4, 0u32..120, any::<bool>())
.prop_map(|(k, i, d, r)| Op::Void(k, i, d, r)),
(0u8..3).prop_map(Op::AddClass), (0u8..3).prop_map(Op::AddClass),
(id.clone(), 0usize..4, prop::option::of(0u32..4)) (id.clone(), 0usize..4, prop::option::of(0u32..4))
.prop_map(|(e, i, c)| Op::SetLineClass(e, i, c)), .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) .receive_payment(i, day(d), Decimal::new(a, 2), c)
.map(drop), .map(drop),
Op::PayBill(b, d, a, c) => store.pay_bill(b, 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::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::SetLineClass(e, i, c) => store.set_line_class(e, i, c),
Op::SetEntryVendor(e, v) => store.set_entry_vendor(e, v), 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 /// The database's invoice row keeps no figure of its own (schema 15,
/// open / partial / paid, matching the ledger. /// 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] #[test]
fn the_database_tracks_what_an_invoice_has_been_paid() { fn the_database_tracks_what_an_invoice_has_been_paid() {
use ledger_core::arap::DocumentLine; use ledger_core::arap::DocumentLine;
@@ -2784,24 +2830,37 @@ fn the_database_tracks_what_an_invoice_has_been_paid() {
}], }],
) )
.unwrap(); .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(); let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
conn.query_row( conn.query_row("SELECT * FROM invoices WHERE id = ?", [inv as i64], |r| {
"SELECT amount_paid, status FROM invoices WHERE id = ?", Ok(format!(
[inv as i64], "{:?}",
|r| Ok((r.get(0)?, r.get(1)?)), (0..6)
) .map(|i| r.get_ref(i).map(|v| format!("{v:?}")))
.collect::<Vec<_>>()
))
})
.unwrap() .unwrap()
}; };
assert_eq!(row(&dir).1, "open"); let before = row(&dir);
store 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) .receive_payment(inv, day(10), Decimal::from(40), cash)
.unwrap(); .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 store
.receive_payment(inv, day(20), Decimal::from(60), cash) .receive_payment(inv, day(20), Decimal::from(60), cash)
.unwrap(); .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(); store.verify().unwrap();
} }
+5 -1
View File
@@ -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; 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 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 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.
+176 -8
View File
@@ -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 (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 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, 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`, **Enforced:** `arap::aging_bucket`, `Ledger::ar_aging`/`ap_aging`
`receive_payment`/`pay_bill` (`PaymentBeforeDocument`). **Checked:** (through `Ledger::doc_state`), `receive_payment`/`pay_bill`
(`PaymentBeforeDocument`). **Checked:**
`aging_boundaries`, `aging_is_exclusive_and_only_ages`, `aging_boundaries`, `aging_is_exclusive_and_only_ages`,
`aging_as_of_a_date_sees_only_what_existed_then`, `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 ## 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 receivables, payables, withholding, or retained earnings account must keep
agreeing with what it controls (open invoices, open bills, taxes withheld, agreeing with what it controls (open invoices, open bills, taxes withheld,
closed earnings): such a role moves only when its account holds no closed earnings): such a role moves only when its account holds no
balance, so the bookkeeper first moves the balance with an entry, which balance. For a withholding or retained earnings role the bookkeeper
the journal shows. Revenue and expense roles move at any time; earlier first moves the balance with an entry, which the journal shows. Revenue
postings stay where they are. 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` / **Enforced:** `Ledger::check_controls`, `Ledger::assign_role` /
`check_assign_role`, `Ledger::check_renumber_account` (an assigned 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`. `ledger-store` replay of `role.assigned`.
## B-24 Asset tags: one label per asset, never reused ## 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`, proptests `prop_project_nets_partition_the_pnl`,
`prop_project_set_round_trips`, `prop_project_contribution_is_the_net_to_date` `prop_project_set_round_trips`, `prop_project_contribution_is_the_net_to_date`
(core) and `prop_snapshot_contribution_is_the_net_to_date` (viewer). (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).
+111
View File
@@ -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 | | 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 | | 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 | | 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.
+7
View File
@@ -89,3 +89,10 @@ open Wanderbooks
#print axioms Prorate.chargeLast_mono #print axioms Prorate.chargeLast_mono
#print axioms Prorate.kernel_parts_total #print axioms Prorate.kernel_parts_total
#print axioms Prorate.kernel_parts_nonneg #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
+1
View File
@@ -6,3 +6,4 @@ import Wanderbooks.Ledger
import Wanderbooks.Apportion import Wanderbooks.Apportion
import Wanderbooks.Parties import Wanderbooks.Parties
import Wanderbooks.Prorate import Wanderbooks.Prorate
import Wanderbooks.Documents
+280
View File
@@ -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