store: refusal property reaches every success path; two engine fixes
check / check (push) Canceled after 0s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-13 17:48:20 -06:00
1 parent 1212367138
commit 22d1a3d449
6 files changed
+633 -91

No files matched your search

+46 -1
View File
@@ -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<EntryId, LedgerError> {
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<EntryId, LedgerError> {
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.
+32
View File
@@ -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();
+24 -24
View File
@@ -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| {
+13
View File
@@ -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<RecurringDraftOut>,
/// 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<RecurringSkippedOut>,
}
/// A revenue or fee line of a payout.
+83 -61
View File
@@ -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")?)?,
)?;
+435 -5
View File
@@ -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::<Vec<_>>(), [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<u64>),
SetVendor1099(u64, bool),
SetAccountTax(u64, u8),
// The rest of the command surface (2026-10-13).
AttachReceipt(Option<u64>, 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::<bool>()).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::<bool>(),
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::<bool>(),
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::<bool>()).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::<bool>(), 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()
}