diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs index 482eed1..316aa45 100644 --- a/crates/ledger-core/src/error.rs +++ b/crates/ledger-core/src/error.rs @@ -123,6 +123,42 @@ pub enum LedgerError { spec: String, }, + /// A child account subdivides its parent, so it must be the same type + /// (Stephen, 2026-10-13). + #[error( + "account {code} is {found:?}, but its parent {parent_code} is {expected:?}; a child account must be the same type as its parent" + )] + ParentTypeMismatch { + /// The new account's code. + code: String, + /// The parent's code. + parent_code: String, + /// The parent's type, which the child must share. + expected: crate::AccountType, + /// The type the child was given. + found: crate::AccountType, + }, + + /// Periods nest (a month in a quarter in a year) but never partially + /// overlap (Stephen, 2026-10-13). + #[error( + "period {name} ({start} to {end}) partially overlaps period {other} ({other_start} to {other_end}); periods may nest, but not overlap" + )] + PeriodsOverlap { + /// The new period's name. + name: String, + /// Its first day. + start: chrono::NaiveDate, + /// Its last day. + end: chrono::NaiveDate, + /// The existing period's name. + other: String, + /// The existing period's first day. + other_start: chrono::NaiveDate, + /// The existing period's last day. + other_end: chrono::NaiveDate, + }, + /// The referenced journal entry does not exist. #[error("entry {0} does not exist")] UnknownEntry(u64), diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index ddaf2b2..c0d5fb3 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -492,6 +492,22 @@ impl Ledger { } /// Add an account to the chart. Returns its id. + /// + /// ```text + /// GOAL: register an account under a unique code. + /// SOURCE: rules.md B-4 (the five elements); R-4; DECISION (Stephen, + /// 2026-10-13): a child has its parent's type. + /// SCENARIOS: + /// 1. IF the code is empty or taken: SHOULD refuse. + /// 2. IF the parent doesn't exist: SHOULD refuse. + /// 3. IF the parent is another type: + /// - SHOULD refuse with ParentTypeMismatch + /// - NEVER add the account + /// BECAUSE a child subdivides its parent. + /// ``` + /// + /// Replay of journals written before the parent rule uses + /// [`Self::replay_add_account`]. pub fn add_account( &mut self, code: &str, @@ -499,6 +515,30 @@ impl Ledger { account_type: AccountType, parent: Option, placeholder: bool, + ) -> Result { + if let Some(p) = parent.and_then(|p| self.chart.get(p)) + && p.account_type != account_type + { + return Err(LedgerError::ParentTypeMismatch { + code: code.to_string(), + parent_code: p.code.clone(), + expected: p.account_type, + found: account_type, + }); + } + self.replay_add_account(code, name, account_type, parent, placeholder) + } + + /// [`Self::add_account`] without the rules added on 2026-10-13 (a + /// child's type), for replaying journals written before them. Never + /// for new work. + pub fn replay_add_account( + &mut self, + code: &str, + name: &str, + account_type: AccountType, + parent: Option, + placeholder: bool, ) -> Result { let id = self.next_account_id; self.next_account_id += 1; @@ -616,6 +656,32 @@ impl Ledger { account: AccountId, side: Side, amount: Money, + ) -> Result<(), LedgerError> { + self.check_line_account(account)?; + self.replay_add_line(entry, account, side, amount) + } + + /// A line's account must exist and take postings, checked when the + /// line is added (Stephen, 2026-10-13); posting checks again. + fn check_line_account(&self, account: AccountId) -> Result<(), LedgerError> { + let a = self + .chart + .get(account) + .ok_or(LedgerError::UnknownAccount(account))?; + if a.placeholder { + return Err(LedgerError::PlaceholderAccount(a.code.clone())); + } + Ok(()) + } + + /// [`Self::add_line`] without the account check added on 2026-10-13, + /// for replaying journals written before it. Never for new work. + pub fn replay_add_line( + &mut self, + entry: EntryId, + account: AccountId, + side: Side, + amount: Money, ) -> Result<(), LedgerError> { if amount.currency != self.base_currency { return Err(LedgerError::CurrencyMismatch { @@ -648,6 +714,28 @@ impl Ledger { foreign_currency: Currency, foreign_amount: Decimal, fx_rate: Decimal, + ) -> Result<(), LedgerError> { + self.check_line_account(account)?; + self.replay_add_line_fx( + entry, + account, + side, + foreign_currency, + foreign_amount, + fx_rate, + ) + } + + /// [`Self::add_line_fx`] without the account check added on + /// 2026-10-13, for replaying journals written before it. + pub fn replay_add_line_fx( + &mut self, + entry: EntryId, + account: AccountId, + side: Side, + foreign_currency: Currency, + foreign_amount: Decimal, + fx_rate: Decimal, ) -> Result<(), LedgerError> { if foreign_amount <= Decimal::ZERO { return Err(LedgerError::InvalidDocumentLineAmount(foreign_amount)); @@ -919,11 +1007,55 @@ impl Ledger { /// Create an accounting period. The date range must be valid /// (end >= start). Returns the period id. + /// + /// ```text + /// GOAL: name a span of days that can later be closed. + /// SOURCE: rules.md B-12 (IRS Pub. 538: tax years, calendar or fiscal); + /// DECISION (Stephen, 2026-10-13): periods nest, never + /// partially overlap. + /// SCENARIOS: + /// 1. IF end is before start: SHOULD refuse. + /// 2. IF it lies inside, or around, every period it touches: SHOULD + /// accept (a month in a quarter in a year). + /// 3. IF it partially overlaps a period: + /// - SHOULD refuse with PeriodsOverlap + /// - NEVER create it, or use up an id + /// BECAUSE a span split across two periods could close twice or + /// never. + /// ``` pub fn create_period( &mut self, name: &str, start: NaiveDate, end: NaiveDate, + ) -> Result { + periods::validate_period_range(start, end)?; + let nested = |a: (NaiveDate, NaiveDate), b: (NaiveDate, NaiveDate)| { + (a.0 >= b.0 && a.1 <= b.1) || (b.0 >= a.0 && b.1 <= a.1) + }; + for q in self.list_periods() { + let touches = start <= q.end && q.start <= end; + if touches && !nested((start, end), (q.start, q.end)) { + return Err(LedgerError::PeriodsOverlap { + name: name.to_string(), + start, + end, + other: q.name.clone(), + other_start: q.start, + other_end: q.end, + }); + } + } + self.replay_create_period(name, start, end) + } + + /// [`Self::create_period`] without the nesting rule added on + /// 2026-10-13, for replaying journals written before it. + pub fn replay_create_period( + &mut self, + name: &str, + start: NaiveDate, + end: NaiveDate, ) -> Result { periods::validate_period_range(start, end)?; let id = self.next_period_id; diff --git a/crates/ledger-core/tests/invariants.rs b/crates/ledger-core/tests/invariants.rs index de7107c..8ff29cb 100644 --- a/crates/ledger-core/tests/invariants.rs +++ b/crates/ledger-core/tests/invariants.rs @@ -242,12 +242,18 @@ fn fx_overflow_is_an_error() { #[test] fn unknown_account_is_rejected() { + // Refused when the line is added (since 2026-10-13); a line that got + // in anyway (replayed from an older journal) is still refused at post. let c = fixture_company(); let mut ledger = c.ledger; let id = ledger.create_entry(date(21), "Ghost account"); ledger.add_line(id, c.cash, Side::Debit, usd(100)).unwrap(); + assert_eq!( + ledger.add_line(id, 999_999, Side::Credit, usd(100)), + Err(LedgerError::UnknownAccount(999_999)) + ); ledger - .add_line(id, 999_999, Side::Credit, usd(100)) + .replay_add_line(id, 999_999, Side::Credit, usd(100)) .unwrap(); assert_eq!( ledger.post_entry(id).unwrap_err(), @@ -263,7 +269,14 @@ fn placeholder_account_is_rejected() { .add_account("100", "Assets header", AccountType::Asset, None, true) .unwrap(); let id = ledger.create_entry(date(21), "Post to header"); - ledger.add_line(id, header, Side::Debit, usd(100)).unwrap(); + assert_eq!( + ledger.add_line(id, header, Side::Debit, usd(100)), + Err(LedgerError::PlaceholderAccount("100".to_string())) + ); + // Got in anyway (an older journal): still refused at post. + ledger + .replay_add_line(id, header, Side::Debit, usd(100)) + .unwrap(); ledger .add_line(id, c.equity, Side::Credit, usd(100)) .unwrap(); @@ -906,3 +919,100 @@ fn a_close_refuses_a_3999_that_is_not_equity() { assert_untouched(&before, &l); assert!(l.closed_period_containing(date(5)).is_none()); } + +// --------------------------------------------------------------------------- +// Chart, periods, and lines: rules confirmed by Stephen, 2026-10-13. +// --------------------------------------------------------------------------- + +/// A child account subdivides its parent, so it is the same kind of +/// account. SOURCE: rules.md B-4; DECISION (Stephen, 2026-10-13). +#[test] +fn a_child_account_has_its_parents_type() { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, true) + .unwrap(); + l.add_account("1010", "Checking", AccountType::Asset, Some(cash), false) + .unwrap(); + let before = l.chart().len(); + assert_eq!( + l.add_account("1020", "Rent", AccountType::Expense, Some(cash), false), + Err(LedgerError::ParentTypeMismatch { + code: "1020".into(), + parent_code: "1000".into(), + expected: AccountType::Asset, + found: AccountType::Expense, + }) + ); + assert_eq!(l.chart().len(), before); + assert!(l.chart().find_by_code("1020").is_none()); +} + +/// Periods nest (a month in a quarter in a year) but never partially +/// overlap. DECISION (Stephen, 2026-10-13). +#[test] +fn periods_nest_but_never_partially_overlap() { + let d = |m: u32, day: u32| NaiveDate::from_ymd_opt(2026, m, day).unwrap(); + let mut l = Ledger::new(Currency::USD); + l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap(); + l.create_period("2026-02", d(2, 1), d(2, 28)).unwrap(); + // A quarter around both months, and a year around everything. + l.create_period("Q1 2026", d(1, 1), d(3, 31)).unwrap(); + l.create_period("2026", d(1, 1), d(12, 31)).unwrap(); + // A week inside January nests too. + l.create_period("week", d(1, 5), d(1, 11)).unwrap(); + let next = l.next_period_id(); + // Mid-January to mid-February straddles two months: refused. + assert!(matches!( + l.create_period("straddle", d(1, 15), d(2, 15)), + Err(LedgerError::PeriodsOverlap { .. }) + )); + // So is a quarter that starts inside Q1. + assert!(matches!( + l.create_period("odd quarter", d(2, 1), d(4, 30)), + Err(LedgerError::PeriodsOverlap { .. }) + )); + assert_eq!(l.next_period_id(), next, "refused means no id burned"); + // Disjoint is fine. + l.create_period( + "2027-01", + NaiveDate::from_ymd_opt(2027, 1, 1).unwrap(), + NaiveDate::from_ymd_opt(2027, 1, 31).unwrap(), + ) + .unwrap(); +} + +/// A line names a real, postable account when it is added, not only when +/// the entry posts. DECISION (Stephen, 2026-10-13); rules.md B-1. +#[test] +fn a_line_on_an_unknown_or_placeholder_account_is_refused_when_added() { + let c = fixture_company(); + let mut l = c.ledger.clone(); + let header = l + .add_account("1900", "Other assets", AccountType::Asset, None, true) + .unwrap(); + let e = l.create_entry(date(21), "Lines"); + assert_eq!( + l.add_line(e, 999_999, Side::Debit, usd(10)), + Err(LedgerError::UnknownAccount(999_999)) + ); + assert_eq!( + l.add_line(e, header, Side::Debit, usd(10)), + Err(LedgerError::PlaceholderAccount("1900".into())) + ); + assert!(matches!( + l.add_line_fx( + e, + header, + Side::Debit, + Currency::EUR, + Decimal::from(10), + Decimal::from(2) + ), + Err(LedgerError::PlaceholderAccount(_)) + )); + assert!( + l.get_entry(e).unwrap().lines().is_empty(), + "refused lines aren't added" + ); +} diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 5ac4520..5762e76 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -2374,6 +2374,10 @@ impl Store { } /// Rebuild a ledger purely from the SQLite projection (used by verify). + /// Rebuild the ledger from the SQLite projection, for the cross-check. + /// Like journal replay, it reads history, so it uses the engine's + /// `replay_*` commands: rules added later never refuse books written + /// before them. fn load_ledger_from_sqlite(&self) -> Result { let mut ledger = Ledger::new(self.base_currency); let mut stmt = self.conn.prepare( @@ -2400,7 +2404,7 @@ impl Store { "account {code} currency {currency} != base" ))); } - let got = ledger.add_account( + let got = ledger.replay_add_account( &code, &name, account_type, @@ -2477,7 +2481,7 @@ impl Store { })?; for (idx, lr) in lrows.enumerate() { let (account, side, amount, currency, class_id) = lr?; - ledger.add_line( + ledger.replay_add_line( got, account as u64, parse_side(&side)?, @@ -2883,7 +2887,7 @@ pub(crate) fn replay_line_into_ledger( let want = get_u64(p, "id")?; let account_type: AccountType = serde_json::from_value(p["account_type"].clone())?; let parent = p.get("parent").and_then(|v| v.as_u64()); - let got = ledger.add_account( + let got = ledger.replay_add_account( get_str(p, "code")?, get_str(p, "name")?, account_type, @@ -2928,7 +2932,7 @@ pub(crate) fn replay_line_into_ledger( let fx_rate: Decimal = get_str(p, "fx_rate")? .parse() .map_err(|_| StoreError::JournalCorrupt("bad fx rate".into()))?; - ledger.add_line_fx( + ledger.replay_add_line_fx( id, account, side, @@ -2940,7 +2944,7 @@ pub(crate) fn replay_line_into_ledger( )?; } None => { - ledger.add_line(id, account, side, Money::new(amount, currency))?; + ledger.replay_add_line(id, account, side, Money::new(amount, currency))?; } } } @@ -2957,7 +2961,7 @@ pub(crate) fn replay_line_into_ledger( for l in p.get("lines").and_then(|v| v.as_array()).ok_or_else(|| { StoreError::JournalCorrupt("entry.posted without lines".into()) })? { - ledger.add_line( + ledger.replay_add_line( id, get_u64(l, "account")?, parse_side(get_str(l, "side")?)?, @@ -3132,7 +3136,7 @@ pub(crate) fn replay_line_into_ledger( } "period.created" => { let want = get_u64(p, "id")?; - let got = ledger.create_period( + let got = ledger.replay_create_period( get_str(p, "name")?, parse_date(get_str(p, "start")?)?, parse_date(get_str(p, "end")?)?, diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index d606a54..6615ca7 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -1000,3 +1000,55 @@ mod refusals { } } } + +/// Contract (2026-10-13): rules added to the engine never lock anyone out +/// of books written before them. A journal holding what the new rules +/// refuse (a child account of another type, partially overlapping periods, +/// a draft line on a missing account) must still open and verify; replay +/// goes through Ledger::replay_*. +#[test] +fn books_written_under_older_rules_still_open() { + use serde_json::json; + let dir = test_dir("older-rules"); + let mut store = Store::create(&dir, Currency::USD).unwrap(); + let cash = store + .add_account("1000", "Cash", AccountType::Asset, None, true) + .unwrap(); + drop(store); + // What an older wanderbooks could have written. + let mut w = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap(); + w.append( + "account.added", + json!({"id": 2, "code": "1010", "name": "Rent", "account_type": "Expense", + "parent": cash, "placeholder": false, "currency": "USD"}), + ) + .unwrap(); + w.append( + "period.created", + json!({"id": 1, "name": "2026-01", "start": "2026-01-01", "end": "2026-01-31"}), + ) + .unwrap(); + w.append( + "period.created", + json!({"id": 2, "name": "straddle", "start": "2026-01-15", "end": "2026-02-15"}), + ) + .unwrap(); + w.append( + "entry.created", + json!({"id": 1, "date": "2026-03-01", "description": "draft"}), + ) + .unwrap(); + w.append( + "entry.line_added", + json!({"id": 1, "account": 999, "side": "debit", "amount": "10.00", "currency": "USD"}), + ) + .unwrap(); + drop(w); + // Opening rebuilds from the journal; it must accept all of it. + let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE)); + let store = Store::open(&dir).expect("older books still open"); + store.verify().unwrap(); + assert_eq!(store.ledger().chart().len(), 2); + assert_eq!(store.ledger().list_periods().len(), 2); + assert_eq!(store.ledger().get_entry(1).unwrap().lines().len(), 1); +} diff --git a/docs/spec/README.md b/docs/spec/README.md index bf2490b..5ea313f 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -31,6 +31,14 @@ SOURCE: CONVENTION (QuickBooks aging); nearest authority ASC 326-20-55-38 (illus A rule whose only source is CONVENTION or DECISION is a candidate for a human to confirm or overturn; nothing else is. +## Rules added later never lock out older books + +When a rule is tightened (a check added to a command), books written +before it must still open. Journal replay and the SQLite cross-check read +history through the engine's `replay_*` commands, which skip checks added +later; new commands go through the checked ones. Pinned by +`books_written_under_older_rules_still_open` (ledger-store). + ## Vendoring rules - Public-domain texts are copied **verbatim** (whitespace normalized), each diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 8634fe1..c917086 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -27,7 +27,10 @@ Spec blocks in the code cite these as `SOURCE: rules.md B-n`. total credits after you post the journal entries to the ledger accounts." **Enforced:** `kernel::check_lines` via `JournalEntry::validate` (two or -more lines, debits = credits exactly). **Checked:** Kani +more lines, debits = credits exactly). A line's account must exist and +take postings when the line is added, not only at post: DECISION +(Stephen, 2026-10-13), `Ledger::add_line` +(`a_line_on_an_unknown_or_placeholder_account_is_refused_when_added`). **Checked:** Kani `check_lines_is_exact`, `accepted_entry_nets_to_zero`; Lean `Accepted.net_zero`; property `prop_posted_entries_balance`. @@ -74,7 +77,9 @@ its liabilities … from delivering or producing goods, rendering services, or carrying out other activities." E81 expenses "are outflows or other using up of assets … or incurrences of its liabilities …". -**Enforced:** `AccountType` (five variants). **Checked:** by type. +**Enforced:** `AccountType` (five variants). A child account has its +parent's type: DECISION (Stephen, 2026-10-13), `Ledger::add_account` +(`ParentTypeMismatch`). **Checked:** `a_child_account_has_its_parents_type`. ## B-5 Normal balances @@ -178,7 +183,10 @@ the entity Periods themselves: IRS Pub. 538 (`sources/irs-periods-and-methods.md`) defines the tax year, calendar and fiscal. Locking a closed period and closing in order are practice (and follow from B-8: a closed period's -book is not rewritten). **Enforced:** `closed_period_containing`, +book is not rewritten). Periods nest (a month in a quarter in a year) +and never partially overlap: DECISION (Stephen, 2026-10-13), +`Ledger::create_period` (`PeriodsOverlap`; +`periods_nest_but_never_partially_overlap`). **Enforced:** `closed_period_containing`, `CloseOutOfOrder`, `Store::rehearse`. **Checked:** Lean `Steps.frozen`; store property `refused_commands_never_reach_the_journal` (no draft in a closed period).