feat(arap)!: documents read their figures from standing lines; control accounts
check / check (push) Canceled after 0s
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:
54 files changed
+5997
-649
No files matched your search
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
+1354
-259
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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;
|
||||||
"#,
|
"#,
|
||||||
];
|
];
|
||||||
@@ -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)]
|
||||||
@@ -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)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in new issue
Block a user