From 0d4a5f8f53a7c5f6ef3a997bc39ce3ab82040c89 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 13 Oct 2026 14:21:44 -0600 Subject: [PATCH] docs(core): written specs, with sources, for the commands and reports Max-testing pass, step 2: every domain decision in ledger.rs and reports.rs gets a GOAL / SOURCE / INVARIANTS / SCENARIOS block in its doc comment: post_entry, try_create_entry, close_period_to, mark_cleared, reconciliation_report, account_balance, create_invoice, create_bill, receive_payment, pay_bill, register_asset, dispose_asset, run_payroll, remit_payroll_taxes, set_line_class, set_entry_vendor; trial_balance(_as_of), profit_and_loss_for_period, balance_sheet_as_of, general_ledger, members_equity, cash_flow, profit_and_loss_by_class, snapshot. Sources trace to docs/spec/rules.md (and through it to Pacioli, the IRS, FASB); what no document settles is marked CONVENTION, DECISION, or ASSUMED for Stephen to confirm. The specs are the input to spec-blind test writing (the next step): tests derived from the spec alone, never from the code. Also: rustdoc now runs in `just check` with -D warnings; fixed the seven existing broken intra-doc links and one bare URL. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-core/src/assets.rs | 2 +- crates/ledger-core/src/ledger.rs | 213 +++++++++++++++++++++++++++++- crates/ledger-core/src/lib.rs | 2 +- crates/ledger-core/src/periods.rs | 2 +- crates/ledger-core/src/reports.rs | 143 +++++++++++++++++++- crates/ledger-mcp/src/types.rs | 2 +- justfile | 3 +- 7 files changed, 360 insertions(+), 7 deletions(-) diff --git a/crates/ledger-core/src/assets.rs b/crates/ledger-core/src/assets.rs index 19f806a..2af270a 100644 --- a/crates/ledger-core/src/assets.rs +++ b/crates/ledger-core/src/assets.rs @@ -5,7 +5,7 @@ //! separate set of books with its own rules and stays with the CPA. This //! module never pretends to do tax. //! -//! The register is master data owned by [`Ledger`]: assets are journaled +//! The register is master data owned by [`Ledger`](crate::Ledger): assets are journaled //! like accounts (see `ledger-store`), and ids are allocator-deterministic //! for replay. Depreciation itself is pure math — [`depreciation_schedule`] //! — and postings go through the normal entry machinery, so every diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index c0d5fb3..7091c31 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -26,7 +26,8 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// An account the engine posts to by code: invoices, bills, payroll, and -/// closes find (or create) these. See [`Ledger::check_controls`]. +/// closes find (or create) these; a command refuses one of the wrong type +/// (`LedgerError::ControlAccountMismatch`). #[derive(Clone, Copy, Debug)] pub struct ControlAccount { /// The code the engine looks for. @@ -615,6 +616,17 @@ impl Ledger { /// Start a draft journal entry, rejecting dates that fall in a closed /// period. This is the entry point for new work; [`Self::create_entry`] /// is for replay and internal use. + /// + /// ```text + /// GOAL: start a draft entry for new work. + /// SOURCE: docs/spec/rules.md B-12; Lean invariant Inv.draftsOpen. + /// SCENARIOS: + /// 1. IF the date falls in a closed period: + /// - SHOULD refuse (ClosedPeriod) + /// - NEVER create a draft there + /// BECAUSE a closed period takes no new entries. + /// 2. Otherwise: SHOULD return a fresh id, one past the last. + /// ``` pub fn try_create_entry( &mut self, date: NaiveDate, @@ -771,6 +783,26 @@ impl Ledger { /// Validate and post a draft entry. One-way: a posted entry is immutable. /// /// Rejects entries dated in a closed period. + /// + /// ```text + /// GOAL: make a draft part of the books, once and for good. + /// SOURCE: docs/spec/rules.md B-1, B-2, B-3, B-8 (Pacioli ch. 36: write + /// only forward), B-12. + /// INVARIANTS: + /// 1. MUST post only an entry that passes JournalEntry::validate. + /// 2. MUST append it to the log; NEVER reorder, change, or remove an + /// earlier posted entry. + /// 3. A refusal MUST leave the books unchanged. + /// SCENARIOS: + /// 1. IF the entry is unknown: SHOULD refuse (UnknownEntry). + /// 2. IF it is already posted: + /// - SHOULD refuse (EntryAlreadyPosted) + /// - NEVER post it twice + /// 3. IF it is dated in a closed period: SHOULD refuse (ClosedPeriod). + /// 4. IF it doesn't balance, has fewer than two lines, a zero, negative, + /// or out-of-range amount, or an unknown or placeholder account: + /// SHOULD refuse with that reason. + /// ``` pub fn post_entry(&mut self, entry: EntryId) -> Result<(), LedgerError> { let snapshot = self .entries @@ -944,6 +976,15 @@ impl Ledger { /// salvage below cost, at least one month of life, base currency) and /// the account links (asset/asset/expense types, three distinct /// accounts). + /// + /// ```text + /// GOAL: put a fixed asset on the register, to depreciate monthly. + /// SOURCE: docs/spec/rules.md B-15; B-22 (capitalize vs expense). + /// SCENARIOS: + /// 1. IF the description is blank, cost isn't positive, salvage is + /// negative or not below cost, the useful life is zero months, or the + /// accounts aren't the right types: SHOULD refuse. + /// ``` pub fn register_asset(&mut self, new: NewAsset) -> Result { self.validate_new_asset(&new)?; let id = self.next_asset_id; @@ -981,6 +1022,18 @@ impl Ledger { /// Mark an asset disposed. The disposal *entry* is posted through the /// normal machinery by the caller; this records the lifecycle change. + /// + /// ```text + /// GOAL: take an asset off the books when sold or scrapped. + /// SOURCE: docs/spec/rules.md B-15; CONVENTION (gain or loss on disposal). + /// INVARIANTS: + /// 1. The gain (or loss) MUST equal proceeds minus book value at the + /// disposal date. + /// 2. The asset's cost and accumulated depreciation MUST both leave the + /// books. + /// SCENARIOS: + /// 1. IF the asset is unknown or already disposed: SHOULD refuse. + /// ``` pub fn dispose_asset( &mut self, id: AssetId, @@ -1121,6 +1174,32 @@ impl Ledger { /// /// Returns the closing entry's id, or `None` if there was nothing to /// close. + /// + /// ```text + /// GOAL: close a period: zero every revenue and expense account's balance + /// as of its last day into equity, and lock it. + /// SOURCE: docs/spec/rules.md B-11 (Pacioli ch. 27, 34), B-12, B-13 (26 + /// U.S.C. §704(a)); R-? Periods and close. + /// INVARIANTS: + /// 1. After a close, every revenue and expense account MUST have a zero + /// balance as of the period's last day. + /// 2. The closing entry MUST balance, be dated the period's last day, and + /// credit equity with exactly the net income through that day (split + /// among members by share, to the cent, by largest remainder). + /// 3. Entries dated after the period MUST stay untouched (their income + /// belongs to their own period). + /// 4. A sweep account (draws, distributions, dividends) MUST end at zero, + /// its balance moved into its target. + /// 5. NEVER post a closing entry when there is nothing to close. + /// SCENARIOS: + /// 1. IF the period is unknown or already closed: SHOULD refuse. + /// 2. IF a draft is dated inside it: SHOULD refuse (DraftsInPeriod). + /// 3. IF a later period is already closed: SHOULD refuse (CloseOutOfOrder). + /// 4. IF a target or sweep account isn't a distinct equity account, or + /// member shares aren't positive and summing to 1: SHOULD refuse. + /// 5. IF closing to retained earnings and 3999 exists but isn't a + /// postable equity account: SHOULD refuse (ControlAccountMismatch). + /// ``` pub fn close_period_to( &mut self, id: PeriodId, @@ -1452,6 +1531,15 @@ impl Ledger { /// Tag an entry (draft or posted) with the vendor it paid or was billed /// by, or clear the tag. The tag is reporting metadata, like a receipt /// link: it changes no amounts, so posted entries may be tagged. + /// + /// ```text + /// GOAL: tag an entry with the vendor it paid, for 1099s. + /// SOURCE: docs/spec/rules.md B-19. + /// SCENARIOS: + /// 1. IF the entry or vendor is unknown: SHOULD refuse. + /// 2. Tagging MUST change no amount (it's metadata, allowed on posted + /// entries). + /// ``` pub fn set_entry_vendor( &mut self, entry: EntryId, @@ -1559,6 +1647,22 @@ impl Ledger { /// Create a sales invoice. Auto-posts: Dr `1200` Accounts Receivable, /// Cr `4000` Revenue. The invoice date must not fall in a closed period. + /// + /// ```text + /// GOAL: bill a customer and recognize the revenue (accrual basis). + /// SOURCE: IRS Pub. 538, accrual method (docs/spec/sources/irs-periods-and-methods.md: + /// "report income in the year it is earned"); ASC 606-10-25-23; + /// control accounts: Ledger::check_controls. + /// INVARIANTS: + /// 1. MUST post one balanced entry: Dr 1200 receivables, Cr 4000 revenue, + /// for the sum of the lines, dated the invoice date. + /// 2. The invoice MUST start open, with nothing paid. + /// 3. A refusal MUST leave the books unchanged. + /// SCENARIOS: + /// 1. IF the customer is unknown, a line amount isn't positive, the due + /// date is before the invoice date, or the date is in a closed + /// period: SHOULD refuse. + /// ``` pub fn create_invoice( &mut self, customer_id: CustomerId, @@ -1604,6 +1708,21 @@ impl Ledger { /// Create a vendor bill. Auto-posts: Dr `6999` General Expenses, /// Cr `2000` Accounts Payable. The bill date must not fall in a closed /// period. + /// + /// ```text + /// GOAL: record a vendor's bill as an expense owed (accrual basis). + /// SOURCE: IRS Pub. 538, accrual method ("deduct ... expenses in the year + /// incurred"); control accounts: Ledger::check_controls. + /// INVARIANTS: + /// 1. MUST post one balanced entry: Dr 6999 general expenses, Cr 2000 + /// payables, for the sum of the lines, dated the bill date, tagged + /// with the vendor (1099s). + /// 2. A refusal MUST leave the books unchanged. + /// SCENARIOS: + /// 1. IF the vendor is unknown, a line amount isn't positive, the due date + /// is before the bill date, or the date is in a closed period: SHOULD + /// refuse. + /// ``` pub fn create_bill( &mut self, vendor_id: VendorId, @@ -1659,6 +1778,20 @@ impl Ledger { /// Record a customer payment against an invoice. Posts: Dr Cash, /// Cr A/R. Updates the invoice's paid amount and status. + /// + /// ```text + /// GOAL: record a customer's payment against an invoice. + /// SOURCE: IRS Pub. 538 (accrual); CONVENTION (A/R workflow). + /// INVARIANTS: + /// 1. MUST post Dr cash, Cr 1200 receivables, for the amount. + /// 2. The invoice's paid amount MUST rise by the amount; it is Paid + /// exactly when fully paid, Partial when partly. + /// SCENARIOS: + /// 1. IF the amount isn't positive: SHOULD refuse. + /// 2. IF it exceeds what's still owed: + /// - SHOULD refuse (Overpayment) + /// - NEVER let paid exceed the total + /// ``` pub fn receive_payment( &mut self, invoice_id: InvoiceId, @@ -1696,6 +1829,18 @@ impl Ledger { /// Pay a vendor bill. Posts: Dr A/P, Cr Cash. Updates the bill's paid /// amount and status. + /// + /// ```text + /// GOAL: record paying a vendor's bill. + /// SOURCE: IRS Pub. 538 (accrual); CONVENTION (A/P workflow). + /// INVARIANTS: + /// 1. MUST post Dr 2000 payables, Cr cash, for the amount, tagged with + /// the vendor. + /// 2. The bill's paid amount MUST rise by the amount. + /// SCENARIOS: + /// 1. IF the amount isn't positive, or exceeds what's still owed: SHOULD + /// refuse. + /// ``` pub fn pay_bill( &mut self, bill_id: BillId, @@ -1917,6 +2062,14 @@ impl Ledger { /// Mark a posted entry's line as cleared (or uncleared). The cleared /// flag is a workflow marker, not accounting data: posted entries stay /// immutable, and the flag lives in a side table. + /// + /// ```text + /// GOAL: mark a posted line as cleared (seen on the bank statement), or not. + /// SOURCE: CONVENTION (bank reconciliation); R-? Bank reconciliation. + /// SCENARIOS: + /// 1. IF the entry is unknown, a draft, or has no such line: SHOULD refuse. + /// 2. Marking MUST change no amount, no report but the reconciliation. + /// ``` pub fn mark_cleared( &mut self, entry_id: EntryId, @@ -1953,6 +2106,19 @@ impl Ledger { /// cleared balance (posted lines marked cleared, dated on or before /// the statement date) to the statement balance, and lists every /// uncleared line so the human can see what's outstanding. + /// + /// ```text + /// GOAL: compare the books with a bank statement. + /// SOURCE: CONVENTION (bank reconciliation); R-? Bank reconciliation. + /// INVARIANTS: + /// 1. book_balance MUST be the account's net of all posted lines. + /// 2. cleared_balance MUST be the net of cleared lines dated on or + /// before the statement date. + /// 3. difference MUST equal cleared_balance - statement_balance; zero + /// means reconciled. + /// 4. uncleared_lines MUST list exactly the posted, uncleared lines on + /// the account dated on or before the statement date. + /// ``` pub fn reconciliation_report( &self, account: AccountId, @@ -2052,6 +2218,17 @@ impl Ledger { /// Balance expressed in the account's normal side: positive means a /// normal balance, negative means a contra balance. + /// + /// ```text + /// GOAL: an account's balance on its normal side. + /// SOURCE: docs/spec/rules.md B-5. + /// INVARIANTS: + /// 1. MUST equal the account's debits minus credits for debit-normal + /// types (asset, expense), and credits minus debits otherwise. + /// 2. NEVER return negative zero. + /// SCENARIOS: + /// 1. IF the account is unknown: SHOULD refuse. + /// ``` pub fn account_balance(&self, account: AccountId) -> Result { let account = self .chart @@ -2116,6 +2293,21 @@ impl Ledger { /// /// Accounting only: there are no tax tables here. Every withholding /// number is supplied by the caller. + /// + /// ```text + /// GOAL: record a payroll: wages, the employer's FICA, what is withheld + /// and owed, and net pay. + /// SOURCE: IRS Pub. 15 (docs/spec/sources/irs-payroll.md); R-? Payroll + /// (accounting only: the caller supplies every number). + /// INVARIANTS: + /// 1. MUST post one balanced entry: Dr 6100 gross wages, Dr 6110 employer + /// FICA, Cr 2110-2150 the withholdings and both shares of FICA, Cr cash + /// net pay. + /// 2. Net pay MUST equal gross minus the employee's withholdings. + /// SCENARIOS: + /// 1. IF any amount is negative, gross is zero, or withholdings exceed + /// gross: SHOULD refuse. + /// ``` pub fn run_payroll( &mut self, date: NaiveDate, @@ -2168,6 +2360,15 @@ impl Ledger { /// Posts one entry — Dr each payable for its balance, Cr cash for the /// total. Fails with [`LedgerError::NothingToRemit`] when nothing is /// owed, and rejects dates in closed periods. + /// + /// ```text + /// GOAL: pay the payroll taxes and withholdings owed. + /// SOURCE: IRS Pub. 15 (deposits). + /// INVARIANTS: + /// 1. MUST zero every payroll payable (2110-2150) against cash. + /// SCENARIOS: + /// 1. IF nothing is owed: SHOULD refuse (NothingToRemit). + /// ``` pub fn remit_payroll_taxes( &mut self, date: NaiveDate, @@ -2283,6 +2484,16 @@ impl Ledger { /// Tag a draft entry's line with a class, or clear it with `None`. /// Draft-only: posted entries are immutable, so their classes are /// frozen at posting time. + /// + /// ```text + /// GOAL: tag a draft line with a class (or clear it). + /// SOURCE: R-? Tracking classes. + /// SCENARIOS: + /// 1. IF the entry is posted: + /// - SHOULD refuse + /// - NEVER change a posted line (B-8) + /// 2. IF the line or class doesn't exist: SHOULD refuse. + /// ``` pub fn set_line_class( &mut self, entry: EntryId, diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 5396c9a..0274334 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -12,7 +12,7 @@ //! * All mutations go through [`Ledger`] commands (`create_entry`, `add_line`, //! `post_entry`, `reverse_entry`). Posted entries are immutable; corrections //! are reversing entries. -//! * Reports ([`trial_balance`], [`balance_sheet`], [`profit_and_loss`]) are +//! * Reports ([`trial_balance`](reports::trial_balance), [`balance_sheet`](reports::balance_sheet), [`profit_and_loss`](reports::profit_and_loss)) are //! pure functions of a ledger, which is what makes the semantic oracle //! (`docs/oracle.md`) possible. diff --git a/crates/ledger-core/src/periods.rs b/crates/ledger-core/src/periods.rs index 8f99132..d9449b8 100644 --- a/crates/ledger-core/src/periods.rs +++ b/crates/ledger-core/src/periods.rs @@ -9,7 +9,7 @@ //! difference lands in Retained Earnings (created as `3999` if missing). //! 3. Marks the period closed. //! -//! Once closed, [`Ledger::create_entry`] and [`Ledger::post_entry`] reject +//! Once closed, [`Ledger::try_create_entry`](crate::Ledger::try_create_entry) and [`Ledger::post_entry`](crate::Ledger::post_entry) reject //! any entry dated inside the period. Corrections to closed periods go //! through the *next* open period (or a reversal dated in an open period). diff --git a/crates/ledger-core/src/reports.rs b/crates/ledger-core/src/reports.rs index 79742b6..b6c7ef2 100644 --- a/crates/ledger-core/src/reports.rs +++ b/crates/ledger-core/src/reports.rs @@ -1,4 +1,4 @@ -//! Reports: pure functions of a [`Ledger`](crate::Ledger). +//! Reports: pure functions of a [`Ledger`]. //! //! Reports never mutate and never guess. They are what the semantic oracle //! (`docs/oracle.md`) diffs: the same fixture books through two engines must @@ -44,6 +44,14 @@ impl TrialBalance { } /// Build the trial balance from all posted entries. +/// +/// ```text +/// GOAL: the trial balance over every posted entry (see trial_balance_as_of). +/// SOURCE: docs/spec/rules.md B-6; R-7. +/// INVARIANTS: +/// 1. MUST equal trial_balance_as_of on or after the last entry's date. +/// 2. MUST balance (total debits == total credits). +/// ``` pub fn trial_balance(ledger: &Ledger) -> TrialBalance { trial_balance_where(ledger, &|_| true) } @@ -51,6 +59,29 @@ pub fn trial_balance(ledger: &Ledger) -> TrialBalance { /// The trial balance as of a date: every posted entry dated on or before /// it (closing entries included, so after a close it is the post-closing /// trial balance). +/// +/// ```text +/// GOAL: every account's balance, in a debit or a credit column, from the +/// posted entries dated on or before `as_of`. +/// SOURCE: docs/spec/rules.md B-6 (Pacioli ch. 36; IRS Pub. 583); R-7, R-9b. +/// INVARIANTS: +/// 1. MUST have total debits equal total credits, for any books built +/// through the Ledger commands. +/// 2. MUST list each non-placeholder account with a nonzero balance +/// exactly once, in code order; NEVER list a placeholder or a zero +/// balance. +/// 3. MUST put each balance in exactly one column, never negative. +/// 4. NEVER count a draft, or an entry dated after `as_of`; closing +/// entries dated on or before it count (after a close this is the +/// post-closing trial balance). +/// SCENARIOS: +/// 1. IF no entries are posted by `as_of`: +/// - SHOULD be empty with zero totals. +/// 2. IF an asset was debited 100 and credited 30: +/// - SHOULD show it with debit 70, credit 0. +/// BECAUSE the trial balance shows each account's net balance on the +/// side it falls. +/// ``` pub fn trial_balance_as_of(ledger: &Ledger, as_of: NaiveDate) -> TrialBalance { trial_balance_where(ledger, &|e| e.date() <= as_of) } @@ -151,6 +182,16 @@ pub struct ClassProfitAndLoss { /// /// The rows partition the ledger: summing every row's revenue and expenses /// reproduces [`profit_and_loss`]. +/// +/// ```text +/// GOAL: a profit and loss per class (department, location, job), plus the +/// unclassified lines. +/// SOURCE: docs/spec/rules.md B-14; R-? Tracking classes. +/// INVARIANTS: +/// 1. The per-class P&Ls (with unclassified) MUST sum to the whole P&L, +/// account by account (conservation). +/// 2. NEVER count a line in two classes. +/// ``` pub fn profit_and_loss_by_class(ledger: &Ledger) -> Result, LedgerError> { // Which classes actually have P&L lines, and whether any P&L line is // unclassified. Balance-sheet-only classes get no row. @@ -399,6 +440,26 @@ fn statement_lines( /// counts every line). Closing entries are excluded: they move a period's /// earnings into retained earnings, so counting them would report a closed /// period as breaking even. +/// +/// ```text +/// GOAL: revenue, expenses, and net income for the entries dated from +/// `start` through `end` (both inclusive), optionally one class's lines. +/// SOURCE: docs/spec/rules.md B-14, B-11; R-9. +/// INVARIANTS: +/// 1. MUST count only revenue and expense accounts, a line per account +/// with its normal-side amount, and net income = revenue - expenses. +/// 2. NEVER count a closing entry (a close moves earnings; it doesn't +/// earn or spend), a draft, or an entry outside the range. +/// 3. MUST partition: for start <= m < end, the period's net income equals +/// [start, m] plus [m+1, end]. +/// 4. MUST equal the change in current earnings (balance sheet) across +/// the range when no close falls inside it. +/// 5. With a class: MUST count only lines tagged with that class. +/// SCENARIOS: +/// 1. IF a month was closed: SHOULD still report that month's earnings. +/// BECAUSE closing doesn't make a period break even (R-9). +/// 2. IF `start` is after `end`: SHOULD refuse. (ASSUMED) +/// ``` pub fn profit_and_loss_for_period( ledger: &Ledger, start: NaiveDate, @@ -443,6 +504,23 @@ pub fn profit_and_loss_for_period( /// Revenue and expense not yet closed to retained earnings appear as /// current earnings inside equity, so the accounting equation holds on /// every date, before and after closes. +/// +/// ```text +/// GOAL: assets, liabilities, and equity as of a date, a line per account. +/// SOURCE: docs/spec/rules.md B-7 (CON 8 ¶E62), B-11; R-8. +/// INVARIANTS: +/// 1. MUST balance: assets = liabilities + equity + current earnings, +/// exactly. +/// 2. MUST count every posted entry dated on or before `as_of`, closing +/// entries included (after a close, earnings sit in retained earnings +/// or members' capital, and current earnings is zero for closed time). +/// 3. Current earnings MUST be revenue minus expenses not yet closed. +/// 4. NEVER list revenue or expense accounts as lines. +/// 5. Lines use each account's normal-side balance (a contra balance is +/// negative). +/// SCENARIOS: +/// 1. IF nothing is posted by `as_of`: SHOULD be empty and balanced. +/// ``` pub fn balance_sheet_as_of( ledger: &Ledger, as_of: NaiveDate, @@ -538,6 +616,22 @@ pub struct GeneralLedger { /// posting to its account, with the entry's other side as the split, and a /// running balance from the account's balance before `start`. Closing /// entries are postings like any other. +/// +/// ```text +/// GOAL: every posting from `start` through `end`, by account in code +/// order, with running balances. +/// SOURCE: docs/spec/rules.md B-6; R-9a (the QuickBooks general ledger). +/// INVARIANTS: +/// 1. Each account MUST show its beginning balance (entries before +/// `start`), every posting in the range in date order, and an ending +/// balance equal to beginning plus the postings, on the account's +/// normal side. +/// 2. Each account's ending balance MUST equal its trial-balance balance +/// as of `end` (normal side). +/// 3. Each posting MUST name its split: the one account on the entry's +/// other side, or "-Split-" when the other side has several. +/// 4. NEVER include drafts. +/// ``` pub fn general_ledger( ledger: &Ledger, start: NaiveDate, @@ -718,6 +812,23 @@ pub struct MembersEquityStatement { /// earnings plus unclosed income), are allocated by share. Allocation is to /// the cent with the last member absorbing rounding, so every row's total /// is exact and the ending total equals balance-sheet equity. +/// +/// ```text +/// GOAL: each member's capital roll-forward for a partnership over a +/// period: beginning, contributions, allocated net income, distributions, +/// ending; with a total. +/// SOURCE: docs/spec/rules.md B-13 (26 U.S.C. §704(a); Form 1065 Schedule +/// M-2), B-11; R-9c. +/// INVARIANTS: +/// 1. Each row MUST roll forward exactly: beginning + contributions + +/// income - distributions + other = ending. +/// 2. The total ending MUST equal balance-sheet equity at `end`. +/// 3. Unclosed earnings and the period's income MUST be split by share, +/// to the cent, by largest remainder (DECISION, Stephen 2026-10-13). +/// 4. NEVER hide an unexplained change: it goes in "other". +/// SCENARIOS: +/// 1. IF shares don't sum to 1, or are not positive: SHOULD refuse. +/// ``` pub fn members_equity( ledger: &Ledger, start: NaiveDate, @@ -862,6 +973,17 @@ pub fn members_equity( /// are sorted by account code, and object keys are fixed. Two engines fed the /// same fixture books must produce byte-identical output — that is the whole /// point (see `docs/oracle.md`). +/// +/// ```text +/// GOAL: the trial balance, P&L, and balance sheet as deterministic JSON, +/// the interchange format for cross-checking against other +/// implementations. +/// SOURCE: R-10; docs/oracle.md. +/// INVARIANTS: +/// 1. MUST be identical for the same books, whatever order accounts were +/// added in (decimal strings, sorted lines, fixed keys). +/// 2. NEVER contain floats. +/// ``` pub fn snapshot(ledger: &Ledger) -> Result { let tb = trial_balance(ledger); let pl = profit_and_loss(ledger)?; @@ -971,6 +1093,25 @@ pub struct CashFlowStatement { /// working-capital changes gives operating cash flow; fixed-asset purchases /// and disposal proceeds give investing; equity and designated debt give /// financing. The three sections sum to the net change in `cash_accounts`. +/// +/// ```text +/// GOAL: the statement of cash flows by the indirect method: net income +/// adjusted to operating cash, plus investing and financing, for a +/// period. +/// SOURCE: docs/spec/rules.md B-14 (ASC 230-10-45-28 indirect method; +/// 230-10-45-16 operating inflows). +/// INVARIANTS: +/// 1. Operating + investing + financing MUST equal the net change in the +/// cash accounts over the period, exactly. +/// 2. Operating MUST start from the period's net income (closing entries +/// excluded) and adjust for changes in non-cash working-capital +/// accounts. +/// 3. Investing MUST hold changes in non-current assets (fixed assets); +/// a disposal's gain or loss moves out of operating into investing. +/// 4. Financing MUST hold changes in equity (other than earnings) and in +/// the liabilities named as financing. +/// 5. NEVER count closing entries. +/// ``` pub fn cash_flow( ledger: &Ledger, params: &CashFlowParams, diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index fd7c3da..137cfb7 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -1251,7 +1251,7 @@ pub struct DigestOut { /// longer know as of today. For each: research the current value from /// the official source or ask the human (owner, CPA), then update it /// for everyone with a pull request (or at least an issue) at - /// https://github.com/swaits/wanderbooks. + /// . pub stale_rules: Vec, } diff --git a/justfile b/justfile index cf40163..688284f 100644 --- a/justfile +++ b/justfile @@ -9,10 +9,11 @@ netlify := "npx --yes netlify-cli@27" default: @just --list -# Everything CI should check: format, clippy (zero warnings), tests +# Everything CI should check: format, clippy and rustdoc (zero warnings), tests check: cargo fmt --all --check cargo clippy --workspace --all-targets -- -D warnings + RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --quiet cargo test --workspace # Formal proofs: Kani on the accounting kernel, Lean for books of any size (docs/verification.md)