From 22d1a3d449ab6227214bcf510258f8eef1c0840d Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 13 Oct 2026 17:48:20 -0600 Subject: [PATCH] store: refusal property reaches every success path; two engine fixes The refusal property now starts from real books and adds compound commands with valid arguments (book an entry, register an asset or schedule, raise an invoice or bill, close a month to members), so every command's success path is exercised. Compound commands checkpoint after each step, so the law is checked against the one command refused. STORE_CASES sets the case count (256 by default). It found two bugs: - run_recurring created drafts dated inside a closed period (B-12). generate_recurring now refuses them, atomically; the store leaves them ungenerated and books.run_recurring reports them in skipped_closed_period for a human to decide. Replay uses replay_generate_recurring so older journals still open. The store also journals before mutating the ledger here, as everywhere else. - Disposing an asset for no proceeds (scrapping it) journaled a catch-up depreciation entry and a half-built draft with a 0.00 line, then refused. Zero lines are now left out (Store::disposal_lines, shared with the MCP preview), and the whole disposal is rehearsed on a clone before anything is written. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-core/src/ledger.rs | 47 ++- crates/ledger-core/tests/schedules.rs | 32 ++ crates/ledger-mcp/src/server.rs | 48 +-- crates/ledger-mcp/src/types.rs | 13 + crates/ledger-store/src/store.rs | 144 +++++---- crates/ledger-store/tests/store.rs | 440 +++++++++++++++++++++++++- 6 files changed, 633 insertions(+), 91 deletions(-) diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index a81fc4e..246aab7 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -390,10 +390,39 @@ impl Ledger { /// Generate one occurrence of a recurring entry as a *draft* (never /// posted: the agent attaches the receipt and posts it). `date` must be /// an occurrence not yet generated. Returns the draft's id. + /// + /// ```text + /// GOAL: turn one due occurrence of a recurring entry into a draft. + /// SOURCE: docs/spec/rules.md B-12; Lean invariant Inv.draftsOpen. + /// SCENARIOS: + /// 1. IF the date falls in a closed period: + /// - SHOULD refuse (ClosedPeriod) + /// - NEVER create a draft there + /// BECAUSE a closed period takes no new entries, however they + /// are made (found by the store's refusal property, 2026-10-13). + /// 2. IF the date is not an ungenerated occurrence: SHOULD refuse. + /// 3. IF a line is refused: SHOULD leave the ledger as it was (no + /// half-built draft, no id spent). + /// 4. Otherwise: SHOULD create a draft with the template's lines and + /// mark the occurrence generated. + /// ``` pub fn generate_recurring( &mut self, id: RecurringId, date: NaiveDate, + ) -> Result { + if let Some(pid) = self.closed_period_containing(date) { + return Err(LedgerError::ClosedPeriod(pid, date.to_string())); + } + self.replay_generate_recurring(id, date) + } + + /// [`Self::generate_recurring`] without the closed-period check, for + /// replaying journals written before that check existed. + pub fn replay_generate_recurring( + &mut self, + id: RecurringId, + date: NaiveDate, ) -> Result { let r = self .recurring @@ -407,7 +436,11 @@ impl Ledger { let (description, lines) = (r.description.clone(), r.lines.clone()); let entry = self.create_entry(date, &description); for (account, side, money) in lines { - self.add_line(entry, account, side, money)?; + if let Err(e) = self.add_line(entry, account, side, money) { + self.entries.remove(&entry); + self.next_entry_id = entry; + return Err(e); + } } self.recurring .get_mut(&id) @@ -417,6 +450,18 @@ impl Ledger { Ok(entry) } + /// Occurrences due through `through` that can't be generated because + /// their date falls in a closed period, as (recurring entry, date). + /// They need a human decision (book them in an open period by hand, + /// or not at all). + pub fn recurring_in_closed_periods(&self, through: NaiveDate) -> Vec<(RecurringId, NaiveDate)> { + self.recurring + .values() + .flat_map(|r| r.due(through).into_iter().map(move |d| (r.id, d))) + .filter(|(_, d)| self.closed_period_containing(*d).is_some()) + .collect() + } + /// The id the next account added will receive. /// /// Exposed for journaling stores that record the id before mutating. diff --git a/crates/ledger-core/tests/schedules.rs b/crates/ledger-core/tests/schedules.rs index ff8fc1f..f6a923a 100644 --- a/crates/ledger-core/tests/schedules.rs +++ b/crates/ledger-core/tests/schedules.rs @@ -225,6 +225,38 @@ fn recurring_entries_generate_drafts_never_posts() { let _ = b.revenue; } +/// Regression (B-12, found by the store's refusal property on +/// 2026-10-13): a recurring occurrence dated in a closed period is +/// refused, leaves nothing behind, and is reported as needing a human. +#[test] +fn a_recurring_occurrence_in_a_closed_period_is_refused() { + let mut b = books(); + let id = + b.l.register_recurring(NewRecurring { + description: "Office rent".into(), + lines: vec![ + (b.rent, Side::Debit, usd("2000")), + (b.cash, Side::Credit, usd("2000")), + ], + frequency: Frequency::Monthly, + start: d(1, 31), + end: None, + }) + .unwrap(); + let jan = b.l.create_period("2026-01", d(1, 1), d(1, 31)).unwrap(); + b.l.close_period(jan).unwrap(); + let next = b.l.next_entry_id(); + assert!(matches!( + b.l.generate_recurring(id, d(1, 31)), + Err(LedgerError::ClosedPeriod(p, _)) if p == jan + )); + assert_eq!(b.l.next_entry_id(), next, "no id spent"); + assert!(b.l.get_entry(next).is_none(), "no draft left behind"); + assert_eq!(b.l.recurring_in_closed_periods(d(2, 28)), [(id, d(1, 31))]); + // Later occurrences still generate. + assert_eq!(b.l.generate_recurring(id, d(2, 28)).unwrap(), next); +} + #[test] fn schedule_length_is_one_to_six_hundred_months() { let b = books(); diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index cfeaa9f..2b20cd6 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -1887,7 +1887,7 @@ impl BooksServer { #[tool( name = "books.run_recurring", - description = "Turn every recurring entry's occurrences on or before `through` (default today) into drafts. Idempotent: an occurrence generates once. Each draft then goes through the normal flow: attach the receipt, preview, post." + description = "Turn every recurring entry's occurrences on or before `through` (default today) into drafts. Idempotent: an occurrence generates once. Occurrences dated in a closed period are not generated; they come back in `skipped_closed_period` for the human to decide. Each draft then goes through the normal flow: attach the receipt, preview, post." )] async fn run_recurring( &self, @@ -1908,7 +1908,19 @@ impl BooksServer { entry_id: entry, }) .collect(); - Ok(Json(RunRecurringOut { drafts })) + let skipped_closed_period = store + .ledger() + .recurring_in_closed_periods(through) + .into_iter() + .map(|(id, date)| RecurringSkippedOut { + recurring_id: id, + date: date.to_string(), + }) + .collect(); + Ok(Json(RunRecurringOut { + drafts, + skipped_closed_period, + })) } #[tool( @@ -2725,28 +2737,16 @@ impl BooksServer { // The would-be disposal entry's lines, for the preview and the // receipt-policy check. - let mut raw: Vec<(AccountId, Side, Money)> = vec![ - ( - asset.accum_depr_account, - Side::Debit, - Money::new(preview.accumulated, base), - ), - (proceeds_account, Side::Debit, proceeds), - (asset.cost_account, Side::Credit, asset.cost), - ]; - if preview.gain_loss > Decimal::ZERO { - raw.push(( - gain_loss_account, - Side::Credit, - Money::new(preview.gain_loss, base), - )); - } else if preview.gain_loss < Decimal::ZERO { - raw.push(( - gain_loss_account, - Side::Debit, - Money::new(-preview.gain_loss, base), - )); - } + let raw: Vec<(AccountId, Side, Money)> = Store::disposal_lines( + &asset, + &preview, + proceeds.amount, + proceeds_account, + gain_loss_account, + ) + .into_iter() + .map(|(a, side, amount)| (a, side, Money::new(amount, base))) + .collect(); // Disposals are money movement: the bill of sale is the receipt. let chart = store.ledger().chart(); let code_of = |id: AccountId| { diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 137cfb7..b02d525 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -216,11 +216,24 @@ pub struct RecurringDraftOut { pub entry_id: u64, } +/// A recurring occurrence left ungenerated (its date is in a closed period). +#[derive(Debug, Serialize, JsonSchema)] +pub struct RecurringSkippedOut { + /// Recurring entry id. + pub recurring_id: u64, + /// Occurrence date. + pub date: String, +} + /// Result of `books.run_recurring`. #[derive(Debug, Serialize, JsonSchema)] pub struct RunRecurringOut { /// Drafts created. pub drafts: Vec, + /// Occurrences not generated because their date falls in a closed + /// period. Ask the human: book them by hand in an open period, or + /// leave them out. + pub skipped_closed_period: Vec, } /// A revenue or fee line of a payout. diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 5cf7e52..0fd4b92 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -738,6 +738,30 @@ impl Store { }) } + /// The disposal entry's lines for an asset, its preview, and the + /// accounts chosen: the one place the shape of a disposal is decided, + /// for both the preview and the post. Zero lines are left out (an + /// engine entry has none): scrapping brings no proceeds, and an asset + /// disposed before its first depreciation has none accumulated. + pub fn disposal_lines( + asset: &ledger_core::Asset, + preview: &DisposalPreview, + proceeds: Decimal, + proceeds_account: AccountId, + gain_loss_account: AccountId, + ) -> Vec<(AccountId, Side, Decimal)> { + [ + (asset.accum_depr_account, Side::Debit, preview.accumulated), + (proceeds_account, Side::Debit, proceeds), + (asset.cost_account, Side::Credit, asset.cost.amount), + (gain_loss_account, Side::Credit, preview.gain_loss), + (gain_loss_account, Side::Debit, -preview.gain_loss), + ] + .into_iter() + .filter(|(_, _, a)| *a > Decimal::ZERO) + .collect() + } + /// Compute a disposal without posting. Pure reads plus the posted-vs- /// scheduled reconciliation that [`Store::dispose_asset`] performs. pub fn dispose_preview( @@ -873,29 +897,53 @@ impl Store { // One source of truth for the math: the preview computes the // posted-vs-scheduled reconciliation; posting follows it. let preview = self.dispose_preview(asset_id, date, proceeds)?; - let accumulated = preview.accumulated; - let gain_loss = preview.gain_loss; - if preview.final_depreciation > Decimal::ZERO { - let diff = preview.final_depreciation; - let eid = self.create_entry( - date, - &format!( + let depreciation = (preview.final_depreciation > Decimal::ZERO).then(|| { + ( + format!( "Depreciation — {} — {} (final)", asset.description, preview.period ), - )?; - self.add_line( - eid, - asset.expense_account, - Side::Debit, - Money::new(diff, base), - )?; - self.add_line( - eid, - asset.accum_depr_account, - Side::Credit, - Money::new(diff, base), - )?; + vec![ + ( + asset.expense_account, + Side::Debit, + preview.final_depreciation, + ), + ( + asset.accum_depr_account, + Side::Credit, + preview.final_depreciation, + ), + ], + ) + }); + let disposal = ( + format!("Disposal — {} — {}", asset.description, date), + Self::disposal_lines( + &asset, + &preview, + proceeds.amount, + proceeds_account, + gain_loss_account, + ), + ); + // Several commands make one disposal: rehearse them all together, + // so a refusal anywhere leaves nothing journaled. + self.rehearse(|l| { + for (description, lines) in depreciation.iter().chain([&disposal]) { + let e = l.try_create_entry(date, description)?; + for (account, side, amount) in lines { + l.add_line(e, *account, *side, Money::new(*amount, base))?; + } + l.post_entry(e)?; + } + l.dispose_asset(asset_id, date, proceeds) + })?; + if let Some((description, lines)) = &depreciation { + let eid = self.create_entry(date, description)?; + for (account, side, amount) in lines { + self.add_line(eid, *account, *side, Money::new(*amount, base))?; + } self.post_entry(eid)?; let dline = self.append_journal( "asset.depreciation_posted", @@ -903,48 +951,14 @@ impl Store { "asset_id": asset_id, "period": preview.period, "entry_id": eid, - "amount": diff.to_string(), + "amount": preview.final_depreciation.to_string(), }), )?; apply_line_to_sqlite(&self.conn, &dline)?; } - - let entry_id = self.create_entry( - date, - &format!("Disposal — {} — {}", asset.description, date), - )?; - self.add_line( - entry_id, - asset.accum_depr_account, - Side::Debit, - Money::new(accumulated, base), - )?; - self.add_line( - entry_id, - proceeds_account, - Side::Debit, - Money::new(proceeds.amount, base), - )?; - self.add_line( - entry_id, - asset.cost_account, - Side::Credit, - Money::new(asset.cost.amount, base), - )?; - if gain_loss > Decimal::ZERO { - self.add_line( - entry_id, - gain_loss_account, - Side::Credit, - Money::new(gain_loss, base), - )?; - } else if gain_loss < Decimal::ZERO { - self.add_line( - entry_id, - gain_loss_account, - Side::Debit, - Money::new(-gain_loss, base), - )?; + let entry_id = self.create_entry(date, &disposal.0)?; + for (account, side, amount) in &disposal.1 { + self.add_line(entry_id, *account, *side, Money::new(*amount, base))?; } self.post_entry(entry_id)?; let line = self.append_journal( @@ -1976,7 +1990,9 @@ impl Store { /// Generate every recurring entry's due occurrences through `through` /// as drafts. Each journaled as `recurring.generated`. Returns - /// (recurring entry, date, draft) for each. + /// (recurring entry, date, draft) for each. Occurrences dated in a + /// closed period are left ungenerated (a closed period takes no new + /// entries); [`Ledger::recurring_in_closed_periods`] lists them. pub fn run_recurring( &mut self, through: NaiveDate, @@ -1989,11 +2005,17 @@ impl Store { due.sort(); let mut out = Vec::with_capacity(due.len()); for (date, id) in due { - let entry = self.ledger.generate_recurring(id, date)?; + if self.ledger.closed_period_containing(date).is_some() { + continue; + } + self.rehearse(|l| l.generate_recurring(id, date))?; + let entry = self.ledger.next_entry_id(); let line = self.append_journal( "recurring.generated", json!({"recurring_id": id, "date": date.to_string(), "entry_id": entry}), )?; + let got = self.ledger.generate_recurring(id, date)?; + debug_assert_eq!(got, entry); apply_line_to_sqlite(&self.conn, &line)?; out.push((id, date, entry)); } @@ -3096,7 +3118,7 @@ pub(crate) fn replay_line_into_ledger( } "recurring.generated" => { let want = get_u64(p, "entry_id")?; - let got = ledger.generate_recurring( + let got = ledger.replay_generate_recurring( get_u64(p, "recurring_id")?, parse_date(get_str(p, "date")?)?, )?; diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 9d9d6c9..04acb2d 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -613,6 +613,55 @@ fn schedules_and_recurring_entries_replay_exactly() { assert!(store.run_recurring(date(2, 28)).unwrap().is_empty()); } +/// Regression (B-12, found by the refusal property on 2026-10-13): +/// `run_recurring` leaves occurrences in a closed period ungenerated +/// instead of creating drafts there, and still generates the rest. +#[test] +fn run_recurring_skips_occurrences_in_a_closed_period() { + let dir = test_dir("recurring-closed"); + let date = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap(); + let usd_s = |s: &str| Money::new(s.parse().unwrap(), Currency::USD); + let mut store = Store::create(&dir, Currency::USD).unwrap(); + let cash = store + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let rent = store + .add_account("6000", "Rent", AccountType::Expense, None, false) + .unwrap(); + store + .register_recurring(ledger_core::NewRecurring { + description: "Rent".into(), + lines: vec![ + (rent, Side::Debit, usd_s("500")), + (cash, Side::Credit, usd_s("500")), + ], + frequency: ledger_core::Frequency::Monthly, + start: date(1, 1), + end: None, + }) + .unwrap(); + let jan = store + .create_period("2026-01", date(1, 1), date(1, 31)) + .unwrap(); + store.close_period(jan).unwrap(); + let drafts = store.run_recurring(date(2, 28)).unwrap(); + assert_eq!(drafts.iter().map(|d| d.1).collect::>(), [date(2, 1)]); + assert_eq!( + store.ledger().recurring_in_closed_periods(date(2, 28)), + [(1, date(1, 1))] + ); + drop(store); + let store = Store::open(&dir).unwrap(); + store.verify().unwrap(); + assert_eq!( + store + .ledger() + .recurring_in_closed_periods(date(2, 28)) + .len(), + 1 + ); +} + #[test] fn vendor_1099_tags_replay_and_project() { use ledger_core::arap::DocumentLine; @@ -826,6 +875,35 @@ mod refusals { SetEntryVendor(u64, Option), SetVendor1099(u64, bool), SetAccountTax(u64, u8), + // The rest of the command surface (2026-10-13). + AttachReceipt(Option, u8), + FileDocument(u8), + LinkReceipt(u64, u64), + UnlinkReceipt(u64), + Withdraw(u64), + SetFxRate(u32, i64), + AddLineFx(u64, u64, bool, i64, i64), + RegisterAsset(u64, u64, u64, i64, u32), + RunDepreciation(u64, u32), + DisposeAsset(u64, u32, i64, u64, u64), + RunPayroll(u32, i64, i64, u64), + Remit(u32, u64), + RegisterSchedule(bool, i64, u32, u32, u64, u64), + PostScheduleMonth(u64, u32), + RegisterRecurring(u64, u64, i64, u32), + RunRecurring(u32), + MarkCleared(u64, usize, bool), + CloseMembers(u64, u64, u64), + /// A balanced pair of lines on a draft, so entries post. + Pair(u64, u64, u64, i64), + // Compound commands with valid arguments, so the success paths + // that need a chain of earlier steps get exercised too. + Book(u32, u64, u64, i64), + GoodAsset(i64, u32), + GoodSchedule(bool, i64, u32), + GoodInvoice(u32, i64), + GoodBill(u32, i64), + GoodClose(u32), } fn day(n: u32) -> NaiveDate { @@ -870,6 +948,63 @@ mod refusals { (id.clone(), prop::option::of(0u64..4)).prop_map(|(e, v)| Op::SetEntryVendor(e, v)), (0u64..4, any::()).prop_map(|(v, t)| Op::SetVendor1099(v, t)), (0u64..10, 0u8..4).prop_map(|(a, k)| Op::SetAccountTax(a, k)), + (prop::option::of(id.clone()), 0u8..4).prop_map(|(e, b)| Op::AttachReceipt(e, b)), + (0u8..4).prop_map(Op::FileDocument), + (0u64..6, id.clone()).prop_map(|(a, e)| Op::LinkReceipt(a, e)), + (0u64..6).prop_map(Op::UnlinkReceipt), + (0u64..6).prop_map(Op::Withdraw), + (0u32..120, -2i64..30_000).prop_map(|(d, r)| Op::SetFxRate(d, r)), + ( + id.clone(), + 0u64..10, + any::(), + arb_cents(), + 1i64..30_000 + ) + .prop_map(|(e, a, s, f, r)| Op::AddLineFx(e, a, s, f, r)), + (0u64..10, 0u64..10, 0u64..10, arb_cents(), 0u32..40) + .prop_map(|(c, d, x, cost, m)| Op::RegisterAsset(c, d, x, cost, m)), + (0u64..4, 0u32..12).prop_map(|(a, m)| Op::RunDepreciation(a, m)), + (0u64..4, 0u32..120, arb_cents(), 0u64..10, 0u64..10) + .prop_map(|(a, d, p, c, g)| Op::DisposeAsset(a, d, p, c, g)), + (0u32..120, arb_cents(), arb_cents(), 0u64..10) + .prop_map(|(d, g, w, c)| Op::RunPayroll(d, g, w, c)), + (0u32..120, 0u64..10).prop_map(|(d, c)| Op::Remit(d, c)), + ( + any::(), + arb_cents(), + 0u32..120, + 0u32..30, + 0u64..10, + 0u64..10 + ) + .prop_map(|(k, t, st, m, b, p)| Op::RegisterSchedule(k, t, st, m, b, p)), + (0u64..4, 0u32..12).prop_map(|(s, m)| Op::PostScheduleMonth(s, m)), + (0u64..10, 0u64..10, arb_cents(), 0u32..120) + .prop_map(|(a, b, c, d)| Op::RegisterRecurring(a, b, c, d)), + (0u32..180).prop_map(Op::RunRecurring), + (id.clone(), 0usize..4, any::()).prop_map(|(e, i, c)| Op::MarkCleared(e, i, c)), + (0u64..5, 0u64..10, 0u64..10).prop_map(|(p, a, b)| Op::CloseMembers(p, a, b)), + (id.clone(), 1u64..10, 1u64..10, 1i64..500_000) + .prop_map(|(e, a, b, c)| Op::Pair(e, a, b, c)), + (id.clone(), 1u64..10, 1u64..10, 1i64..500_000) + .prop_map(|(e, a, b, c)| Op::Pair(e, a, b, c)), + (id.clone(), 1u64..10, 1u64..10, 1i64..500_000) + .prop_map(|(e, a, b, c)| Op::Pair(e, a, b, c)), + id.clone().prop_map(Op::Post), + id.clone().prop_map(Op::Post), + (0u32..120, 1u64..10, 1u64..10, 1i64..500_000) + .prop_map(|(d, a, b, c)| Op::Book(d, a, b, c)), + (0u32..120, 1u64..10, 1u64..10, 1i64..500_000) + .prop_map(|(d, a, b, c)| Op::Book(d, a, b, c)), + (0u32..120, 1u64..10, 1u64..10, 1i64..500_000) + .prop_map(|(d, a, b, c)| Op::Book(d, a, b, c)), + (1i64..5_000_000, 1u32..60).prop_map(|(c, m)| Op::GoodAsset(c, m)), + (any::(), 1i64..1_000_000, 1u32..24) + .prop_map(|(k, t, m)| Op::GoodSchedule(k, t, m)), + (0u32..120, 1i64..1_000_000).prop_map(|(d, c)| Op::GoodInvoice(d, c)), + (0u32..120, 1i64..1_000_000).prop_map(|(d, c)| Op::GoodBill(d, c)), + (1u32..5).prop_map(Op::GoodClose), ] } @@ -890,7 +1025,10 @@ mod refusals { Some(""), ]; - fn apply(store: &mut Store, op: &Op) -> Result<(), StoreError> { + /// Runs one op. A compound op is several commands; `mark` is called + /// after each step that succeeded, so the refusal law is checked + /// against the one command that was refused. + fn apply(store: &mut Store, op: &Op, mark: &mut dyn FnMut()) -> Result<(), StoreError> { let usd = |c: i64| Money::new(Decimal::new(c, 2), Currency::USD); let line = |c: i64| { vec![DocumentLine { @@ -928,6 +1066,196 @@ mod refusals { Op::SetEntryVendor(e, v) => store.set_entry_vendor(e, v), Op::SetVendor1099(v, t) => store.set_vendor_1099(v, Some(Form1099::Nec), t), Op::SetAccountTax(a, k) => store.set_account_tax(a, TAX[k as usize], None), + Op::AttachReceipt(e, b) => store + .attach_receipt(e, "r.pdf", "application/pdf", &[b; 3], Some("receipt")) + .map(drop), + Op::FileDocument(b) => store + .file_document("legal", "d.pdf", "application/pdf", &[b, 9], None) + .map(drop), + Op::LinkReceipt(a, e) => store.link_receipt(a, e), + Op::UnlinkReceipt(a) => store.unlink_receipt(a, "wrong receipt").map(drop), + Op::Withdraw(a) => store.withdraw_attachment(a, "duplicate"), + Op::SetFxRate(d, r) => store.set_fx_rate(Currency::EUR, day(d), Decimal::new(r, 4)), + Op::AddLineFx(e, a, d, f, r) => { + let side = if d { Side::Debit } else { Side::Credit }; + store.add_line_fx( + e, + a, + side, + Currency::EUR, + Decimal::new(f, 2), + Decimal::new(r, 4), + ) + } + Op::RegisterAsset(c, d, x, cost, m) => store + .register_asset(ledger_core::NewAsset { + description: "van".into(), + purchase_date: day(0), + cost: usd(cost), + salvage_value: usd(0), + useful_life_months: m, + cost_account: c, + accum_depr_account: d, + expense_account: x, + }) + .map(drop), + Op::RunDepreciation(a, m) => store + .run_depreciation(a, &format!("2026-{:02}", m + 1)) + .map(drop), + Op::DisposeAsset(a, d, p, c, g) => { + store.dispose_asset(a, day(d), usd(p), c, g).map(drop) + } + Op::RunPayroll(d, g, w, c) => store + .run_payroll( + day(d), + vec![ledger_core::PayrollLine { + employee_name: "Pat".into(), + gross_pay: Decimal::new(g, 2), + federal_withholding: Decimal::new(w, 2), + state_withholding: Decimal::ZERO, + ss_employee: Decimal::ZERO, + medicare_employee: Decimal::ZERO, + ss_employer: Decimal::new(g, 2) * Decimal::new(62, 3), + medicare_employer: Decimal::ZERO, + other_deductions: Decimal::ZERO, + }], + c, + ) + .map(drop), + Op::Remit(d, c) => store.remit_payroll_taxes(day(d), c).map(drop), + Op::RegisterSchedule(k, t, st, m, b, pl) => store + .register_schedule(ledger_core::NewSchedule { + description: "sched".into(), + kind: if k { + ledger_core::ScheduleKind::PrepaidExpense + } else { + ledger_core::ScheduleKind::DeferredRevenue + }, + total: usd(t), + start: day(st), + months: m, + balance_account: b, + pl_account: pl, + }) + .map(drop), + Op::PostScheduleMonth(id, m) => { + let first = NaiveDate::from_ymd_opt(2026, m + 1, 1).unwrap(); + let end = first + chrono::Months::new(1) - chrono::Days::new(1); + store.post_schedule_month(id, end).map(drop) + } + Op::RegisterRecurring(a, b, c, d) => store + .register_recurring(ledger_core::NewRecurring { + description: "rent".into(), + lines: vec![(a, Side::Debit, usd(c)), (b, Side::Credit, usd(c))], + frequency: ledger_core::Frequency::Monthly, + start: day(d), + end: None, + }) + .map(drop), + Op::RunRecurring(d) => store + .run_recurring( + NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Days::new(u64::from(d)), + ) + .map(drop), + Op::MarkCleared(e, i, c) => store.mark_cleared(e, i, c), + Op::Book(d, a, b, c) => { + let e = store.create_entry(day(d), "book")?; + mark(); + store.add_line(e, a, Side::Debit, usd(c))?; + mark(); + store.add_line(e, b, Side::Credit, usd(c))?; + mark(); + store.post_entry(e) + } + Op::GoodAsset(c, m) => store + .register_asset(ledger_core::NewAsset { + description: "machine".into(), + purchase_date: day(0), + cost: usd(c), + salvage_value: usd(0), + useful_life_months: m, + cost_account: 2, + accum_depr_account: 3, + expense_account: 9, + }) + .map(drop), + Op::GoodSchedule(k, t, m) => store + .register_schedule(ledger_core::NewSchedule { + description: "insurance".into(), + kind: if k { + ledger_core::ScheduleKind::PrepaidExpense + } else { + ledger_core::ScheduleKind::DeferredRevenue + }, + total: usd(t), + start: day(0), + months: m, + balance_account: if k { 1 } else { 6 }, + pl_account: if k { 8 } else { 7 }, + }) + .map(drop), + Op::GoodInvoice(d, c) => { + let who = store.add_customer("Client")?; + mark(); + store + .create_invoice(who, day(d), day(d) + chrono::Days::new(30), line(c)) + .map(drop) + } + Op::GoodBill(d, c) => { + let who = store.add_vendor("Supplier")?; + mark(); + store + .create_bill(who, day(d), day(d) + chrono::Days::new(30), line(c)) + .map(drop) + } + Op::GoodClose(m) => { + let start = NaiveDate::from_ymd_opt(2026, m, 1).unwrap(); + let end = NaiveDate::from_ymd_opt(2026, m + 1, 1) + .unwrap() + .pred_opt() + .unwrap(); + let p = store.create_period(&format!("2026-{m:02}"), start, end)?; + mark(); + store + .close_period_to( + p, + &ledger_core::CloseTo::Members(vec![ + ledger_core::MemberClose { + capital: 4, + share: Decimal::new(5, 1), + distributions: None, + }, + ledger_core::MemberClose { + capital: 5, + share: Decimal::new(5, 1), + distributions: None, + }, + ]), + ) + .map(drop) + } + Op::Pair(e, a, b, c) => { + store.add_line(e, a, Side::Debit, usd(c))?; + mark(); + store.add_line(e, b, Side::Credit, usd(c)) + } + Op::CloseMembers(p, a, b) => store + .close_period_to( + p, + &ledger_core::CloseTo::Members(vec![ + ledger_core::MemberClose { + capital: a, + share: Decimal::new(5, 1), + distributions: None, + }, + ledger_core::MemberClose { + capital: b, + share: Decimal::new(5, 1), + distributions: None, + }, + ]), + ) + .map(drop), } } @@ -961,8 +1289,9 @@ mod refusals { } proptest! { + // 256 cases on every check; `STORE_CASES=5000` for a deeper run. #![proptest_config(ProptestConfig { - cases: 1000, + cases: std::env::var("STORE_CASES").ok().and_then(|v| v.parse().ok()).unwrap_or(256), failure_persistence: None, ..ProptestConfig::default() })] @@ -976,11 +1305,50 @@ mod refusals { ) { let dir = test_dir(&format!("refusals-{seed}")); let mut store = Store::create(&dir, Currency::USD).unwrap(); + // Start from realistic books, so most commands act on real + // things and refusals come from bad arguments (the first + // version started empty and almost nothing ever posted). + // Ids 1..=9: cash, equipment, accumulated depreciation, two + // members' capital, a liability, sales, rent, depreciation. + for (code, name, t) in [ + ("1000", "Cash", AccountType::Asset), + ("1500", "Equipment", AccountType::Asset), + ("1510", "Accumulated depreciation", AccountType::Asset), + ("3000", "Capital A", AccountType::Equity), + ("3001", "Capital B", AccountType::Equity), + ("2500", "Loan", AccountType::Liability), + ("4100", "Sales", AccountType::Revenue), + ("6000", "Rent", AccountType::Expense), + ("6200", "Depreciation", AccountType::Expense), + ] { + store.add_account(code, name, t, None, false).unwrap(); + } + for (n, (dr, cr, cents)) in [(1u64, 4u64, 1_000_000i64), (8, 1, 120_000), (1, 7, 250_000)] + .into_iter() + .enumerate() + { + let e = store.create_entry(day(n as u32 * 10), "setup").unwrap(); + let m = Money::new(Decimal::new(cents, 2), Currency::USD); + store.add_line(e, dr, Side::Debit, m).unwrap(); + store.add_line(e, cr, Side::Credit, m).unwrap(); + store.post_entry(e).unwrap(); + } let journal = dir.join(ledger_store::JOURNAL_FILE); for op in &ops { check_invariants(&store)?; - let before = std::fs::read_to_string(&journal).unwrap(); - if let Err(e) = apply(&mut store, op) { + let mut before = std::fs::read_to_string(&journal).unwrap(); + let result = apply(&mut store, op, &mut || { + before = std::fs::read_to_string(&journal).unwrap(); + }); + if std::env::var("STORE_STATS").is_ok() { + let kind = format!("{op:?}"); + let kind = kind.split('(').next().unwrap_or(""); + match &result { + Ok(()) => eprintln!("OK {kind}"), + Err(e) => eprintln!("REFUSED {kind} {e}"), + } + } + if let Err(e) = result { let after = std::fs::read_to_string(&journal).unwrap(); prop_assert!( after == before, @@ -992,10 +1360,17 @@ mod refusals { } } check_invariants(&store)?; + // Reopening gives back exactly these books (rank 3: the live + // ledger against journal replay). + let before = ledger_core::reports::snapshot(store.ledger()).unwrap(); + let attachments = store.list_attachments(None).unwrap().len(); drop(store); let reopened = Store::open(&dir); prop_assert!(reopened.is_ok(), "books failed to reopen: {:?}", reopened.err()); - reopened.unwrap().verify().unwrap(); + let reopened = reopened.unwrap(); + reopened.verify().unwrap(); + prop_assert_eq!(ledger_core::reports::snapshot(reopened.ledger()).unwrap(), before); + prop_assert_eq!(reopened.list_attachments(None).unwrap().len(), attachments); let _ = std::fs::remove_dir_all(&dir); } } @@ -1148,3 +1523,58 @@ fn a_blob_id_never_reaches_outside_the_blob_store() { assert_eq!(blobs.get(&sha).unwrap(), b"receipt"); assert_eq!(blobs.get(&sha.to_uppercase()).unwrap(), b"receipt"); } + +/// Regression (found by the refusal property on 2026-10-13): scrapping an +/// asset (no proceeds) is a valid disposal. It posts without a zero +/// proceeds line, books the whole book value as a loss, and survives +/// reopen. Before the fix it journaled a catch-up depreciation entry and a +/// half-built disposal draft, then refused. +#[test] +fn scrapping_an_asset_for_nothing_disposes_it() { + let dir = test_dir("scrap"); + let mut store = Store::create(&dir, Currency::USD).unwrap(); + let mut add = |code: &str, t| store_add(&mut store, code, t); + let cash = add("1000", AccountType::Asset); + let equipment = add("1500", AccountType::Asset); + let accum = add("1510", AccountType::Asset); + let loss = add("6900", AccountType::Expense); + let depr = add("6200", AccountType::Expense); + let day = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap(); + let buy = store.create_entry(day(1, 1), "laptop").unwrap(); + store + .add_line(buy, equipment, Side::Debit, usd(1200)) + .unwrap(); + store.add_line(buy, cash, Side::Credit, usd(1200)).unwrap(); + store.post_entry(buy).unwrap(); + let asset = store + .register_asset(ledger_core::NewAsset { + description: "laptop".into(), + purchase_date: day(1, 1), + cost: usd(1200), + salvage_value: usd(0), + useful_life_months: 12, + cost_account: equipment, + accum_depr_account: accum, + expense_account: depr, + }) + .unwrap(); + let entry = store + .dispose_asset(asset, day(3, 31), usd(0), cash, loss) + .unwrap(); + let lines = store.ledger().get_entry(entry).unwrap().lines().to_vec(); + assert!(lines.iter().all(|l| !l.amount.amount.is_zero())); + assert!(lines.iter().all(|l| l.account != cash), "no proceeds line"); + // January through March depreciated (300), so a 900 loss on 1,200. + let balance = |s: &Store, a| s.ledger().account_balance(a).unwrap(); + assert_eq!(balance(&store, loss), Decimal::from(900)); + assert_eq!(balance(&store, equipment), Decimal::ZERO); + assert_eq!(balance(&store, accum), Decimal::ZERO); + drop(store); + let store = Store::open(&dir).unwrap(); + store.verify().unwrap(); + assert_eq!(balance(&store, loss), Decimal::from(900)); +} + +fn store_add(store: &mut Store, code: &str, t: AccountType) -> ledger_core::AccountId { + store.add_account(code, code, t, None, false).unwrap() +}