//! The bookspec examples (`bookspec/examples/*.toml`, P13, D-34), checked //! against the engine on every run, so they can't rot. This is the one //! place every example is loaded. //! //! Each example is a setup, events and expected values worked by hand from //! its cited source (`bookspec/README.md` gives the shape). Every example //! must load, whatever it asks or whichever appendix it is in: its shape, //! its accounts, fixed assets, schedules, parties, members, payees, //! payees' dated 1099 decisions and payment methods, every event (an //! entry, a reversal, a close, a bill, or a command that posts what is //! due: a depreciation run, a disposal, a schedule run or registration), //! each accepted or refused as it says, with the parties, payee and //! payment method it is tagged with, and every change that posts no entry //! (a rename, a policy, an asset's correction). The books are the //! engine's own store (`ledger_store::Store`, in a directory of their //! own), so every event goes through the command the books take it by, //! journaled, and the books must replay to what was kept //! (`Store::verify`). The queries the //! core answers (`cash_flow`, `trial_balance`, `profit_and_loss`, //! `vendor_1099_paid`, `vendor_1099`, `entry`, `balances`, //! `members_equity`, `k1`, `k1_tax_basis`, `k1_reconciliation`, //! `party_part`, `party_totals`, `party_tin_last4`, `period`, `day`, //! `calendar_year`, `account_names`, `uncategorized_accounts`, `run`, //! `disposal`, `depreciation_schedule`, `schedule_plan`, `register_row`, //! `register_totals`, `asset_basis`) are then checked value by value, and //! the commands it tries (`rename_account`, `add_account`, //! `set_tax_category`) accepted or refused on a copy of the books, each //! after the event it names (`after`) or the last; the formats appendix's //! (`iif_lines`, `iif_changes`) are checked by ledger-package //! (`the_bookspec_iif_example_matches_the_engine`), and a query no crate //! answers is refused. A key the shape doesn't have is refused too, never //! ignored. use std::borrow::Borrow; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use chrono::NaiveDate; use ledger_core::calendar::{PeriodDefaults, period_defaults}; use ledger_core::parties::{Basis, MoneyAccounts}; use ledger_core::settings::{Company, Source}; use ledger_core::tax_capital::TaxCapitalRow; use ledger_core::{ AccountType, AssetId, CashFlowParams, CashFlowSection, CashFlowStatement, CloseTo, Currency, Dated1099, Decision1099, DocumentLine, EntryId, EntryStatus, ExemptReason, Form1099, InstrumentId, InstrumentKind, Ledger, LedgerError, MemberCapital, MemberClose, Money, NewAsset, NewSchedule, PartyId, PartyMember, PartyShare, PaymentAccounts, ReconcilingKind, Role, ScheduleConvention, ScheduleId, ScheduleKind, Share, Side, TinFacts, TinType, VendorId, W9Facts, cash_flow, members_equity, profit_and_loss_for_period, trial_balance_as_of, vendor_1099_report, }; use ledger_store::{DepreciationRun, DisposalPreview, Store, StoreError}; use rust_decimal::Decimal; use serde::Deserialize; #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Example { id: String, title: String, rule: String, status: String, sources: Vec, tags: Vec, /// `"formats"`: an interchange format rather than an accounting rule /// (P13's appendix of formats); absent for the rules themselves. #[serde(default)] appendix: Option, setup: Setup, events: Vec, /// Changes that post no entry, each made after the event it names. #[serde(default)] changes: Vec, expect: Vec, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Setup { currency: String, fiscal_year_start: String, basis: String, rounding: String, accounts: Vec, #[serde(default)] cash_accounts: Vec, #[serde(default)] financing_accounts: Vec, #[serde(default)] investing_accounts: Vec, #[serde(default)] fixed_assets: Vec, /// Fixed assets registered, with their figures (rules.md B-15). #[serde(default)] assets: Vec, /// Prepaid-expense and deferred-revenue schedules registered (rules.md /// B-16). #[serde(default)] schedules: Vec, /// The company's directory: parties other than a payee's vendor /// record, and the payees' when the example names their roles. #[serde(default)] parties: Vec, #[serde(default)] members: Vec, #[serde(default)] payees: Vec, /// A payee's 1099 decision from a date on (rules.md B-19, D-40). #[serde(default)] status_changes: Vec, /// The ways the company (or a member, for it) pays (rules.md B-19). #[serde(default)] instruments: Vec, /// The projects (rules.md B-37), each added with its facts. #[serde(default)] projects: Vec, /// The day the books are asked on, in the company's time zone: what /// an omitted date means (rules.md B-30). A query that omits a date /// needs it. #[serde(default)] today: Option, } /// A project (rules.md B-37): its name (once each), and its kind /// (`internal`, `client`, `on_spec`, `other`), status and party (a /// `setup.parties` name), if given. #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct ProjectSpec { name: String, #[serde(default)] kind: Option, #[serde(default)] status: Option, #[serde(default)] party: Option, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct AccountSpec { code: String, name: String, #[serde(rename = "type")] kind: String, /// The parent account's code, declared before it. #[serde(default)] parent: Option, /// A header account: it groups others and takes no postings. #[serde(default)] header: bool, /// Its tax category, a key of the catalog (rules.md B-32). #[serde(default)] tax_category: Option, } impl AccountSpec { fn account_type(&self, id: &str) -> AccountType { match self.kind.as_str() { "asset" => AccountType::Asset, "liability" => AccountType::Liability, "equity" => AccountType::Equity, "revenue" => AccountType::Revenue, "expense" => AccountType::Expense, other => panic!("{id}: account type {other:?}"), } } } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct FixedAsset { cost_account: String, accumulated_account: String, expense_account: String, } /// A fixed asset registered before the first event, named `id` by the /// events and queries that ask about it: straight line over its life from /// its in-service date (rules.md B-15). #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct AssetSpec { id: String, description: String, cost: String, salvage: String, life_months: u32, in_service: String, /// `"straight_line"`, the one method the books have. method: String, cost_account: String, accumulated_account: String, expense_account: String, /// The event whose entry put it on the books (its purchase or a /// member's contribution), linked once that event is applied: its date /// is the asset's acquired date (rules.md B-15 rule 7). #[serde(default)] acquired_by: Option, /// How the company got it and the facts its tax figures follow from /// (rules.md B-25), recorded as it is registered. #[serde(default)] basis: Option, } /// An asset's basis facts (rules.md B-25): `rule` `"purchase"`, /// `"carryover_723"` or `"personal_use_conversion"`; a contribution's /// `contributor_adjusted_basis` and `fmv_at_contribution`; its §704(c) /// method, if chosen (`"traditional"`, `"traditional_with_curative"`, /// `"remedial"`). #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct BasisSpec { rule: String, #[serde(default)] contributor_adjusted_basis: Option, #[serde(default)] fmv_at_contribution: Option, #[serde(default)] sec704c_method: Option, } /// A prepaid expense or deferred revenue recognized over `months` from /// `start` (rules.md B-16), named `id`: in `setup.schedules`, or registered /// by a `register_schedule` event. #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct ScheduleSpec { id: String, /// `"prepaid_expense"` or `"deferred_revenue"`. kind: String, description: String, total: String, start: String, months: u32, /// `"daily"` or `"monthly_even"`. convention: String, balance_account: String, pl_account: String, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct MemberSpec { /// The member's name: a party's in `setup.parties` (with the member /// role) when the example asks about the member as a party. name: String, capital_account: String, /// Their share of income, an exact decimal fraction; every member has /// one or none does, and the shares sum to 1. A close to the members /// needs them. #[serde(default)] share: Option, /// Their distributions account (rules.md B-13), when they have one. #[serde(default)] distributions_account: Option, } /// A payee marked for an information return from the beginning (rules.md /// B-19): a vendor record, the party's of that name in `setup.parties` /// when there is one with the vendor role. #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct PayeeSpec { name: String, form: String, } /// One party in the directory (rules.md B-26, B-35). #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct PartySpec { name: String, /// Role keys (`member`, `vendor`, `customer`, `contractor`, /// `financial_institution`, ...); `vendor` and `customer` give the /// party its record (one of them at most). roles: Vec, /// The liabilities, by code, the company owes the party itself /// (rules.md B-35 item 6). #[serde(default)] creditor_of: Vec, /// Its TIN facts' last four digits (an SSN's). #[serde(default)] tin_last4: Option, /// Its W-9 facts' last four digits (a 1099 role's). #[serde(default)] w9_tin_last4: Option, /// The vault's copy of the TIN purged, the facts kept (needs /// `tin_last4`). #[serde(default)] tin_purged: bool, } /// A payee's 1099 decision from `effective` on (rules.md B-19, D-40). #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct StatusChangeSpec { payee: String, effective: String, /// `"1099-NEC"`, `"1099-MISC"`, `"not_reportable"` (with `reason_kind` /// and `reason`) or `"undetermined"`. decision: String, #[serde(default)] reason_kind: Option, #[serde(default)] reason: Option, } /// A payment method (rules.md B-19). #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct InstrumentSpec { label: String, /// `bank`, `business_card`, `member_card`, `member_cash_or_check` or /// `platform`. kind: String, /// The account it pays through, by code. account: String, #[serde(default)] last4: Option, /// The party it belongs to, by name (informational). #[serde(default)] owner: Option, } /// A party named on an entry, with its share: an `amount`, a `percent`, /// or neither (derived from the entry: rules.md B-35). #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct ShareSpec { party: String, #[serde(default)] amount: Option, #[serde(default)] percent: Option, } /// One event: an entry (`lines`), a reversal (`reverses`), a close /// (`close`), a bill (`bill`), or a command that posts what is due /// (`run_depreciation`, `dispose_asset`, `run_schedules`) or registers a /// schedule (`register_schedule`), exactly one of them. #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Event { /// The day of an entry, a reversal, a bill or a disposal, the last day /// of a close; a command that dates its own entries takes none. #[serde(default)] date: Option, /// An entry's, a reversal's, a close's or a bill's; a command writes /// its own. #[serde(default)] description: Option, #[serde(default)] lines: Vec, reverses: Option, #[serde(default)] close: Option, #[serde(default)] bill: Option, #[serde(default)] run_depreciation: Option, #[serde(default)] dispose_asset: Option, #[serde(default)] run_schedules: Option, #[serde(default)] register_schedule: Option, /// The engine refuses the event, for this reason in the words of /// [`refusal`]: it changes nothing, and the books go on without it. /// For a reversal or a command, which the books take whole or not at /// all. #[serde(default)] refused: Option, /// The payee (in `setup.payees`) the entry paid. #[serde(default)] payee: Option, /// The parties (in `setup.parties`) the entry is with, and their /// shares. #[serde(default)] parties: Vec, /// The payment method (an `setup.instruments` label) it was paid with. #[serde(default)] instrument: Option, } /// A depreciation run of `asset` for `period` ("YYYY-MM"): what is due /// through the month's end, dated then (rules.md B-15). #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct RunSpec { asset: String, period: String, } /// The disposal of `asset` on the event's date for `proceeds`, received /// into `proceeds_account`, its gain or loss to `gain_loss_account` /// (rules.md B-15). #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct DisposeSpec { asset: String, proceeds: String, proceeds_account: String, gain_loss_account: String, } /// Every schedule's months due through `through`, posted (rules.md B-16). #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct RunSchedulesSpec { through: String, } /// A bill from `vendor` for `amount`, dated and due on the event's date: /// Dr General Expenses (6999), Cr Accounts Payable (2000), each created /// with its fixed name when first needed, as the engine creates a control /// role's account (rules.md B-32). #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct BillSpec { vendor: String, amount: String, } /// A change to the books that posts no entry, made right after event /// `after` (counting from 1) once the expectations asked after that event /// are checked: a rename (`rename`), the tax-category policy /// (`require_tax_category`) or an asset's correction (`correct_asset`), /// exactly one of them. #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct ChangeSpec { after: usize, #[serde(default)] rename: Option, /// `policy.require_tax_category`: "warn" (the books' default) or /// "refuse" (rules.md B-32). #[serde(default)] require_tax_category: Option, #[serde(default)] correct_asset: Option, } /// A registered asset's economics corrected (a registration mistake), for /// `reason`: each figure given replaces the asset's; the rest stay. #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct CorrectSpec { asset: String, #[serde(default)] cost: Option, #[serde(default)] salvage: Option, #[serde(default)] life_months: Option, #[serde(default)] in_service: Option, reason: String, } /// Rename the account `code` to `name` (rules.md B-31). #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct RenameSpec { code: String, name: String, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct LineSpec { account: String, side: String, amount: String, /// The project (a `setup.projects` name) the line is tagged with. #[serde(default)] project: Option, } /// A close of the period from `start` through the event's date, to /// `"members"` (by share, in the order listed) or `"retained_earnings"`; /// `rounding = "per-close"`: a members close split on its own (books from /// an engine that rounded each close by itself), else the cumulative rule /// (rules.md B-13). #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct CloseSpec { start: String, to: String, #[serde(default)] rounding: Option, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Expect { query: String, from: Option, to: Option, as_of: Option, /// The event whose entry an `entry` or `party_part` query asks about. entry: Option, /// The `run_depreciation` event a `run` query asks about. event: Option, /// The fixed asset (`setup.assets` id) a `disposal`, /// `depreciation_schedule`, `register_row` or `asset_basis` query asks /// about. asset: Option, /// The schedule (its id) a `schedule_plan` query asks about. schedule: Option, /// The party a `party_part`, `party_totals` or `party_tin_last4` /// query asks about (in `setup.parties`). party: Option, /// The project (in `setup.projects`) a `project` query asks about. #[serde(default)] project: Option, /// `party_totals`' basis: `"cash"` or `"all"` (rules.md B-35 item 4). basis: Option, /// The payee a `vendor_1099` query asks about (in `setup.payees`). payee: Option, /// The books after event N, not after the last. after: Option, /// The account a `rename_account` or `set_tax_category` command /// names, by code. code: Option, /// The new name a `rename_account` command gives. name: Option, /// The category a `set_tax_category` command sets (absent: it clears /// the account's). category: Option, /// The account an `add_account` command adds. account: Option, /// A command the engine accepts: `true` (never `false`: a command is /// accepted or `refused`). accepted: Option, /// A command, or a `period`, the engine refuses: why, in the words /// of [`refusal`]. refused: Option, #[serde(default)] values: Option, } /// What a query expects: figures by key (every figure an exact decimal /// string), or, for a file format, its lines or its rows of fields. #[derive(Deserialize)] #[serde(untagged)] enum Values { Figures(BTreeMap), Lines(Vec), Rows(Vec>), } impl Values { fn is_empty(&self) -> bool { match self { Values::Figures(f) => f.is_empty(), Values::Lines(l) => l.is_empty(), Values::Rows(r) => r.is_empty(), } } } impl Expect { /// The values a query expects; panics when it gives none. fn values(&self, id: &str) -> &Values { self.values .as_ref() .unwrap_or_else(|| panic!("{id}: {} expects values", self.query)) } /// The figures a figure query expects; panics on lines or rows. fn figures(&self, id: &str) -> &BTreeMap { match self.values(id) { Values::Figures(f) => f, _ => panic!("{id}: {} expects figures (a table)", self.query), } } /// The period `[from, to]` a period query covers, each date omitted /// resolved by the one rule (rules.md B-30) on `setup.today`; panics /// on `as_of`, on an omitted date without `setup.today`, and on a /// period the rule refuses. fn period(&self, ex: &Example) -> (NaiveDate, NaiveDate) { self.resolved_period(ex) .unwrap_or_else(|e| panic!("{}: {}: {e}", ex.id, self.query)) } /// [`Self::period`], or the rule's refusal. fn resolved_period(&self, ex: &Example) -> Result<(NaiveDate, NaiveDate), LedgerError> { let id = &ex.id; assert!( self.as_of.is_none(), "{id}: {} covers a period (from, to), not as_of", self.query ); let from = self.from.as_deref().map(|s| date(s, id)); let to = self.to.as_deref().map(|s| date(s, id)); // A `period` query asks the rule itself, both dates given or not. if let (Some(from), Some(to)) = (from, to) && self.query != "period" { assert!(from <= to, "{id}: {} from {from} after to {to}", self.query); return Ok((from, to)); } defaults(ex, &self.query).statement(from, to) } /// The day an as-of query is made as of (omitted: `setup.today`, /// rules.md B-30); panics on `from` or `to`. fn day(&self, ex: &Example) -> NaiveDate { let id = &ex.id; assert!( self.from.is_none() && self.to.is_none(), "{id}: {} is as of a day (as_of), not a period", self.query ); match self.as_of.as_deref() { Some(s) => date(s, id), None => defaults(ex, &self.query).as_of(None), } } } /// What an omitted date means for `ex` (rules.md B-30): its fiscal year /// on `setup.today`, which `query` needs. fn defaults(ex: &Example, query: &str) -> PeriodDefaults { let today = ex.setup.today.as_deref().unwrap_or_else(|| { panic!( "{}: {query} omits a date: setup.today says what it means (rules.md B-30)", ex.id ) }); let company = Company { fiscal_year_start: fiscal_year_start(ex), ..Company::default() }; period_defaults(&company, date(today, &format!("{} today", ex.id))) } /// `setup.fiscal_year_start`, MM-DD, as (month, day). fn fiscal_year_start(ex: &Example) -> (u32, u32) { let s = &ex.setup.fiscal_year_start; let (m, d) = s .split_once('-') .unwrap_or_else(|| panic!("{}: fiscal_year_start {s:?}", ex.id)); let md = ( m.parse() .unwrap_or_else(|_| panic!("{}: fiscal_year_start {s:?}", ex.id)), d.parse() .unwrap_or_else(|_| panic!("{}: fiscal_year_start {s:?}", ex.id)), ); assert!( NaiveDate::from_ymd_opt(2001, md.0, md.1).is_some(), "{}: fiscal_year_start {s:?}", ex.id ); md } /// A refusal in the examples' words (bookspec/README.md), engine-neutral: /// what was refused, never the engine's message. fn refusal(e: &LedgerError) -> String { match e { LedgerError::InvalidDateRange { .. } => "end_before_start", LedgerError::StartAfterToday { .. } => "start_after_today", LedgerError::EmptyAccountName => "blank", LedgerError::AccountNameUnchanged { .. } => "unchanged", LedgerError::DuplicateAccountName { .. } => "sibling_has_it", LedgerError::AccountNameHoldsTin => "holds_a_tin", LedgerError::InvalidAccountName(_) => "not_one_line", LedgerError::DuplicateAccountCode(_) => "code_taken", LedgerError::TaxCategoryRequired { .. } => "tax_category_required", LedgerError::DepreciationPastDisposal(_) => "depreciation_past_disposal", LedgerError::AssetEntryIrreversible(..) => "entry_irreversible", LedgerError::AssetAlreadyDisposed(_) => "already_disposed", LedgerError::InvalidDepreciationPeriod(_) => "invalid_depreciation_period", LedgerError::InvalidSchedule(_) => "invalid_schedule", other => return format!("another refusal: {other}"), } .to_string() } /// [`refusal`] of a command the books refused: the engine's reason, or the /// store's own (none is in the examples' words). fn store_refusal(e: &StoreError) -> String { match e { StoreError::Ledger(e) => refusal(e), other => format!("another refusal: {other}"), } } fn examples_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bookspec/examples") } fn date(s: &str, what: &str) -> NaiveDate { NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap_or_else(|e| panic!("{what}: {s:?}: {e}")) } /// An exact decimal string: digits, an optional sign and point; never a /// float's exponent. fn exact(s: &str, what: &str) -> Decimal { assert!( !s.is_empty() && s.chars() .all(|c| c.is_ascii_digit() || c == '.' || c == '-') && s.matches('.').count() <= 1, "{what}: {s:?} is not an exact decimal string" ); s.parse() .unwrap_or_else(|e| panic!("{what}: {s:?} is not a decimal: {e}")) } /// An example's books: its setup, then its events as they are applied, /// kept by the engine's own store (each command journaled as the books /// journal it), in a directory of their own that goes when they do. struct Books<'a> { ex: &'a Example, store: Store, /// Where the store keeps them. _dir: tempfile::TempDir, currency: Currency, /// The members, with their shares (zero when the example gives none). members: Vec, /// The members who are parties (`setup.parties` with the member /// role), as the settings give them to party shares and the 1099. party_members: Vec, /// The directory's parties, by name. parties: BTreeMap, /// The payees' vendor records, by name. payees: BTreeMap, /// The vendors bills came from, by name (a payee's record if one). vendors: BTreeMap, /// The payment methods, by label. instruments: BTreeMap, /// The fixed assets of `setup.assets`, by id. assets: BTreeMap, /// The projects of `setup.projects`, by name. projects: BTreeMap, /// The schedules of `setup.schedules` and `register_schedule` events, /// by id. schedules: BTreeMap, /// The entries each event applied so far posted, in order: event N's /// at N - 1. posted: Vec>, /// Each `run_depreciation` event's run, by event. runs: BTreeMap, /// What each disposal took, by asset id, as the engine computed it /// just before posting it. disposals: BTreeMap, } /// A store command's result, unwrapped: the example's books take it. fn took(r: Result, what: &str) -> T { r.unwrap_or_else(|e| panic!("{what}: {e}")) } /// Who an example's decisions and records are made by. fn bookspec_source(id: &str) -> Source { Source { decided_by: "bookspec".into(), decided_on: date("2026-01-01", id), citation: None, note: None, } } /// A payee's 1099 decision in an example's words (bookspec/README.md). fn decision(ex: &str, what: &str, kind: Option<&str>, reason: Option<&str>) -> Decision1099 { match what { "not_reportable" => { // Exactly as the README spells it: the engine's parse forgives // case and spaces, an example doesn't. let kind = kind .and_then(ExemptReason::parse) .filter(|k| Some(k.key()) == kind) .unwrap_or_else(|| { panic!("{ex}: not_reportable needs a reason_kind, got {kind:?}") }); let reason = reason.unwrap_or_else(|| panic!("{ex}: not_reportable needs a reason")); Decision1099::not_reportable(kind, reason).unwrap_or_else(|e| panic!("{ex}: {e}")) } "undetermined" | "1099-NEC" | "1099-MISC" => { assert!( kind.is_none() && reason.is_none(), "{ex}: only not_reportable takes reason_kind and reason" ); match Form1099::parse(what) { Some(f) if what.starts_with("1099-") => Decision1099::Files(f), _ => Decision1099::Undetermined, } } other => panic!("{ex}: decision {other:?}"), } } /// The example's directory (`setup.parties`), by name: each party with /// its roles (a vendor or customer with its record), the liabilities it /// is the creditor of, and its TIN and W-9 facts' last four. fn add_parties(ex: &Example, store: &mut Store) -> BTreeMap { let mut out = BTreeMap::new(); for p in &ex.setup.parties { let what = format!("{} party {}", ex.id, p.name); let roles: Vec = p .roles .iter() .map(|r| Role::parse(r).unwrap_or_else(|| panic!("{what}: role {r:?}"))) .collect(); let record = |r: Role| roles.contains(&r); let others: Vec = roles .iter() .copied() .filter(|r| !matches!(r, Role::Vendor | Role::Customer)) .collect(); let id = match (record(Role::Vendor), record(Role::Customer)) { (true, true) => panic!("{what}: a vendor or a customer, not both"), (true, false) => { let v = took(store.add_vendor(&p.name), &what); store.ledger().party_of_vendor(v).expect("a vendor's party") } (false, true) => { let c = took(store.add_customer(&p.name), &what); store .ledger() .party_of_customer(c) .expect("a customer's party") } (false, false) => took(store.add_party(&p.name, &others), &what), }; if record(Role::Vendor) || record(Role::Customer) { for r in &others { took(store.set_party_role(id, *r, true), &what); } } for code in &p.creditor_of { let account = store .ledger() .chart() .find_by_code(code) .unwrap_or_else(|| panic!("{what}: no account {code}")) .id; took( store.set_party_creditor(id, account, true), &format!("{what}: creditor of {code}"), ); } assert!( !p.tin_purged || p.tin_last4.is_some(), "{what}: tin_purged needs tin_last4" ); if let Some(last4) = &p.tin_last4 { let facts = TinFacts { tin_type: TinType::Ssn, last4: last4.clone(), vault_record: (!p.tin_purged).then(|| "0".repeat(64)), purged: p.tin_purged.then(|| "purged: retention ended".into()), }; took(store.set_party_tin(id, Some(facts)), &what); } if let Some(last4) = &p.w9_tin_last4 { let facts = W9Facts { received: date("2026-01-01", &what), entity_type: "individual/sole proprietor".into(), exempt_payee_code: None, fatca_code: None, tin_last4: last4.clone(), kept_at: "wanderbooks vault".into(), vault_record: None, }; took(store.set_party_w9(id, Some(facts)), &what); } assert!(out.insert(p.name.clone(), id).is_none(), "{what}: twice"); } out } /// An asset's basis facts as the books record them. fn basis(b: &BasisSpec, what: &str) -> ledger_core::AssetBasis { let amount = |s: &Option| s.as_deref().map(|v| exact(v, what)); ledger_core::AssetBasis { rule: ledger_core::BasisRule::parse(&b.rule) .unwrap_or_else(|| panic!("{what}: basis rule {:?}", b.rule)), contributor_adjusted_basis: amount(&b.contributor_adjusted_basis), fmv_at_contribution: amount(&b.fmv_at_contribution), sec704c_method: b.sec704c_method.as_deref().map(|m| { ledger_core::Sec704cMethod::parse(m) .unwrap_or_else(|| panic!("{what}: §704(c) method {m:?}")) }), source: None, } } /// A schedule as the books register it. fn new_schedule(l: &Ledger, s: &ScheduleSpec, currency: Currency, what: &str) -> NewSchedule { let account = |code: &str| { l.chart() .find_by_code(code) .unwrap_or_else(|| panic!("{what}: no account {code}")) .id }; NewSchedule { description: s.description.clone(), kind: match s.kind.as_str() { "prepaid_expense" => ScheduleKind::PrepaidExpense, "deferred_revenue" => ScheduleKind::DeferredRevenue, other => panic!("{what}: schedule kind {other:?}"), }, total: Money::new(exact(&s.total, what), currency), start: date(&s.start, what), months: s.months, balance_account: account(&s.balance_account), pl_account: account(&s.pl_account), convention: ScheduleConvention::parse(&s.convention) .unwrap_or_else(|| panic!("{what}: schedule convention {:?}", s.convention)), } } impl<'a> Books<'a> { /// The books an example sets up, before any event: its shape checked, /// its accounts, fixed assets, schedules, parties, members, payees, /// their dated decisions, and payment methods. fn new(ex: &'a Example) -> Self { let s = &ex.setup; let currency = Currency::new(&s.currency).unwrap(); let dir = tempfile::tempdir().unwrap(); let mut store = took( Store::create(&dir.path().join("books"), currency), &format!("{}: the books", ex.id), ); assert!( ex.appendix.as_deref().is_none_or(|a| a == "formats"), "{}: appendix {:?}", ex.id, ex.appendix ); assert!( matches!(s.basis.as_str(), "accrual" | "cash"), "{}: basis {:?}", ex.id, s.basis ); fiscal_year_start(ex); if let Some(today) = &s.today { date(today, &format!("{} today", ex.id)); } assert!( matches!( s.rounding.as_str(), "half-even" | "half-away-from-zero" | "largest-remainder" ), "{}: rounding {:?}", ex.id, s.rounding ); // The engine splits a close among the members by largest // remainder (rules.md B-13): an example closing to the members // says so. assert!( s.rounding == "largest-remainder" || !ex .events .iter() .any(|e| e.close.as_ref().is_some_and(|c| c.to == "members")), "{}: a close to the members rounds by largest-remainder", ex.id ); let id = |l: &Ledger, code: &str| { l.chart() .find_by_code(code) .unwrap_or_else(|| panic!("{}: no account {code}", ex.id)) .id }; for a in &s.accounts { let what = format!("{}: account {}", ex.id, a.code); // A parent is declared before its subaccounts. let parent = a.parent.as_deref().map(|p| id(store.ledger(), p)); let account = took( store.add_account(&a.code, &a.name, a.account_type(&ex.id), parent, a.header), &what, ); if let Some(c) = &a.tax_category { took(store.set_account_tax(account, Some(c), None), &what); } } let first = ex .events .iter() .find_map(|e| e.date.as_deref()) .map_or(date("2026-01-01", "start"), |d| date(d, "date")); for fa in &s.fixed_assets { let new = NewAsset { description: format!("Fixed asset on {}", fa.cost_account), in_service_date: first, cost: Money::new(Decimal::ONE, currency), salvage_value: Money::new(Decimal::ZERO, currency), useful_life_months: 12, cost_account: id(store.ledger(), &fa.cost_account), accum_depr_account: id(store.ledger(), &fa.accumulated_account), expense_account: id(store.ledger(), &fa.expense_account), }; took( store.register_asset(new), &format!("{}: fixed asset", ex.id), ); } let mut assets = BTreeMap::new(); for a in &s.assets { let what = format!("{}: asset {}", ex.id, a.id); assert_eq!(a.method, "straight_line", "{what}: method"); let new = NewAsset { description: a.description.clone(), in_service_date: date(&a.in_service, &what), cost: Money::new(exact(&a.cost, &what), currency), salvage_value: Money::new(exact(&a.salvage, &what), currency), useful_life_months: a.life_months, cost_account: id(store.ledger(), &a.cost_account), accum_depr_account: id(store.ledger(), &a.accumulated_account), expense_account: id(store.ledger(), &a.expense_account), }; let asset = took(store.register_asset(new), &what); assert!( assets.insert(a.id.clone(), asset).is_none(), "{what}: id used twice" ); if let Some(b) = &a.basis { took(store.set_asset_basis(asset, basis(b, &what)), &what); } assert!( a.acquired_by.is_none_or(|n| n >= 1 && n <= ex.events.len()), "{what}: acquired_by = {:?}: events count from 1, to {}", a.acquired_by, ex.events.len() ); } let mut schedules = BTreeMap::new(); for sc in &s.schedules { let what = format!("{}: schedule {}", ex.id, sc.id); let new = new_schedule(store.ledger(), sc, currency, &what); let schedule = took(store.register_schedule(new), &what); assert!( schedules.insert(sc.id.clone(), schedule).is_none(), "{what}: id used twice" ); } let shared = s.members.iter().filter(|m| m.share.is_some()).count(); assert!( shared == 0 || shared == s.members.len(), "{}: every member has a share, or none does", ex.id ); let members: Vec = s .members .iter() .map(|m| MemberCapital { name: m.name.clone(), capital_account: id(store.ledger(), &m.capital_account), distribution_account: m .distributions_account .as_deref() .map(|c| id(store.ledger(), c)), share: m.share.as_deref().map_or(Decimal::ZERO, |x| { let share = exact(x, &format!("{} {} share", ex.id, m.name)); assert!(share > Decimal::ZERO, "{}: {}'s share", ex.id, m.name); share }), }) .collect(); if shared > 0 { let total: Decimal = members.iter().map(|m| m.share).sum(); assert_eq!(total, Decimal::ONE, "{}: the shares sum to 1", ex.id); } let parties = add_parties(ex, &mut store); // A member who is a party (by name, with the member role), as the // settings' members are (settings::Company::party_members). let party_members: Vec = members .iter() .filter_map(|m| { let p = store.ledger().party_by_name(&m.name)?; p.roles.contains(&Role::Member).then_some(PartyMember { party: p.id, share: m.share, capital: m.capital_account, distributions: m.distribution_account, }) }) .collect(); let source = bookspec_source(&ex.id); let mut payees = BTreeMap::new(); for p in &s.payees { let what = format!("{}: payee {}", ex.id, p.name); let form = Form1099::parse(&p.form) .filter(|f| f.label() == p.form) .unwrap_or_else(|| panic!("{what}: form {:?}", p.form)); // A payee in the directory with the vendor role is that // party's vendor record. let known = parties .get(&p.name) .and_then(|&id| store.ledger().get_party(id)?.vendor); let v = match known { Some(v) => v, None => took(store.add_vendor(&p.name), &what), }; let dated = Dated1099::from_beginning(Decision1099::Files(form), Some(source.clone())); took(store.set_vendor_1099(v, &dated, Some(false)), &what); assert!( payees.insert(p.name.clone(), v).is_none(), "{}: payee {} twice", ex.id, p.name ); } for c in &s.status_changes { let what = format!("{} status change of {}", ex.id, c.payee); let &v = payees .get(&c.payee) .unwrap_or_else(|| panic!("{what}: payee not in setup")); let dated = Dated1099 { effective: Some(date(&c.effective, &what)), decision: decision( &what, &c.decision, c.reason_kind.as_deref(), c.reason.as_deref(), ), source: Some(source.clone()), }; took(store.set_vendor_1099(v, &dated, Some(false)), &what); } let mut projects = BTreeMap::new(); for p in &s.projects { let what = format!("{}: project {}", ex.id, p.name); let facts = ledger_core::ProjectFacts { kind: p.kind.as_deref().map(|k| { ledger_core::ProjectKind::parse(k) .unwrap_or_else(|| panic!("{what}: kind {k:?}")) }), status: p.status.as_deref().map_or(Default::default(), |k| { ledger_core::ProjectStatus::parse(k) .unwrap_or_else(|| panic!("{what}: status {k:?}")) }), party: p.party.as_ref().map(|n| { *parties .get(n) .unwrap_or_else(|| panic!("{what}: party {n:?} not in setup.parties")) }), ..Default::default() }; let id = took(store.add_project(&p.name, &facts), &what); assert!( projects.insert(p.name.clone(), id).is_none(), "{what}: named twice" ); } let mut instruments = BTreeMap::new(); for i in &s.instruments { let what = format!("{} instrument {}", ex.id, i.label); let kind = InstrumentKind::parse(&i.kind) .filter(|k| k.key() == i.kind) .unwrap_or_else(|| panic!("{what}: kind {:?}", i.kind)); let account = id(store.ledger(), &i.account); let owner = i.owner.as_ref().map(|o| { *parties .get(o) .unwrap_or_else(|| panic!("{what}: owner {o:?} not in setup.parties")) }); let id = took( store.add_instrument(kind, &i.label, i.last4.as_deref(), owner, account), &what, ); assert!( instruments.insert(i.label.clone(), id).is_none(), "{what}: twice" ); } Books { ex, store, _dir: dir, currency, members, party_members, parties, vendors: payees.clone(), payees, instruments, assets, projects, schedules, posted: Vec::new(), runs: BTreeMap::new(), disposals: BTreeMap::new(), } } /// The books as they stand. fn l(&self) -> &Ledger { self.store.ledger() } fn id(&self, code: &str) -> ledger_core::AccountId { self.l() .chart() .find_by_code(code) .unwrap_or_else(|| panic!("{}: no account {code}", self.ex.id)) .id } /// The fixed asset `setup.assets` names `name`. fn asset(&self, name: &str, what: &str) -> AssetId { *self .assets .get(name) .unwrap_or_else(|| panic!("{what}: no asset {name:?} in setup.assets")) } /// The entry event `event` (counting from 1) posted: an event applied /// already, that posted exactly one. fn entry_of(&self, event: usize, what: &str) -> EntryId { assert!( event >= 1 && event <= self.posted.len(), "{what}: event {event}: events count from 1, to the last one applied ({})", self.posted.len() ); match self.posted[event - 1].as_slice() { [e] => *e, other => panic!( "{what}: event {event} posted {} entries: only an event that posts one entry names it", other.len() ), } } /// Event `n` (counting from 1): accepted, or refused for the reason it /// gives and changing nothing; the entries it posted kept as event /// n's, tagged with the parties, payee and payment method it names. fn apply(&mut self, n: usize, e: &Event) { let spec = self.ex; let ex = spec.id.as_str(); let kinds = [ ("lines", !e.lines.is_empty()), ("reverses", e.reverses.is_some()), ("close", e.close.is_some()), ("bill", e.bill.is_some()), ("run_depreciation", e.run_depreciation.is_some()), ("dispose_asset", e.dispose_asset.is_some()), ("run_schedules", e.run_schedules.is_some()), ("register_schedule", e.register_schedule.is_some()), ]; let kind = match kinds.iter().filter(|(_, given)| *given).collect::>()[..] { [(kind, _)] => *kind, _ => panic!( "{ex} event {n}: an event is one of lines, reverses, close, bill, run_depreciation, dispose_asset, run_schedules or register_schedule" ), }; // An entry of the example's own words: dated and described. A // disposal is dated; the other commands date their own entries, // and every command describes its own. let own = matches!(kind, "lines" | "reverses" | "close" | "bill"); assert_eq!( e.date.is_some(), own || kind == "dispose_asset", "{ex} event {n}: {kind} {}", if own || kind == "dispose_asset" { "is dated (date)" } else { "dates its own entries: no date" } ); assert_eq!( e.description.is_some(), own, "{ex} event {n}: {kind} {}", if own { "is described (description)" } else { "describes its own entries: no description" } ); assert!( e.payee.is_none() || kind == "lines", "{ex} event {n}: a payee is an entry's: a reversal takes its original's, a bill names its vendor, a close and a command have none" ); assert!( (e.parties.is_empty() && e.instrument.is_none()) || kind != "reverses", "{ex} event {n}: a reversal takes its original's parties and payment method" ); assert!( (e.parties.is_empty() && e.instrument.is_none()) || own, "{ex} event {n}: {kind} is tagged with no parties or payment method" ); assert!( e.refused.is_none() || !matches!(kind, "lines" | "close" | "bill"), "{ex} event {n}: refused is for a reversal or a command, which the books take whole or not at all" ); let what = format!("{ex} event {n}"); let when = e.date.as_deref().map(|d| date(d, &what)); let description = e.description.as_deref().unwrap_or_default(); let head = self.store.head(); let first = self.l().next_entry_id(); let result = self.take(n, e, kind, when, description, &what); match (result, &e.refused) { (Ok(()), None) => {} (Err(err), Some(want)) => { assert_eq!(store_refusal(&err), *want, "{what}: {kind} refused ({err})"); assert_eq!( self.store.head(), head, "{what}: a refused {kind} changes nothing" ); } (Err(err), None) => panic!("{what}: {kind}: {err}"), (Ok(()), Some(want)) => panic!("{what}: {kind} accepted, expected refused: {want}"), } let next = self.l().next_entry_id(); let posted: Vec = (first..next).collect(); for &id in &posted { assert_eq!( self.l().get_entry(id).map(|x| x.status()), Some(EntryStatus::Posted), "{what}: entry {id}, which it made, is posted" ); } if own && e.refused.is_none() { assert_eq!(posted.len(), 1, "{what}: {kind} posts one entry"); } self.posted.push(posted); // The assets this event's entry put on the books. for a in spec .setup .assets .iter() .filter(|a| a.acquired_by == Some(n)) { let entry = self.entry_of(n, &format!("{what}: asset {}'s acquired_by", a.id)); let asset = self.asset(&a.id, &what); took(self.store.set_asset_acquisition(asset, entry), &what); } if !e.parties.is_empty() { let entry = self.entry_of(n, &what); let shares: Vec = e .parties .iter() .map(|sh| { let party = *self .parties .get(&sh.party) .unwrap_or_else(|| panic!("{what}: party {:?} not in setup", sh.party)); let share = match (&sh.amount, &sh.percent) { (None, None) => Share::Derived, (Some(a), None) => Share::Amount(exact(a, ex)), (None, Some(p)) => Share::Percent(exact(p, ex)), _ => panic!("{what}: a share is an amount or a percent"), }; PartyShare { party, share } }) .collect(); took(self.store.set_entry_parties(entry, &shares), &what); } // The payee after the parties: a payee's party must be among // several named (the vendor tag follows the parties). if let Some(p) = &e.payee { let v = *self .payees .get(p) .unwrap_or_else(|| panic!("{what}: payee {p:?} not in setup")); let entry = self.entry_of(n, &what); took(self.store.set_entry_vendor(entry, Some(v)), &what); } if let Some(label) = &e.instrument { let i = *self .instruments .get(label) .unwrap_or_else(|| panic!("{what}: payment method {label:?} not in setup")); let entry = self.entry_of(n, &what); took(self.store.set_entry_instrument(entry, Some(i)), &what); } } /// Event `n`'s command, given to the books (`kind`, as [`Self::apply`] /// found it): whatever it posts is posted, or the books refuse it. fn take( &mut self, n: usize, e: &Event, kind: &str, when: Option, description: &str, what: &str, ) -> Result<(), StoreError> { let money = |s: &str| { let amount = exact(s, what); assert!(amount > Decimal::ZERO, "{what}: amounts are positive"); amount }; let day = || when.expect("checked: dated"); match kind { "lines" => { let entry = self.store.create_entry(day(), description)?; for (i, line) in e.lines.iter().enumerate() { let side = match line.side.as_str() { "debit" => Side::Debit, "credit" => Side::Credit, other => panic!("{what}: side {other:?}"), }; let amount = Money::new(money(&line.amount), self.currency); let account = self.id(&line.account); self.store.add_line(entry, account, side, amount)?; // Tagged as books.add_line tags it: after the line. if let Some(p) = &line.project { let class = *self.projects.get(p).unwrap_or_else(|| { panic!("{what}: project {p:?} not in setup.projects") }); self.store.set_line_class(entry, i, Some(class))?; } } self.store.post_entry(entry) } "reverses" => { let of = e.reverses.expect("checked: a reversal"); // Events count from 1, as entries do: `reverses = 1` is // the first event (bookspec/README.md). assert!(of >= 1, "{what}: reverses = {of}: events count from 1"); assert!(of < n, "{what}: reverses a later event"); let entry = self.entry_of(of, what); self.store .reverse_entry(entry, day(), description) .map(|_| ()) } "close" => { let c = e.close.as_ref().expect("checked: a close"); let start = date(&c.start, what); let p = self .store .create_period(&format!("event {n}"), start, day())?; let members: Vec = self .members .iter() .map(|m| MemberClose { capital: m.capital_account, share: m.share, distributions: None, }) .collect(); if c.to == "members" { assert!( !members.is_empty() && members.iter().all(|m| m.share > Decimal::ZERO), "{what}: a close to the members needs their shares" ); } let to = match (c.to.as_str(), c.rounding.as_deref()) { ("members", None) => CloseTo::Members(members), ("members", Some("per-close")) => CloseTo::MembersPerClose(members), ("retained_earnings", None) => CloseTo::RetainedEarnings { sweep: vec![] }, other => panic!("{what}: close {other:?}"), }; let closed = self.store.close_period_to(p, &to)?; assert!(closed.is_some(), "{what}: the close has nothing to close"); Ok(()) } "bill" => { let b = e.bill.as_ref().expect("checked: a bill"); let amount = money(&b.amount); let vendor = match self.vendors.get(&b.vendor) { Some(&v) => v, None => { let v = self.store.add_vendor(&b.vendor)?; self.vendors.insert(b.vendor.clone(), v); v } }; let lines = vec![DocumentLine { description: description.to_string(), amount, }]; self.store .create_bill(vendor, day(), day(), lines) .map(|_| ()) } "run_depreciation" => { let r = e.run_depreciation.as_ref().expect("checked: a run"); let asset = self.asset(&r.asset, what); let run = self.store.run_depreciation(asset, &r.period)?; self.runs.insert(n, run); Ok(()) } "dispose_asset" => { let d = e.dispose_asset.as_ref().expect("checked: a disposal"); let asset = self.asset(&d.asset, what); let proceeds = exact(&d.proceeds, what); assert!( proceeds >= Decimal::ZERO, "{what}: proceeds aren't negative" ); let proceeds = Money::new(proceeds, self.currency); let (into, gain_loss) = (self.id(&d.proceeds_account), self.id(&d.gain_loss_account)); // What the disposal takes, as the engine computes it before // posting; the disposal itself refused whenever its // computation is. let preview = self.store.dispose_preview(asset, day(), proceeds); let disposed = self .store .dispose_asset(asset, day(), proceeds, into, gain_loss); match (preview, disposed) { (Ok(p), Ok(_)) => { assert!( self.disposals.insert(d.asset.clone(), p).is_none(), "{what}: asset {} disposed of twice", d.asset ); Ok(()) } (Err(p), Ok(_)) => { panic!("{what}: disposed of, though its computation was refused: {p}") } (_, Err(err)) => Err(err), } } "run_schedules" => { let r = e.run_schedules.as_ref().expect("checked: a run"); let through = date(&r.through, what); self.store.run_schedules(through).map(|_| ()) } "register_schedule" => { let sc = e.register_schedule.as_ref().expect("checked: a schedule"); let new = new_schedule(self.l(), sc, self.currency, what); let schedule = self.store.register_schedule(new)?; assert!( self.schedules.insert(sc.id.clone(), schedule).is_none(), "{what}: schedule id {} used twice", sc.id ); Ok(()) } other => unreachable!("{other} is no event"), } } } impl Books<'_> { /// Make a change that posts no entry (after event `after`). fn change(&mut self, c: &ChangeSpec) { let ex = &self.ex.id; let after = c.after; let what = format!("{ex} change after {after}"); match (&c.rename, &c.require_tax_category, &c.correct_asset) { (Some(r), None, None) => { let account = self.id(&r.code); took(self.store.rename_account(account, &r.name, None), &what); } (None, None, Some(f)) => { let id = self.asset(&f.asset, &what); let a = self.l().get_asset(id).expect("registered").clone(); let amount = |s: &Option, was: Money| { s.as_deref() .map_or(was, |v| Money::new(exact(v, &what), self.currency)) }; let fix = ledger_core::AssetCorrection { cost: amount(&f.cost, a.cost), salvage_value: amount(&f.salvage, a.salvage_value), useful_life_months: f.life_months.unwrap_or(a.useful_life_months), in_service_date: f .in_service .as_deref() .map_or(a.in_service_date, |d| date(d, &what)), }; took(self.store.correct_asset(id, fix, &f.reason), &what); } (None, Some(policy), None) => { assert!( matches!(policy.as_str(), "warn" | "refuse"), "{what}: require_tax_category {policy:?}" ); use ledger_core::settings::Change; let change = Change { key: "policy.require_tax_category".into(), value: Some(serde_json::json!(policy)), }; took( self.store.change_settings(&[change], &bookspec_source(ex)), &what, ); } _ => panic!("{what}: a change is one of rename, require_tax_category or correct_asset"), } } } /// The books an example sets up, with every event and change applied and /// nothing checked. fn load(ex: &Example) -> Books<'_> { let mut b = Books::new(ex); for (i, e) in ex.events.iter().enumerate() { b.apply(i + 1, e); for c in ex.changes.iter().filter(|c| c.after == i + 1) { b.change(c); } } b } /// The `cash_flow` query's value for `key` (bookspec/README.md), or None /// for a key it doesn't know. A key's `` is event N, whose entry /// `entry_of` gives. fn cash_flow_value( cf: &CashFlowStatement, key: &str, entry_of: &dyn Fn(usize) -> EntryId, ) -> Option { let sections = [ ("operating", CashFlowSection::Operating), ("investing", CashFlowSection::Investing), ("financing", CashFlowSection::Financing), ]; let count = |n: usize| Decimal::from(n as u64); Some(match key { "net_change" => cf.net_change_in_cash, "cash_beginning" => cf.cash_beginning, "cash_ending" => cf.cash_ending, "difference" => cf.reconciles.difference, "net_income" => cf.operating_reconciliation.net_income, "noncash.count" => count(cf.noncash_disclosures.len()), _ => { if let Some(&(_, s)) = sections.iter().find(|(k, _)| *k == key) { return Some(cf.total(s)); } let (head, tail) = key.split_once('.')?; if head == "noncash" { let n = entry_of(tail.parse().ok()?); return Some( cf.noncash_disclosures .iter() .filter(|d| d.entry == n) .map(|d| d.amount) .sum(), ); } if head == "reconciliation" { let (kind, account) = match tail.split_once('.') { Some((k, a)) => (k, Some(a)), None => (tail, None), }; let kind = match kind { "depreciation" => ReconcilingKind::Depreciation, "investing_gain_loss" => ReconcilingKind::InvestingGainLoss, "noncash_through" => ReconcilingKind::NoncashThrough, "other_noncash" => ReconcilingKind::OtherNoncash, "balance_change" => ReconcilingKind::OperatingBalanceChange, _ => return None, }; return Some( cf.operating_reconciliation .lines .iter() .filter(|l| l.kind == kind && l.account.as_deref() == account) .map(|l| l.amount) .sum(), ); } let &(_, s) = sections.iter().find(|(k, _)| *k == head)?; let lines = cf.lines(s); if tail == "count" { let entries: BTreeSet = lines.iter().map(|l| l.entry).collect(); return Some(count(entries.len())); } // `
.`, or `
..on_behalf`: of event N's // entry's cash there, what a member or lender handled for the // company. let (n, on_behalf) = match tail.split_once('.') { Some((n, "on_behalf")) => (n, true), Some(_) => return None, None => (tail, false), }; let n = entry_of(n.parse().ok()?); lines .iter() .filter(|l| l.entry == n) .map(|l| if on_behalf { l.on_behalf } else { l.amount }) .sum() } }) } /// The reconciliation key a line answers to. fn reconciliation_key(l: &ledger_core::ReconcilingLine) -> String { let kind = match l.kind { ReconcilingKind::Depreciation => "depreciation", ReconcilingKind::InvestingGainLoss => "investing_gain_loss", ReconcilingKind::NoncashThrough => "noncash_through", ReconcilingKind::OtherNoncash => "other_noncash", ReconcilingKind::OperatingBalanceChange => "balance_change", }; match &l.account { Some(a) => format!("reconciliation.{kind}.{a}"), None => format!("reconciliation.{kind}"), } } /// Check one `cash_flow` expectation, every value it gives. fn check_cash_flow(b: &Books<'_>, x: &Expect) { let (ex, l, members) = (b.ex, b.l(), &b.members); let (start, end) = x.period(ex); let values = x.figures(&ex.id); let params = CashFlowParams { start, end, cash_accounts: codes(l, &ex.setup.cash_accounts, &ex.id), financing_accounts: codes(l, &ex.setup.financing_accounts, &ex.id), investing_accounts: codes(l, &ex.setup.investing_accounts, &ex.id), members: members.to_vec(), }; let cf = cash_flow(l, ¶ms).unwrap_or_else(|e| panic!("{}: {e}", ex.id)); for (key, want) in values { let want = exact(want, &format!("{} {key}", ex.id)); let entry_of = |n: usize| b.entry_of(n, &format!("{} cash_flow {key}", ex.id)); let got = cash_flow_value(&cf, key, &entry_of) .unwrap_or_else(|| panic!("{}: unknown cash_flow key {key:?}", ex.id)); assert_eq!( got, want, "{} [{} .. {}] {key}", ex.id, params.start, params.end ); } // A count given with entries listed: those are all the entries. for section in ["operating", "investing", "financing", "noncash"] { let listed: BTreeSet<&str> = values .keys() .filter_map(|k| k.strip_prefix(section)?.strip_prefix('.')) .filter(|t| *t != "count") .map(|t| t.split_once('.').map_or(t, |(n, _)| n)) .collect(); if let Some(n) = values.get(&format!("{section}.count")) && !listed.is_empty() { assert_eq!( Decimal::from(listed.len() as u64), exact(n, &ex.id), "{}: {section}.count against the entries listed", ex.id ); } } // Reconciliation keys given: every line the statement has is one. if values.keys().any(|k| k.starts_with("reconciliation.")) { for line in &cf.operating_reconciliation.lines { let key = reconciliation_key(line); assert!( values.contains_key(&key), "{}: the statement has {key} ({}), the example doesn't", ex.id, line.amount ); } } } /// The accounts the codes name. fn codes(l: &Ledger, cs: &[String], id: &str) -> Vec { cs.iter() .map(|c| { l.chart() .find_by_code(c) .unwrap_or_else(|| panic!("{id}: no account {c}")) .id }) .collect() } /// Every key is one of `keys`, and every one of `keys` is given: a figure /// query lists everything it covers, so an example can't leave a figure /// unchecked by leaving it out. fn exactly(ex: &Example, x: &Expect, keys: &BTreeSet) { let given: BTreeSet = x.figures(&ex.id).keys().cloned().collect(); assert_eq!( &given, keys, "{}: {} lists exactly what it covers", ex.id, x.query ); } /// Check one `trial_balance` expectation: each account's balance as of /// the day, debits less credits (a credit balance negative), every /// account listed (rules.md B-6, B-33). fn check_trial_balance(ex: &Example, l: &Ledger, x: &Expect) { let as_of = x.day(ex); let tb = trial_balance_as_of(l, as_of); assert!(tb.balanced(), "{}: the trial balance balances", ex.id); let keys = ex.setup.accounts.iter().map(|a| a.code.clone()).collect(); exactly(ex, x, &keys); for (code, want) in x.figures(&ex.id) { let want = exact(want, &format!("{} {code}", ex.id)); let got: Decimal = tb .lines .iter() .filter(|t| &t.code == code) .map(|t| t.debit - t.credit) .sum(); assert_eq!(got, want, "{} trial balance as of {as_of}: {code}", ex.id); } } /// Check one `profit_and_loss` expectation: each revenue and expense /// account's activity over the period on its normal side, closing entries /// left out, and `net_income`, revenue less expenses; every revenue and /// expense account listed (rules.md B-14, B-33). fn check_profit_and_loss(ex: &Example, l: &Ledger, x: &Expect) { let (start, end) = x.period(ex); let pl = profit_and_loss_for_period(l, start, end, None) .unwrap_or_else(|e| panic!("{}: {e}", ex.id)); let mut keys: BTreeSet = ex .setup .accounts .iter() .filter(|a| matches!(a.kind.as_str(), "revenue" | "expense")) .map(|a| a.code.clone()) .collect(); keys.insert("net_income".to_string()); exactly(ex, x, &keys); for (key, want) in x.figures(&ex.id) { let want = exact(want, &format!("{} {key}", ex.id)); let got = if key == "net_income" { pl.net_income() } else { pl.revenue .iter() .chain(&pl.expenses) .filter(|s| &s.code == key) .map(|s| s.amount) .sum() }; assert_eq!( got, want, "{} profit and loss [{start} .. {end}]: {key}", ex.id ); } } /// The 1099 report a `vendor_1099_paid` or `vendor_1099` expectation /// asks for: the calendar year of `to`, from January 1 through `to`, out /// of the cash and financing accounts, an entry's shares resolved with /// the members who are parties (rules.md B-19, B-33). fn report_1099(b: &Books<'_>, x: &Expect) -> (NaiveDate, NaiveDate, ledger_core::Vendor1099Report) { let (ex, l) = (b.ex, b.l()); // A calendar-year count, never the fiscal default (rules.md B-30): // both dates given. assert!( x.from.is_some() && x.to.is_some(), "{}: {} gives from and to", ex.id, x.query ); let (from, to) = x.period(ex); let year = chrono::Datelike::year(&to); assert_eq!( Some(from), NaiveDate::from_ymd_opt(year, 1, 1), "{}: a 1099 counts a calendar year: from is January 1 of to's year", ex.id ); assert!( !ex.setup.cash_accounts.is_empty(), "{}: {} needs cash_accounts (what payees are paid from)", ex.id, x.query ); let sources = PaymentAccounts { cash: codes(l, &ex.setup.cash_accounts, &ex.id), financing: codes(l, &ex.setup.financing_accounts, &ex.id), }; let r = vendor_1099_report(l, year, None, &sources, &b.party_members, Some(to)) .unwrap_or_else(|e| panic!("{}: {e}", ex.id)); (from, to, r) } /// Check one `vendor_1099_paid` expectation: each payee's payments that /// count toward its 1099 ([`report_1099`]), a payment card's left out, /// every payee listed (rules.md B-19, B-33). fn check_vendor_1099_paid(b: &Books<'_>, x: &Expect) { let ex = b.ex; let (from, to, r) = report_1099(b, x); exactly(ex, x, &b.payees.keys().cloned().collect()); for (name, want) in x.figures(&ex.id) { let want = exact(want, &format!("{} {name}", ex.id)); let got: Decimal = r .vendors .iter() .filter(|v| v.vendor_id == b.payees[name]) .map(|v| v.paid) .sum(); assert_eq!(got, want, "{} 1099 paid [{from} .. {to}]: {name}", ex.id); } } /// Check one `vendor_1099` expectation: one payee's line of the 1099 /// worksheet ([`report_1099`]): `status` (`reportable`, /// `under_threshold`, `not_reportable`, `undetermined`), the `threshold` /// applied, and what was `paid` (counts toward its 1099), /// `excluded_card` (a card or payment network's, Form 1099-K), /// `undetermined` (its payment method not recorded) and `not_reportable` /// (paid while decided not reportable); every one given (rules.md B-19). fn check_vendor_1099(b: &Books<'_>, x: &Expect) { let ex = b.ex; let name = x .payee .as_deref() .unwrap_or_else(|| panic!("{}: vendor_1099 names its payee", ex.id)); let v = *b .payees .get(name) .unwrap_or_else(|| panic!("{}: payee {name:?} not in setup", ex.id)); let (from, to, r) = report_1099(b, x); let line = r .vendors .iter() .find(|l| l.vendor_id == v) .unwrap_or_else(|| panic!("{}: the 1099 worksheet has no line for {name}", ex.id)); let keys = [ "status", "threshold", "paid", "excluded_card", "undetermined", "not_reportable", ]; exactly(ex, x, &keys.iter().map(|k| k.to_string()).collect()); let what = |k: &str| format!("{} 1099 [{from} .. {to}] {name}: {k}", ex.id); for (key, want) in x.figures(&ex.id) { if key == "status" { assert_eq!(line.status.key(), want, "{}", what(key)); continue; } let got = match key.as_str() { "threshold" => r.threshold, "paid" => line.paid, "excluded_card" => line.excluded_card, "undetermined" => line.undetermined, _ => line.not_reportable, }; assert_eq!(got, exact(want, &what(key)), "{}", what(key)); } } /// The money party shares resolve against: the cash accounts (every /// asset account but receivables when none are named), the cards and the /// financing accounts (rules.md B-35 item 5). fn party_money(ex: &Example, l: &Ledger) -> MoneyAccounts { ledger_core::parties::money_accounts( l, &codes(l, &ex.setup.cash_accounts, &ex.id), &codes(l, &ex.setup.financing_accounts, &ex.id), ) } /// The party an expectation names. fn expect_party(b: &Books<'_>, x: &Expect) -> PartyId { let name = x .party .as_deref() .unwrap_or_else(|| panic!("{}: {} names its party", b.ex.id, x.query)); *b.parties .get(name) .unwrap_or_else(|| panic!("{}: party {name:?} not in setup.parties", b.ex.id)) } /// Check one `party_part` expectation: entry N's part for the party /// (rules.md B-35): `resolved`, signed as a line on the party's own /// account (debit-positive: paid or given positive, received negative), /// the entry's `direction` (`paid`, `received` or `neither`), and what /// of it moved as money (`cash`) and as property (`in_kind`); every one /// given. fn check_party_part(b: &Books<'_>, x: &Expect) { let (ex, l) = (b.ex, b.l()); assert!( x.from.is_none() && x.to.is_none() && x.as_of.is_none(), "{}: party_part asks about one entry, no dates", ex.id ); let event = x .entry .unwrap_or_else(|| panic!("{}: a party_part query names its event", ex.id)); let n = b.entry_of(event, &format!("{} party_part", ex.id)); let party = expect_party(b, x); let r = ledger_core::shares::resolve( l, n, l.entry_parties(n), &party_money(ex, l), &b.party_members, ) .unwrap_or_else(|why| panic!("{}: entry {n}'s shares: {why}", ex.id)); let part = r .part(party) .unwrap_or_else(|| panic!("{}: entry {n} doesn't name {:?}", ex.id, x.party)); let keys = ["resolved", "direction", "cash", "in_kind"]; exactly(ex, x, &keys.iter().map(|k| k.to_string()).collect()); let what = |k: &str| format!("{} entry {n} {:?}: {k}", ex.id, x.party); for (key, want) in x.figures(&ex.id) { if key == "direction" { assert_eq!(r.target.direction.key(), want, "{}", what(key)); continue; } let got = match key.as_str() { "resolved" => part.amount, "cash" => part.cash, _ => part.in_kind, }; assert_eq!(got, exact(want, &what(key)), "{}", what(key)); } } /// Check one `party_totals` expectation: what was `paid` to and /// `received` from the party from `from` through `to` on `basis` (`cash`: /// money; `all`: money and property in kind), each entry's part on its /// root original's side (rules.md B-35 item 4); both given. fn check_party_totals(b: &Books<'_>, x: &Expect) { let (ex, l) = (b.ex, b.l()); assert!( x.from.is_some() && x.to.is_some(), "{}: party_totals gives from and to", ex.id ); let (from, to) = x.period(ex); let basis = x .basis .as_deref() .map(|k| { Basis::parse(k) .filter(|b| b.key() == k) .unwrap_or_else(|| panic!("{}: basis {k:?}", ex.id)) }) .unwrap_or_else(|| panic!("{}: party_totals names its basis", ex.id)); let party = expect_party(b, x); let a = ledger_core::parties::activity_with( l, party, (from, to), &party_money(ex, l), &b.party_members, ); assert!( a.unresolved.is_empty(), "{}: {:?}'s entries {:?} don't resolve", ex.id, x.party, a.unresolved ); let (paid, received) = a.totals(basis); exactly(ex, x, &["paid".to_string(), "received".to_string()].into()); let f = x.figures(&ex.id); let what = |k: &str| { format!( "{} {:?} [{from} .. {to}] {}: {k}", ex.id, x.party, basis.key() ) }; assert_eq!(paid, exact(&f["paid"], &what("paid")), "{}", what("paid")); assert_eq!( received, exact(&f["received"], &what("received")), "{}", what("received") ); } /// Check one `project` expectation (rules.md B-37): the project's /// `revenue`, `expenses` and `net` for the period (B-30 resolving omitted /// dates), the same `to_date` (every tagged line through `to`), and its /// `unrecovered_cost` (`"none"` unless it is on spec); every one given. /// Through ledger_core::project_figures, as the tools and the viewer. fn check_project(b: &Books<'_>, x: &Expect) { let (ex, l) = (b.ex, b.l()); let name = x .project .as_ref() .unwrap_or_else(|| panic!("{}: a project query names its project", ex.id)); let id = *b .projects .get(name) .unwrap_or_else(|| panic!("{}: project {name:?} not in setup.projects", ex.id)); let (from, to) = x.period(ex); let f = ledger_core::project_figures(l, id, from, to).unwrap_or_else(|e| panic!("{}: {e}", ex.id)); let keys: BTreeSet = [ "revenue", "expenses", "net", "to_date.revenue", "to_date.expenses", "to_date.net", "unrecovered_cost", ] .iter() .map(|k| k.to_string()) .collect(); exactly(ex, x, &keys); let what = |k: &str| format!("{} project {name:?} [{from} .. {to}]: {k}", ex.id); for (key, want) in x.figures(&ex.id) { let got = match key.as_str() { "revenue" => Some(f.period.total_revenue), "expenses" => Some(f.period.total_expenses), "net" => Some(f.period.net_income()), "to_date.revenue" => Some(f.to_date.revenue), "to_date.expenses" => Some(f.to_date.expenses), "to_date.net" => Some(f.to_date.net()), _ => f.to_date.unrecovered_cost, }; match (want.as_str(), got) { ("none", None) => {} ("none", Some(g)) => panic!("{}: {g}, expected none", what(key)), (w, Some(g)) => assert_eq!(g, exact(w, &what(key)), "{}", what(key)), (w, None) => panic!("{}: none, expected {w}", what(key)), } } } /// Check one `party_tin_last4` expectation: the last four of the party's /// TIN the books keep (its TIN facts', else its W-9's; `""` for none: /// rules.md B-26). fn check_party_tin_last4(b: &Books<'_>, x: &Expect) { let ex = b.ex; assert!( x.from.is_none() && x.to.is_none() && x.as_of.is_none(), "{}: party_tin_last4 takes no dates", ex.id ); let party = expect_party(b, x); exactly(ex, x, &["tin_last4".to_string()].into()); let got = b .l() .get_party(party) .and_then(|p| p.tin_last4()) .unwrap_or_default(); assert_eq!( got, x.figures(&ex.id)["tin_last4"], "{}: {:?}'s TIN last four", ex.id, x.party ); } /// Check one `k1` expectation: the K-1 worksheet over the period (rules.md /// B-13; Schedule K-1 Item L), `"."` for the columns /// `beginning`, `contributions_cash`, `contributions_property`, /// `net_income`, `distributions`, `other` and `ending`; the figures named /// are the example's choice. It needs `cash_accounts` (what is money /// rather than property). fn check_k1(ex: &Example, l: &Ledger, members: &[MemberCapital], x: &Expect) { let (from, to) = x.period(ex); assert!( !members.is_empty() && members.iter().all(|m| m.share > Decimal::ZERO), "{}: k1 needs the members and their shares", ex.id ); assert!( !ex.setup.cash_accounts.is_empty(), "{}: k1 needs cash_accounts (what is money rather than property)", ex.id ); let cash = codes(l, &ex.setup.cash_accounts, &ex.id); let rows = ledger_core::k1::k1_worksheet(l, from, to, members, &cash) .unwrap_or_else(|e| panic!("{}: {e}", ex.id)); let mut got = BTreeMap::new(); for r in &rows { for (column, v) in [ ("beginning", r.beginning), ("contributions_cash", r.contributions_cash), ("contributions_property", r.contributions_property), ("net_income", r.net_income), ("distributions", r.distributions), ("other", r.other), ("ending", r.ending), ] { got.insert(format!("{}.{column}", r.name), v); } } for (key, want) in x.figures(&ex.id) { let want = exact(want, &format!("{} {key}", ex.id)); let got = got .get(key) .unwrap_or_else(|| panic!("{}: k1 has no {key}", ex.id)); assert_eq!(*got, want, "{} K-1 [{from} .. {to}]: {key}", ex.id); } } /// The formats appendix's queries, and the test (another crate's) that /// checks them against the engine. const FORMAT_QUERIES: [(&str, &str); 2] = [ ( "iif_lines", "ledger-package the_bookspec_iif_example_matches_the_engine", ), ( "iif_changes", "ledger-package the_bookspec_iif_example_matches_the_engine", ), ]; /// A format query's shape, checked here; its values are checked by /// [`FORMAT_QUERIES`]' test. fn check_format_shape(ex: &Example, x: &Expect) { assert_eq!( ex.appendix.as_deref(), Some("formats"), "{}: {} is a format's: the example is in appendix \"formats\"", ex.id, x.query ); // ledger-package reads the as_of itself: given, never defaulted. assert!(x.as_of.is_some(), "{}: {} gives as_of", ex.id, x.query); x.day(ex); assert!( x.after.is_none(), "{}: {}: a format query is of the books after every event", ex.id, x.query ); match (x.query.as_str(), x.values(&ex.id)) { ("iif_lines", Values::Lines(_)) => {} ("iif_changes", Values::Rows(rows)) => { for r in rows { assert_eq!(r.len(), 3, "{}: iif_changes row {r:?}", ex.id); } } (q, _) => panic!("{}: {q}: values of the wrong shape", ex.id), } } /// Check one `entry` expectation: the lines of event N's entry, `".debit"` and /// `".credit"` each summing that account's lines on that side, /// every line of the entry named. fn check_entry(b: &Books<'_>, x: &Expect) { let (ex, l) = (b.ex, b.l()); assert!( x.from.is_none() && x.to.is_none() && x.as_of.is_none(), "{}: entry asks about one entry, no dates", ex.id ); let n = x .entry .unwrap_or_else(|| panic!("{}: an entry query names its event", ex.id)); let entry = l .get_entry(b.entry_of(n, &format!("{} entry {n}", ex.id))) .expect("an entry an event posted"); let mut got: BTreeMap = BTreeMap::new(); for line in entry.lines() { let code = &l.chart().get(line.account).unwrap().code; let side = match line.side { Side::Debit => "debit", Side::Credit => "credit", }; *got.entry(format!("{code}.{side}")).or_default() += line.amount.amount; } let keys = got.keys().cloned().collect(); exactly(ex, x, &keys); for (key, want) in x.figures(&ex.id) { let want = exact(want, &format!("{} {key}", ex.id)); assert_eq!(got[key], want, "{} entry {n}: {key}", ex.id); } } /// Check one `balances` expectation: each account named, its balance as /// of the day on its normal side (so a member's capital after a loss is /// negative); the accounts named are the example's choice. fn check_balances(ex: &Example, l: &Ledger, x: &Expect) { let as_of = x.day(ex); let tb = trial_balance_as_of(l, as_of); for (code, want) in x.figures(&ex.id) { let want = exact(want, &format!("{} {code}", ex.id)); let account = l .chart() .find_by_code(code) .unwrap_or_else(|| panic!("{}: no account {code}", ex.id)); let (dr, cr) = tb .lines .iter() .find(|t| &t.code == code) .map_or((Decimal::ZERO, Decimal::ZERO), |t| (t.debit, t.credit)); let got = match account.account_type.normal_side() { Side::Debit => dr - cr, Side::Credit => cr - dr, }; assert_eq!(got, want, "{} balance as of {as_of}: {code}", ex.id); } } /// Check one `members_equity` expectation: the statement of changes in /// members' equity over the period, `"."` for the columns /// `beginning`, `contributions`, `net_income`, `distributions`, `other` /// and `ending`; the figures named are the example's choice (rules.md /// B-13). fn check_members_equity(ex: &Example, l: &Ledger, members: &[MemberCapital], x: &Expect) { let (from, to) = x.period(ex); assert!( !members.is_empty() && members.iter().all(|m| m.share > Decimal::ZERO), "{}: members_equity needs the members and their shares", ex.id ); let st = members_equity(l, from, to, members).unwrap_or_else(|e| panic!("{}: {e}", ex.id)); let mut got = BTreeMap::new(); for m in &st.members { for (column, v) in [ ("beginning", m.beginning), ("contributions", m.contributions), ("net_income", m.net_income), ("distributions", m.distributions), ("other", m.other), ("ending", m.ending), ] { got.insert(format!("{}.{column}", m.name), v); } } for (key, want) in x.figures(&ex.id) { let want = exact(want, &format!("{} {key}", ex.id)); let got = got .get(key) .unwrap_or_else(|| panic!("{}: members_equity has no {key}", ex.id)); assert_eq!( *got, want, "{} members' equity [{from} .. {to}]: {key}", ex.id ); } } /// No dates: a query about one thing the books did, not about a day. fn undated(ex: &Example, x: &Expect) { assert!( x.from.is_none() && x.to.is_none() && x.as_of.is_none(), "{}: {} takes no dates", ex.id, x.query ); } /// Check one `run` expectation: what the `run_depreciation` event `event` /// posted (`amount`: zero when nothing was due, or the month already had /// its run) and, of it, what it caught up beyond its own month's /// depreciation (`caught_up`), both listed (rules.md B-15). fn check_run(b: &Books<'_>, x: &Expect) { let ex = b.ex; undated(ex, x); let n = x .event .unwrap_or_else(|| panic!("{}: run names its event", ex.id)); let run = b.runs.get(&n).unwrap_or_else(|| { panic!( "{}: event {n} is no run_depreciation (or not yet run)", ex.id ) }); let posted = if run.already_posted { Decimal::ZERO } else { run.amount }; let caught_up = (posted - run.scheduled).max(Decimal::ZERO); exactly( ex, x, &["amount".to_string(), "caught_up".to_string()].into(), ); for (key, got) in [("amount", posted), ("caught_up", caught_up)] { let want = exact(&x.figures(&ex.id)[key], &format!("{} {key}", ex.id)); assert_eq!(got, want, "{} run of event {n}: {key}", ex.id); } } /// Check one `disposal` expectation: what the disposal of `asset` took, as /// the engine computed it when posting it (rules.md B-15), every figure /// listed: `final_depreciation` (posted in its own entry before the /// disposal), `catch_up_depreciation` and `excess_depreciation` (charged, /// and credited back, in the disposal entry), `accumulated` (cleared), /// `book_value` and `gain_loss` (a gain positive). fn check_disposal(b: &Books<'_>, x: &Expect) { let ex = b.ex; undated(ex, x); let asset = x .asset .as_deref() .unwrap_or_else(|| panic!("{}: disposal names its asset", ex.id)); let p = b .disposals .get(asset) .unwrap_or_else(|| panic!("{}: asset {asset:?} wasn't disposed of", ex.id)); let figures = [ ("final_depreciation", p.final_depreciation), ("catch_up_depreciation", p.catch_up_depreciation), ("excess_depreciation", p.excess_depreciation), ("accumulated", p.accumulated), ("book_value", p.book_value), ("gain_loss", p.gain_loss), ]; exactly(ex, x, &figures.iter().map(|(k, _)| k.to_string()).collect()); for (key, got) in figures { let want = exact(&x.figures(&ex.id)[key], &format!("{} {key}", ex.id)); assert_eq!(got, want, "{} disposal of {asset}: {key}", ex.id); } } /// A month key, "YYYY-MM", checked. fn month_key(key: &str, what: &str) { assert!( key.len() == 7 && NaiveDate::parse_from_str(&format!("{key}-01"), "%Y-%m-%d").is_ok(), "{what}: {key:?} is no month (YYYY-MM)" ); } /// Check one `depreciation_schedule` expectation: the schedule of `asset` /// through `as_of` (rules.md B-15), each month named (`"YYYY-MM"`) its /// depreciation, 0.00 for a month the schedule doesn't reach; /// `accumulated` and `book_value` through `as_of`; the example names the /// figures it shows. fn check_depreciation_schedule(b: &Books<'_>, x: &Expect) { let ex = b.ex; let as_of = x.day(ex); let name = x .asset .as_deref() .unwrap_or_else(|| panic!("{}: depreciation_schedule names its asset", ex.id)); let what = format!("{} depreciation_schedule of {name} through {as_of}", ex.id); let asset = b .l() .get_asset(b.asset(name, &what)) .expect("a registered asset"); let lines = ledger_core::depreciation_schedule(asset, as_of).unwrap_or_else(|e| panic!("{what}: {e}")); for (key, want) in x.figures(&ex.id) { let want = exact(want, &format!("{what}: {key}")); let got = match key.as_str() { "accumulated" => ledger_core::accumulated_scheduled_through(asset, as_of), "book_value" => ledger_core::scheduled_book_value(asset, as_of), month => { month_key(month, &what); Ok(lines .iter() .find(|l| l.period == month) .map_or(Decimal::ZERO, |l| l.amount)) } } .unwrap_or_else(|e| panic!("{what}: {key}: {e}")); assert_eq!(got, want, "{what}: {key}"); } } /// Check one `schedule_plan` expectation: what the schedule's plan /// recognizes at each month end named (`"YYYY-MM-DD"`, 0.00 at a month end /// the plan doesn't post), and `total`, all it recognizes (rules.md B-16); /// the example names the figures it shows. fn check_schedule_plan(b: &Books<'_>, x: &Expect) { let ex = b.ex; undated(ex, x); let name = x .schedule .as_deref() .unwrap_or_else(|| panic!("{}: schedule_plan names its schedule", ex.id)); let what = format!("{} schedule_plan of {name}", ex.id); let id = *b .schedules .get(name) .unwrap_or_else(|| panic!("{what}: no schedule {name:?}")); let plan = b .l() .get_schedule(id) .expect("a registered schedule") .plan() .unwrap_or_else(|e| panic!("{what}: {e}")); for (key, want) in x.figures(&ex.id) { let want = exact(want, &format!("{what}: {key}")); let got: Decimal = if key == "total" { plan.iter().map(|(_, a)| *a).sum() } else { let d = date(key, &what); assert_eq!( d, ledger_core::schedules::month_end(d), "{what}: {key} is no month end" ); plan.iter().filter(|(m, _)| *m == d).map(|(_, a)| *a).sum() }; assert_eq!(got, want, "{what}: {key}"); } } /// Check expectations whose values are named figures: each key one of /// `figures`' (an unknown one refused), its value the figure's text. fn named + Ord>( ex: &Example, x: &Expect, what: &str, figures: &BTreeMap, ) { for (key, want) in x.figures(&ex.id) { let got = figures .get(key.as_str()) .unwrap_or_else(|| panic!("{what}: no figure {key:?}")); assert_eq!(got, want, "{what}: {key}"); } } /// A decimal figure as an example writes it: two places at least, as the /// books keep money (exact; "0.00", "-14.20"). fn figure(d: Decimal) -> String { let mut d = d.normalize(); if d.scale() < 2 { d.rescale(2); } d.to_string() } /// Check one `register_row` expectation: the asset's row of the fixed /// asset register as of `as_of` (rules.md B-15 rules 7 and 8, B-24): /// `acquired` and `in_service` (`YYYY-MM-DD`), `cost`, /// `accumulated_scheduled` (the schedule's), `accumulated_posted` (what /// the books carry), `unposted` (the one less the other), `book_value` /// (cost less posted); each 0.00 once disposed; the example names the /// figures it shows. An asset not on the register by `as_of` (acquired /// later) is refused. fn check_register_row(b: &Books<'_>, x: &Expect) { let ex = b.ex; let as_of = x.day(ex); let name = x .asset .as_deref() .unwrap_or_else(|| panic!("{}: register_row names its asset", ex.id)); let what = format!("{} register_row of {name} as of {as_of}", ex.id); let id = b.asset(name, &what); let r = ledger_core::fixed_asset_register(b.l(), as_of).unwrap_or_else(|e| panic!("{what}: {e}")); let row = r .rows .iter() .find(|row| row.asset_id == id) .unwrap_or_else(|| panic!("{what}: not on the register (acquired later)")); let figures = BTreeMap::from([ ("acquired", row.acquired_date.to_string()), ("in_service", row.in_service_date.to_string()), ("cost", figure(row.cost)), ("accumulated_scheduled", figure(row.accumulated_scheduled)), ("accumulated_posted", figure(row.accumulated_posted)), ( "unposted", figure(row.accumulated_scheduled - row.accumulated_posted), ), ("book_value", figure(row.book_value)), ]); named(ex, x, &what, &figures); } /// Check one `register_totals` expectation: the fixed asset register's /// totals as of `as_of`, over the assets held (rules.md B-24, B-25): /// `total_cost`, `total_accumulated_scheduled`, `total_accumulated_posted`, /// `total_book_value`, `total_depreciable_basis`, `total_tax_basis_723`, /// `missing_basis` (how many held have none), and how it reconciles to /// the books: `unregistered_cost`, `unposted_depreciation`, /// `unattributed_accumulated`; the example names the figures it shows. fn check_register_totals(b: &Books<'_>, x: &Expect) { let ex = b.ex; let as_of = x.day(ex); let what = format!("{} register_totals as of {as_of}", ex.id); let r = ledger_core::fixed_asset_register(b.l(), as_of).unwrap_or_else(|e| panic!("{what}: {e}")); let figures = BTreeMap::from([ ("total_cost", figure(r.total_cost)), ( "total_accumulated_scheduled", figure(r.total_accumulated_scheduled), ), ( "total_accumulated_posted", figure(r.total_accumulated_posted), ), ("total_book_value", figure(r.total_book_value)), ("total_depreciable_basis", figure(r.total_depreciable_basis)), ("total_tax_basis_723", figure(r.total_tax_basis_723)), ("missing_basis", r.missing_basis.to_string()), ("unregistered_cost", figure(r.unregistered_cost())), ("unposted_depreciation", figure(r.unposted_depreciation())), ( "unattributed_accumulated", figure(r.unattributed_accumulated()), ), ]); named(ex, x, &what, &figures); } /// Check one `asset_basis` expectation: the asset's basis facts and the /// tax figures they give (rules.md B-25): `rule`, /// `contributor_adjusted_basis`, `fmv_at_contribution`, `tax_basis_723`, /// `depreciable_basis`, `built_in_gain_704c` (a loss negative), each /// `"none"` where the asset has none (a purchase's §723 basis), and /// `sec704c_1c` (`"true"`: a built-in loss, which counts only for the /// contributing member, IRC §704(c)(1)(C)); the example names the figures /// it shows. fn check_asset_basis(b: &Books<'_>, x: &Expect) { let ex = b.ex; undated(ex, x); let name = x .asset .as_deref() .unwrap_or_else(|| panic!("{}: asset_basis names its asset", ex.id)); let what = format!("{} asset_basis of {name}", ex.id); let a = b .l() .get_asset(b.asset(name, &what)) .expect("a registered asset"); let f = a .basis_figures() .unwrap_or_else(|e| panic!("{what}: {e}")) .unwrap_or_else(|| panic!("{what}: no basis recorded")); let opt = |d: Option| d.map_or("none".to_string(), figure); let rule = a.basis.as_ref().expect("a basis recorded").rule; let figures = BTreeMap::from([ ("rule", rule.key().to_string()), ( "contributor_adjusted_basis", opt(a.basis.as_ref().and_then(|b| b.contributor_adjusted_basis)), ), ("fmv_at_contribution", opt(a.fmv_at_contribution())), ("tax_basis_723", opt(f.tax_basis_723)), ("depreciable_basis", figure(f.depreciable_basis)), ("built_in_gain_704c", opt(f.built_in_gain_704c)), ("sec704c_1c", a.sec704c_1c().to_string()), ]); named(ex, x, &what, &figures); } /// The tax-basis capital worksheet for `[from, to]` (rules.md B-36), a /// row a member, as `k1` reads the book one: it needs the members and /// their shares, and `cash_accounts`. fn tax_capital_rows(b: &Books<'_>, x: &Expect, what: &str) -> Vec { let ex = b.ex; let (from, to) = x.period(ex); assert!( !b.members.is_empty() && b.members.iter().all(|m| m.share > Decimal::ZERO), "{what}: needs the members and their shares" ); assert!( !ex.setup.cash_accounts.is_empty(), "{what}: the K-1 worksheet needs cash_accounts" ); let cash = codes(b.l(), &ex.setup.cash_accounts, &ex.id); ledger_core::tax_capital::tax_basis_capital(b.l(), from, to, &b.members, &cash) .unwrap_or_else(|e| panic!("{what}: {e}")) .rows } /// Check one `k1_tax_basis` expectation: each member's capital account /// for `[from, to]` on the tax basis, as Schedule K-1 Item L reports it /// (rules.md B-36), `"."` for the lines `beginning`, /// `contributions_cash`, `contributions_property`, `net_income`, `other`, /// `distributions` and `ending`, beside `k1`'s book figures; a line the /// books can't know is `"open"`, with what they know so far as /// `".known_"`. The example names the figures it shows. fn check_k1_tax_basis(b: &Books<'_>, x: &Expect) { let ex = b.ex; let what = format!("{} k1_tax_basis", ex.id); let mut figures = BTreeMap::new(); for row in tax_capital_rows(b, x, &what) { let m = &row.book.name; figures.insert( format!("{m}.contributions_cash"), figure(row.contributions_cash), ); for (line, tax) in [ ("beginning", &row.beginning), ("contributions_property", &row.contributions_property), ("net_income", &row.net_income), ("other", &row.other), ("distributions", &row.distributions), ("ending", &row.ending), ] { figures.insert( format!("{m}.{line}"), tax.tax().map_or("open".to_string(), figure), ); figures.insert(format!("{m}.known_{line}"), figure(tax.known)); } } named(ex, x, &what, &figures); } /// The causes `k1_reconciliation` reports, each member's every one. const RECONCILING_CAUSES: [&str; 5] = [ "beginning", "built_in_gain_704c", "property_without_basis", "property_distributed", "total", ]; /// Check one `k1_reconciliation` expectation: each member's book capital /// less tax-basis capital at the end of `[from, to]`, by cause (rules.md /// B-36), `"."` for `beginning`, `built_in_gain_704c` /// (their share of the §704(c) gain or loss at contribution), /// `property_without_basis`, `property_distributed` and `total`: the /// example names the members it shows, each with every cause. fn check_k1_reconciliation(b: &Books<'_>, x: &Expect) { let ex = b.ex; let what = format!("{} k1_reconciliation", ex.id); let mut figures = BTreeMap::new(); for row in tax_capital_rows(b, x, &what) { let r = &row.reconciliation; let total = r .total() .unwrap_or_else(|| panic!("{what}: the total is too large")); for (cause, v) in RECONCILING_CAUSES.into_iter().zip([ r.beginning, r.built_in_gain_704c, r.property_without_basis, r.property_distributed, total, ]) { figures.insert(format!("{}.{cause}", row.book.name), figure(v)); } } let named_members: BTreeSet<&str> = x .figures(&ex.id) .keys() .map(|k| k.split_once('.').map_or(k.as_str(), |(m, _)| m)) .collect(); for m in named_members { for cause in RECONCILING_CAUSES { assert!( x.figures(&ex.id).contains_key(&format!("{m}.{cause}")), "{what}: {m} is shown with every cause: {m}.{cause} is missing" ); } } named(ex, x, &what, &figures); } /// The commands an example may try: each on a copy of the books, so it /// changes nothing that is checked after it. const COMMANDS: [&str; 3] = ["rename_account", "add_account", "set_tax_category"]; /// The account `code` names, for a command. fn command_account(ex: &Example, l: &Ledger, x: &Expect) -> ledger_core::AccountId { let code = x .code .as_deref() .unwrap_or_else(|| panic!("{}: {} names its account (code)", ex.id, x.query)); l.chart() .find_by_code(code) .unwrap_or_else(|| panic!("{}: no account {code}", ex.id)) .id } /// Every balance, by code: what a change that moves no amount leaves as /// it was. fn every_balance(l: &Ledger) -> Vec<(String, Decimal, Decimal)> { trial_balance_as_of(l, ledger_core::calendar::LAST_DAY) .lines .into_iter() .map(|t| (t.code, t.debit, t.credit)) .collect() } /// Try one command on a copy of the books, as the books' own command does /// it (the engine's checks, then the policy's: rules.md B-31, B-32), and /// check it is accepted or refused for the reason given. fn check_command(ex: &Example, l: &Ledger, x: &Expect) { let mut copy = l.clone(); let result: Result<(), LedgerError> = match x.query.as_str() { "rename_account" => { let account = command_account(ex, l, x); let name = x .name .as_deref() .unwrap_or_else(|| panic!("{}: rename_account gives the name", ex.id)); copy.rename_account(account, name).inspect(|()| { // A rename changes the label, nothing else (rules.md B-31). assert_eq!( copy.chart().get(account).unwrap().name, name.trim(), "{}: rename_account keeps the name trimmed", ex.id ); assert_eq!( every_balance(©), every_balance(l), "{}: a rename moves no amount", ex.id ); }) } "add_account" => { let a = x .account .as_ref() .unwrap_or_else(|| panic!("{}: add_account gives the account", ex.id)); let kind = a.account_type(&ex.id); let parent = a.parent.as_deref().map(|p| { l.chart() .find_by_code(p) .unwrap_or_else(|| panic!("{}: no account {p}", ex.id)) .id }); copy.add_account(&a.code, &a.name, kind, parent, a.header) .and_then(|id| match &a.tax_category { Some(c) => copy.set_account_tax(id, Some(c), None), None => Ok(()), }) .and_then(|()| { l.check_tax_category_required( &a.code, &a.name, kind, a.header, a.tax_category.as_deref(), ) }) } "set_tax_category" => { let account = command_account(ex, l, x); let category = x.category.as_deref(); copy.set_account_tax(account, category, None) .and_then(|()| l.check_tax_category_kept(account, category)) } q => unreachable!("{q} is no command"), }; match (result, &x.refused) { (Ok(()), None) => {} (Err(e), Some(want)) => { assert_eq!(refusal(&e), *want, "{}: {} refused ({e})", ex.id, x.query) } (Err(e), None) => panic!("{}: {} refused ({e}), expected accepted", ex.id, x.query), (Ok(()), Some(want)) => panic!("{}: {} accepted, expected refused: {want}", ex.id, x.query), } } /// Check one `period` expectation: the period a period report asked with /// these dates covers, by the one rule (rules.md B-30), as `from` and /// `to`; or the rule's refusal. fn check_period(ex: &Example, x: &Expect) { match (x.resolved_period(ex), &x.refused) { (Ok((from, to)), None) => { exactly(ex, x, &["from".to_string(), "to".to_string()].into()); let f = x.figures(&ex.id); assert_eq!(from, date(&f["from"], &ex.id), "{}: period from", ex.id); assert_eq!(to, date(&f["to"], &ex.id), "{}: period to", ex.id); } (Err(e), Some(want)) => assert_eq!(refusal(&e), *want, "{}: period refused ({e})", ex.id), (Err(e), None) => panic!("{}: period refused ({e})", ex.id), (Ok((from, to)), Some(want)) => { panic!("{}: period {from} .. {to}, expected refused: {want}", ex.id) } } } /// Check one `day` expectation: the day a report as of a day is made as /// of (rules.md B-30), as `as_of`. fn check_day(ex: &Example, x: &Expect) { exactly(ex, x, &["as_of".to_string()].into()); let want = date(&x.figures(&ex.id)["as_of"], &ex.id); assert_eq!(x.day(ex), want, "{}: day", ex.id); } /// Check one `calendar_year` expectation: the year a calendar-year count /// (1099s, party totals) asked without one counts (rules.md B-30). fn check_calendar_year(ex: &Example, x: &Expect) { assert!( x.from.is_none() && x.to.is_none() && x.as_of.is_none(), "{}: calendar_year takes no dates", ex.id ); exactly(ex, x, &["year".to_string()].into()); let want: i32 = x.figures(&ex.id)["year"] .parse() .unwrap_or_else(|e| panic!("{}: calendar_year year: {e}", ex.id)); assert_eq!( defaults(ex, &x.query).calendar_year(), want, "{}: calendar_year", ex.id ); } /// Check one `account_names` expectation: each account's name, by code, /// as the chart holds it and every report prints it (the trial balance /// and the profit and loss, over every date: past periods too, rules.md /// B-31); every account listed. fn check_account_names(ex: &Example, l: &Ledger, x: &Expect) { assert!( x.from.is_none() && x.to.is_none() && x.as_of.is_none(), "{}: account_names takes no dates", ex.id ); let keys = l.chart().all().map(|a| a.code.clone()).collect(); exactly(ex, x, &keys); let (first, last) = ( ledger_core::calendar::FIRST_DAY, ledger_core::calendar::LAST_DAY, ); let tb = trial_balance_as_of(l, last); let pl = profit_and_loss_for_period(l, first, last, None) .unwrap_or_else(|e| panic!("{}: {e}", ex.id)); for (code, want) in x.figures(&ex.id) { let a = l.chart().find_by_code(code).unwrap(); assert_eq!(&a.name, want, "{}: account {code}'s name", ex.id); let printed = tb.lines.iter().map(|t| (&t.code, &t.name)).chain( pl.revenue .iter() .chain(&pl.expenses) .map(|s| (&s.code, &s.name)), ); for (c, name) in printed.filter(|(c, _)| *c == code) { assert_eq!(name, want, "{}: a report prints {c} as {name:?}", ex.id); } } } /// Check one `uncategorized_accounts` expectation: every account that /// maps to a line of the return and has no tax category, with activity or /// not, as `" "`, by code (rules.md B-32). fn check_uncategorized_accounts(ex: &Example, l: &Ledger, x: &Expect) { assert!( x.from.is_none() && x.to.is_none() && x.as_of.is_none(), "{}: uncategorized_accounts takes no dates", ex.id ); let Values::Lines(want) = x.values(&ex.id) else { panic!("{}: uncategorized_accounts expects a list", ex.id) }; let got: Vec = ledger_core::tax::uncategorized_accounts(l) .into_iter() .map(|a| format!("{} {}", a.code, a.name)) .collect(); assert_eq!(&got, want, "{}: uncategorized_accounts", ex.id); } /// Check one expectation against the books as they stand: a query the /// core answers, value by value; a format query's shape, its values left /// to the test [`FORMAT_QUERIES`] names (returned); a query no crate /// answers is refused. fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> { let (ex, l) = (b.ex, b.l()); let q = x.query.as_str(); let only = |given: bool, key: &str, queries: &[&str]| { assert!( !given || queries.contains(&q), "{}: {q}: {key} is for {} only", ex.id, queries.join(" and ") ); }; only(x.entry.is_some(), "entry", &["entry", "party_part"]); only(x.event.is_some(), "event", &["run"]); only( x.asset.is_some(), "asset", &[ "disposal", "depreciation_schedule", "register_row", "asset_basis", ], ); only(x.schedule.is_some(), "schedule", &["schedule_plan"]); only( x.party.is_some(), "party", &["party_part", "party_totals", "party_tin_last4"], ); only(x.basis.is_some(), "basis", &["party_totals"]); only(x.payee.is_some(), "payee", &["vendor_1099"]); only( x.code.is_some(), "code", &["rename_account", "set_tax_category"], ); only(x.name.is_some(), "name", &["rename_account"]); only(x.category.is_some(), "category", &["set_tax_category"]); only(x.account.is_some(), "account", &["add_account"]); only(x.accepted.is_some(), "accepted", &COMMANDS); only( x.refused.is_some(), "refused", &[ "rename_account", "add_account", "set_tax_category", "period", ], ); if COMMANDS.contains(&q) { // A command is accepted or refused, and shows no values. assert!( x.values.is_none() && (x.accepted == Some(true)) != x.refused.is_some(), "{}: {q} is a command: accepted = true, or refused, and no values", ex.id ); check_command(ex, l, x); return None; } // Anything else shows its values; a period may be refused instead. assert!( x.refused.is_some() != x.values.as_ref().is_some_and(|v| !v.is_empty()), "{}: {q} expects values (a period: values or refused)", ex.id ); match q { "period" => check_period(ex, x), "day" => check_day(ex, x), "calendar_year" => check_calendar_year(ex, x), "account_names" => check_account_names(ex, l, x), "uncategorized_accounts" => check_uncategorized_accounts(ex, l, x), "cash_flow" => check_cash_flow(b, x), "trial_balance" => check_trial_balance(ex, l, x), "profit_and_loss" => check_profit_and_loss(ex, l, x), "vendor_1099_paid" => check_vendor_1099_paid(b, x), "vendor_1099" => check_vendor_1099(b, x), "entry" => check_entry(b, x), "run" => check_run(b, x), "disposal" => check_disposal(b, x), "depreciation_schedule" => check_depreciation_schedule(b, x), "schedule_plan" => check_schedule_plan(b, x), "register_row" => check_register_row(b, x), "register_totals" => check_register_totals(b, x), "asset_basis" => check_asset_basis(b, x), "k1_tax_basis" => check_k1_tax_basis(b, x), "k1_reconciliation" => check_k1_reconciliation(b, x), "balances" => check_balances(ex, l, x), "members_equity" => check_members_equity(ex, l, &b.members, x), "k1" => check_k1(ex, l, &b.members, x), "party_part" => check_party_part(b, x), "party_totals" => check_party_totals(b, x), "party_tin_last4" => check_party_tin_last4(b, x), "project" => check_project(b, x), q => { let &(_, by) = FORMAT_QUERIES .iter() .find(|(f, _)| *f == q) .unwrap_or_else(|| panic!("{}: no crate answers query {q:?}", ex.id)); check_format_shape(ex, x); return Some(by); } } None } /// Load an example and check every expectation: each one with `after = N` /// on the books after event N (before the changes made after it), the /// rest after the last event and every change. Gives each query checked, /// with the test of another crate that checks its values, if any. fn run(ex: &Example) -> Vec<(String, Option<&'static str>)> { let afters = ex .expect .iter() .map(|x| x.after) .chain(ex.changes.iter().map(|c| Some(c.after))); for after in afters { assert!( after.is_none_or(|n| n >= 1 && n <= ex.events.len()), "{}: after = {after:?}: events count from 1, to {}", ex.id, ex.events.len() ); } let mut b = Books::new(ex); let mut out = Vec::new(); for (i, e) in ex.events.iter().enumerate() { b.apply(i + 1, e); for x in ex.expect.iter().filter(|x| x.after == Some(i + 1)) { out.push((x.query.clone(), check(&b, x))); } for c in ex.changes.iter().filter(|c| c.after == i + 1) { b.change(c); } } for x in ex.expect.iter().filter(|x| x.after.is_none()) { out.push((x.query.clone(), check(&b, x))); } // What the books kept replays to the books (the journal, the live // books and their projection agree). b.store .verify() .unwrap_or_else(|e| panic!("{}: the books replay as kept: {e}", ex.id)); out } #[test] fn every_bookspec_example_holds() { let mut files: Vec = std::fs::read_dir(examples_dir()) .expect("bookspec/examples") .map(|e| e.unwrap().path()) .filter(|p| p.extension().is_some_and(|x| x == "toml")) .collect(); files.sort(); let mut ids = BTreeSet::new(); let mut checked: BTreeMap = BTreeMap::new(); for f in &files { let text = std::fs::read_to_string(f).unwrap(); let ex: Example = toml::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", f.display())); let stem = f.file_stem().unwrap().to_string_lossy(); assert_eq!(ex.id, stem, "{}: the file is named for its id", f.display()); assert!(ids.insert(ex.id.clone()), "{}: id used twice", ex.id); assert!( !ex.title.is_empty() && !ex.rule.is_empty() && !ex.tags.is_empty(), "{}: title, rule and tags", ex.id ); assert!( matches!(ex.status.as_str(), "AUTHORITY" | "CONVENTION" | "CHOICE"), "{}: status {:?}", ex.id, ex.status ); assert!(!ex.sources.is_empty(), "{}: every rule is sourced", ex.id); assert!(!ex.expect.is_empty(), "{}: something to expect", ex.id); for (q, by) in run(&ex) { if let Some(by) = by { eprintln!("{}: {q} is checked by {by}", ex.id); } *checked.entry(q).or_insert(0) += 1; } } // Each query the examples use ran: the cash flow's six examples at // least, the as-of and 1099 examples', the members closes', the // formats appendix's, and P3's default period, rename, tax category, // parties, 1099, depreciation, schedules, asset basis and K-1 tax-basis // examples'. for (q, at_least) in [ ("cash_flow", 6), ("trial_balance", 2), ("profit_and_loss", 2), ("vendor_1099_paid", 4), ("vendor_1099", 10), ("entry", 8), ("balances", 4), ("members_equity", 10), ("k1", 3), ("party_part", 28), ("party_totals", 23), ("party_tin_last4", 5), ("iif_lines", 1), ("iif_changes", 1), ("period", 6), ("day", 1), ("calendar_year", 1), ("account_names", 2), ("uncategorized_accounts", 2), ("rename_account", 5), ("add_account", 4), ("set_tax_category", 2), ("run", 4), ("disposal", 5), ("depreciation_schedule", 5), ("schedule_plan", 3), ("register_row", 5), ("register_totals", 1), ("asset_basis", 6), ("k1_tax_basis", 2), ("k1_reconciliation", 1), ("project", 6), ] { let n = checked.get(q).copied().unwrap_or(0); assert!(n >= at_least, "{q} ran {n} times, fewer than {at_least}"); } } /// An inline example: two contributions, then `reverses` on the third /// event. fn reversing(reverses: usize) -> Example { toml::from_str(&format!( r#" id = "loader.reverses" title = "reverses counts events from 1" rule = "An event's reverses names the event it reverses, counting from 1." status = "CONVENTION" sources = ["bookspec/README.md"] tags = ["loader"] [setup] currency = "USD" fiscal_year_start = "01-01" basis = "accrual" rounding = "half-even" [[setup.accounts]] code = "1000" name = "Cash" type = "asset" [[setup.accounts]] code = "3000" name = "Capital" type = "equity" [[events]] date = "2026-01-02" description = "First" lines = [ {{ account = "1000", side = "debit", amount = "100.00" }}, {{ account = "3000", side = "credit", amount = "100.00" }}, ] [[events]] date = "2026-01-03" description = "Second" lines = [ {{ account = "1000", side = "debit", amount = "250.00" }}, {{ account = "3000", side = "credit", amount = "250.00" }}, ] [[events]] date = "2026-01-04" description = "Reverse the second" reverses = {reverses} [[expect]] query = "none" [expect.values] none = "0" "# )) .unwrap() } /// bookspec/README.md: `reverses = N` is the Nth event (entry #N), /// counting from 1. `reverses = 2` reverses the second event, 250.00, not /// the first (as counting from 0 would read it) nor itself. #[test] fn reverses_counts_events_from_one() { let ex = reversing(2); let b = load(&ex); let r = b.l().get_entry(3).unwrap(); assert_eq!(r.reversal_of(), Some(2)); let amounts: Vec = r.lines().iter().map(|x| x.amount.amount).collect(); assert_eq!(amounts, [Decimal::new(25000, 2), Decimal::new(25000, 2)]); assert_eq!(r.lines()[0].side, Side::Credit); } /// `reverses = 0` names no event: refused, saying events count from 1. #[test] #[should_panic(expected = "events count from 1")] fn reverses_zero_is_refused() { load(&reversing(0)); } /// A key the shape doesn't have (a typo for a setting) is refused, never /// ignored: an example can't silently run with other roles than it says. #[test] fn an_unknown_key_is_refused() { let text = std::fs::read_to_string( examples_dir().join("cash-flow.reversed-property-contribution.toml"), ) .unwrap() .replace("cash_accounts = ", "cash_account = "); assert!(toml::from_str::(&text).is_err()); } /// The example `file`'s text, changed by `edit`, loaded and its /// expectations checked as `every_bookspec_example_holds` checks them. fn run_edited(file: &str, edit: impl Fn(String) -> String) { let text = edit(std::fs::read_to_string(examples_dir().join(file)).unwrap()); let before = std::fs::read_to_string(examples_dir().join(file)).unwrap(); assert_ne!(text, before, "the edit changed nothing"); let ex: Example = toml::from_str(&text).unwrap(); run(&ex); } /// A query no crate answers is refused, never skipped: its values would /// otherwise go unchecked. #[test] #[should_panic(expected = "no crate answers query")] fn an_unknown_query_is_refused() { run_edited("as-of-package-stops-at-as-of.toml", |t| { t.replacen("query = \"trial_balance\"", "query = \"trial_balances\"", 1) }); } /// A format's query belongs to the formats appendix: an example without /// `appendix = "formats"` can't ask it. #[test] #[should_panic(expected = "appendix \"formats\"")] fn a_format_query_outside_the_formats_appendix_is_refused() { run_edited("iif-quickbooks-journal-file.toml", |t| { t.replacen("appendix = \"formats\"\n", "", 1) }); } /// The appendix is "formats" or absent. #[test] #[should_panic(expected = "appendix Some(\"format\")")] fn an_unknown_appendix_is_refused() { run_edited("iif-quickbooks-journal-file.toml", |t| { t.replacen("appendix = \"formats\"", "appendix = \"format\"", 1) }); } /// An event's payee must be one of the setup's payees. #[test] #[should_panic(expected = "not in setup")] fn a_payee_not_in_the_setup_is_refused() { run_edited("1099-worksheet-through-as-of.toml", |t| { t.replacen( "payee = \"Contractor A\"\nlines", "payee = \"Contractor B\"\nlines", 1, ) }); } /// A figure query lists everything it covers: a trial balance leaving an /// account out is refused, so no balance goes unchecked. #[test] #[should_panic(expected = "lists exactly what it covers")] fn a_trial_balance_leaving_an_account_out_is_refused() { run_edited("as-of-package-stops-at-as-of.toml", |t| { t.replacen(", \"6000\" = \"1200.00\" }", " }", 1) }); } /// A trial balance is as of a day, a profit and loss over a period: the /// wrong dates are refused, never read some other way. #[test] #[should_panic(expected = "is as of a day")] fn a_trial_balance_over_a_period_is_refused() { run_edited("as-of-package-stops-at-as-of.toml", |t| { t.replacen( "query = \"trial_balance\"\nas_of = \"2026-06-30\"", "query = \"trial_balance\"\nfrom = \"2026-01-01\"\nto = \"2026-06-30\"", 1, ) }); } /// An `entry` query names every line of the entry: one left out is /// refused, so the entry is exactly what is expected. #[test] #[should_panic(expected = "lists exactly what it covers")] fn an_entry_leaving_a_line_out_is_refused() { run_edited("members-close.true-up-of-a-per-close-split.toml", |t| { t.replacen("\"3000.debit\" = \"0.01\"\n", "", 1) }); } /// `after = N` checks the books after event N: the statement to April 30 /// asked before April's close (A 0.03, B 0.01) fails when asked after it /// (A 0.02, B 0.02). #[test] #[should_panic(expected = "members' equity [2026-01-01 .. 2026-04-30]: A.ending")] fn after_is_the_books_after_that_event() { run_edited("members-close.true-up-of-a-per-close-split.toml", |t| { t.replacen("after = 7\n", "", 1) }); } /// `after` counts events from 1 up to the last. #[test] #[should_panic(expected = "events count from 1")] fn after_past_the_last_event_is_refused() { run_edited("members-close.true-up-of-a-per-close-split.toml", |t| { t.replacen("after = 6\n", "after = 9\n", 1) }); } /// A close to the members needs every member's share, and the shares sum /// to 1. #[test] #[should_panic(expected = "the shares sum to 1")] fn shares_that_dont_sum_to_one_are_refused() { run_edited("members-close.true-up-of-a-per-close-split.toml", |t| { t.replacen("share = \"0.5\"", "share = \"0.4\"", 1) }); } /// The rounding an example declares is the engine's for what it asks: a /// members close splits by largest remainder. #[test] #[should_panic(expected = "rounds by largest-remainder")] fn a_members_close_declared_with_another_rounding_is_refused() { run_edited("members-close.true-up-of-a-per-close-split.toml", |t| { t.replacen( "rounding = \"largest-remainder\"", "rounding = \"half-even\"", 1, ) }); } /// A period's dates are the rule's (rules.md B-30): an example saying the /// fiscal year to date starts on January 1 under a July fiscal year fails. #[test] #[should_panic(expected = "period from")] fn a_period_other_than_the_rules_is_refused() { run_edited("default-period-fiscal-year-to-date.toml", |t| { t.replacen( "values = { from = \"2026-07-01\", to = \"2026-10-20\" }", "values = { from = \"2026-01-01\", to = \"2026-10-20\" }", 1, ) }); } /// A refusal is checked for its reason: a start after today is not "the /// end before the start". #[test] #[should_panic(expected = "period refused")] fn a_refusal_for_another_reason_is_refused() { run_edited("default-period-fiscal-year-to-date.toml", |t| { t.replacen( "refused = \"start_after_today\"", "refused = \"end_before_start\"", 1, ) }); } /// A query that omits a date needs `setup.today`: without it, nothing /// says what the omitted date means. #[test] #[should_panic(expected = "setup.today says what it means")] fn an_omitted_date_without_today_is_refused() { run_edited("default-period-fiscal-year-to-date.toml", |t| { t.replacen("today = \"2026-10-20\"\n", "", 1) }); } /// `account_names` lists every account: one left out is refused. #[test] #[should_panic(expected = "lists exactly what it covers")] fn account_names_leaving_an_account_out_is_refused() { run_edited("rename-changes-no-amount.toml", |t| { t.replacen(", \"6160\" = \"Hosting\" }", " }", 1) }); } /// A change is made after the event it names, once that event's /// expectations are checked: asked after the rename, the old name fails. #[test] #[should_panic(expected = "account 6150's name")] fn a_change_is_made_after_its_events_expectations() { run_edited("rename-changes-no-amount.toml", |t| { t.replacen( "\"6150\" = \"Software subscriptions\", \"6160\" = \"Hosting\" }", "\"6150\" = \"Software\", \"6160\" = \"Hosting\" }", 1, ) }); } /// A command is tried under the policy in force when it is asked: without /// the change to refuse, the account without a category is accepted. #[test] #[should_panic(expected = "accepted, expected refused: tax_category_required")] fn a_command_runs_under_the_policy_in_force() { run_edited("tax-category-required.toml", |t| { t.replacen( "[[changes]]\nafter = 1\nrequire_tax_category = \"refuse\"\n", "", 1, ) }); } /// A command is accepted or refused, never both, and shows no values. #[test] #[should_panic(expected = "is a command")] fn a_command_with_values_is_refused() { run_edited("tax-category-required.toml", |t| { t.replacen( "category = \"other_deductions\"\naccepted = true\n", "category = \"other_deductions\"\naccepted = true\nvalues = { x = \"1\" }\n", 1, ) }); } /// An event's parties must be in the setup's directory. #[test] #[should_panic(expected = "not in setup")] fn a_party_not_in_the_setup_is_refused() { run_edited("p3-party-signs-and-reversals.toml", |t| { t.replacen( "parties = [{ party = \"Tobin Harlow\" }]", "parties = [{ party = \"Tobin Harlo\" }]", 1, ) }); } /// A party's part is checked for its direction as well as its figures: /// a contribution read as paid fails. #[test] #[should_panic(expected = "entry 2 Some(\"Tobin Harlow\"): direction")] fn a_party_parts_direction_is_checked() { run_edited("p3-party-signs-and-reversals.toml", |t| { t.replacen( "resolved = \"-400.00\", direction = \"received\"", "resolved = \"-400.00\", direction = \"paid\"", 1, ) }); } /// A reversal takes its original's parties: naming them again is /// refused, never read as a second tag. #[test] #[should_panic(expected = "takes its original's parties")] fn a_reversal_naming_parties_is_refused() { run_edited("p3-party-signs-and-reversals.toml", |t| { t.replacen( "reverses = 4\n", "reverses = 4\nparties = [{ party = \"Tobin Harlow\" }]\n", 1, ) }); } /// Party totals name their basis: cash and all differ for property. #[test] #[should_panic(expected = "party_totals names its basis")] fn party_totals_without_a_basis_are_refused() { run_edited("p3-party-signs-and-reversals.toml", |t| { t.replacen("basis = \"all\"\n", "", 1) }); } /// A `vendor_1099` line lists every figure it covers: one left out is /// refused. #[test] #[should_panic(expected = "lists exactly what it covers")] fn a_vendor_1099_line_leaving_a_figure_out_is_refused() { run_edited("1099-card-payments-are-excluded.toml", |t| { t.replacen(", not_reportable = \"0.00\" }", " }", 1) }); } /// A `vendor_1099` line's status is checked: card payments that would /// reach the threshold leave the payee under it. #[test] #[should_panic(expected = "Harbor Design LLC: status")] fn a_vendor_1099_status_is_checked() { run_edited("1099-card-payments-are-excluded.toml", |t| { t.replacen("status = \"under_threshold\"", "status = \"reportable\"", 1) }); } /// A payment method an event names must be in the setup. #[test] #[should_panic(expected = "payment method \"Harbor debit\" not in setup")] fn a_payment_method_not_in_the_setup_is_refused() { run_edited("1099-card-payments-are-excluded.toml", |t| { t.replacen( "instrument = \"Harbor debit card\"", "instrument = \"Harbor debit\"", 1, ) }); } /// A value from a closed list is spelled exactly as bookspec/README.md /// spells it: the engine's own parse forgives case and spaces, the /// loader doesn't (a spec has one spelling). #[test] #[should_panic(expected = "form \"1099-nec\"")] fn a_form_in_another_case_is_refused() { run_edited("1099-a-dated-status-applies-by-payment-date.toml", |t| { t.replacen("form = \"1099-NEC\"", "form = \"1099-nec\"", 1) }); } #[test] #[should_panic(expected = "kind \"Business_Card\"")] fn a_payment_method_kind_in_another_case_is_refused() { run_edited("1099-card-payments-are-excluded.toml", |t| { t.replacen("kind = \"business_card\"", "kind = \"Business_Card\"", 1) }); } #[test] #[should_panic(expected = "not_reportable needs a reason_kind")] fn a_reason_kind_in_another_case_is_refused() { run_edited("1099-a-dated-status-applies-by-payment-date.toml", |t| { t.replacen( "reason_kind = \"corporation\"", "reason_kind = \"Corporation\"", 1, ) }); } /// An event the example says the engine refuses must be refused: one it /// takes fails the example (March's run reversed on its own day is taken). #[test] #[should_panic(expected = "accepted, expected refused: entry_irreversible")] fn an_event_the_engine_takes_is_not_refused() { run_edited( "depreciation-reversal-before-its-run-is-refused.toml", |t| t.replacen("date = \"2026-02-10\"", "date = \"2026-03-31\"", 1), ); } /// A refused event is refused for the reason it gives, in the examples' /// words, never another. #[test] #[should_panic(expected = "reverses refused")] fn an_event_refused_for_another_reason_is_refused() { run_edited( "depreciation-reversal-before-its-run-is-refused.toml", |t| { t.replacen( "refused = \"entry_irreversible\"", "refused = \"depreciation_past_disposal\"", 1, ) }, ); } /// A refused event posts nothing, so nothing can name its entry. #[test] #[should_panic(expected = "event 6 posted 0 entries")] fn a_refused_event_has_no_entry_to_reverse() { run_edited( "depreciation-reversal-before-its-run-is-refused.toml", |t| { t.replacen( "reverses = 5\n\n# Hand check", "reverses = 6\n\n# Hand check", 1, ) }, ); } /// A command dates its own entries: a run given a date is refused, never /// read as the day it runs. #[test] #[should_panic(expected = "dates its own entries: no date")] fn a_run_given_a_date_is_refused() { run_edited("depreciation-catch-up-month.toml", |t| { t.replacen( "[[events]]\nrun_depreciation", "[[events]]\ndate = \"2026-01-31\"\nrun_depreciation", 1, ) }); } /// `run` asks about a run_depreciation event, nothing else. #[test] #[should_panic(expected = "event 2 is no run_depreciation")] fn a_run_query_naming_another_event_is_refused() { run_edited("depreciation-catch-up-month.toml", |t| { t.replacen("event = 4", "event = 2", 1) }); } /// A disposal lists every figure it took: one left out is refused. #[test] #[should_panic(expected = "lists exactly what it covers")] fn a_disposal_leaving_a_figure_out_is_refused() { run_edited("depreciation-disposal-mid-month.toml", |t| { t.replacen("catch_up_depreciation = \"0.00\"\n", "", 1) }); } /// The example's figures are the engine's, checked: a schedule month off /// by a cent fails it. #[test] #[should_panic(expected = "schedule_plan of SUB: 2026-02-28")] fn a_schedule_month_off_by_a_cent_fails() { run_edited("schedule-daily-deferred-revenue.toml", |t| { t.replacen( "\"2026-02-28\" = \"333.34\"", "\"2026-02-28\" = \"333.33\"", 1, ) }); } /// An asset is on the register from its acquired date: a row asked for /// before it is refused, never shown with figures. #[test] #[should_panic(expected = "not on the register (acquired later)")] fn a_register_row_before_the_asset_was_acquired_is_refused() { run_edited("assets-acquired-before-in-service.toml", |t| { t.replacen("as_of = \"2026-03-31\"", "as_of = \"2026-03-09\"", 1) }); } /// `acquired_by` names an event whose entry put the asset on the books: /// an event that posts no entry can't. #[test] #[should_panic(expected = "event 2 posted 0 entries")] fn an_asset_acquired_by_an_event_with_no_entry_is_refused() { run_edited("assets-a-reversed-run-is-not-posted.toml", |t| { t.replacen("acquired_by = 1", "acquired_by = 2", 1) .replacen( "[[events]]\nrun_depreciation = { asset = \"printer\", period = \"2026-01\" }", "[[events]]\nrun_depreciation = { asset = \"printer\", period = \"2025-12\" }\nrefused = \"invalid_depreciation_period\"", 1, ) }); } /// A tax line the books can't know is "open", never a figure: an example /// giving the book figure for it fails. #[test] #[should_panic(expected = "k1_tax_basis: Tobin Harlow.net_income")] fn a_tax_line_the_books_cant_know_is_open() { run_edited("partnership-k1-tax-basis-capital.toml", |t| { t.replacen( "\"Tobin Harlow.net_income\" = \"open\"", "\"Tobin Harlow.net_income\" = \"-385.00\"", 1, ) }); } /// A member's reconciliation is shown whole: an example leaving out a /// cause fails, so a figure can't quietly go unchecked. #[test] #[should_panic(expected = "Tobin Harlow.property_distributed is missing")] fn a_members_reconciliation_shows_every_cause() { run_edited("partnership-k1-tax-basis-capital.toml", |t| { t.replacen(", \"Tobin Harlow.property_distributed\" = \"0.00\"", "", 1) }); } /// A project named twice in `setup.projects` is refused (names are /// unique, rules.md B-37). #[test] #[should_panic(expected = "a project named")] fn a_project_named_twice_is_refused() { run_edited("projects-net-is-its-tagged-lines.toml", |t| { t.replacen("name = \"Beta\"", "name = \"alpha\"", 1) }); } /// A line naming a project the setup doesn't have is refused. #[test] #[should_panic(expected = "not in setup.projects")] fn a_line_naming_an_unknown_project_is_refused() { run_edited("projects-net-is-its-tagged-lines.toml", |t| { t.replacen("project = \"Beta\" }", "project = \"Gamma\" }", 1) }); } /// A project query lists every value it covers. #[test] #[should_panic(expected = "lists exactly what it covers")] fn a_project_query_leaving_a_value_out_is_refused() { run_edited("projects-on-spec-costs-are-expenses.toml", |t| { t.replacen("unrecovered_cost = \"1460.00\"\n", "", 1) }); }