store: refusals leave the live books untouched; per-filing names; spec suite
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
The refusal property gained the converse law (an accepted command journals something; only batch runs may find nothing to do) and checks the store's class and payroll listings. It found a schedule registered with an out-of-range total whose first month was refused after its entry was begun: the live ledger kept a draft the journal never had. - Core: compound commands (post_schedule_month, reverse_entry, close_period_to, create_invoice, create_bill, receive_payment, pay_bill, run_payroll, remit_payroll_taxes) run inside Ledger::atomically, so a refusal partway leaves the ledger exactly as it was. - Core: a schedule total no line could carry, or finer than a cent, and an asset cost beyond the line range, are refused at registration. Replay keeps older schedules (replay_register_schedule). - Store: add_line, add_line_fx, mark_cleared, set_fx_rate, post_schedule_month and close_period_to changed the live ledger before journaling; they now run on a copy, journal what it produced, then swap it in, so a failed write leaves the live books as journaled. New spec-blind suite tests/spec_store.rs (57 tests written from the store's specs alone). Its two findings, adjudicated: - A file filed twice under different names listed the first name and type for both (the projection kept them per blob). Schema v8 gives each attachment its own; older databases backfill and heal by verify. - Withdrawn attachments stay in the store's listing, marked (the MCP listing hides them): the spec and doc said "omit" and are corrected. Also removes journal::truncate_to_last_good_seq: unused, and it dropped everything after the first bad complete line, which the books now treat as corruption for a person, never repaired automatically. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
8a49f0ff0e
commit
4d8d5daf37
11 files changed
+2075
-75
No files matched your search
@@ -263,6 +263,17 @@ impl Ledger {
|
||||
/// Register an amortization schedule. Returns its id.
|
||||
pub fn register_schedule(&mut self, new: NewSchedule) -> Result<ScheduleId, LedgerError> {
|
||||
schedules::validate_schedule(&new, &self.chart)?;
|
||||
self.replay_register_schedule(new)
|
||||
}
|
||||
|
||||
/// [`Self::register_schedule`] without the total's range and minor-unit
|
||||
/// checks added on 2026-10-13, for replaying journals written before
|
||||
/// them. Never for new work.
|
||||
pub fn replay_register_schedule(
|
||||
&mut self,
|
||||
new: NewSchedule,
|
||||
) -> Result<ScheduleId, LedgerError> {
|
||||
schedules::validate_schedule_shape(&new, &self.chart)?;
|
||||
let id = self.next_schedule_id;
|
||||
self.next_schedule_id += 1;
|
||||
self.schedules.insert(
|
||||
@@ -339,6 +350,15 @@ impl Ledger {
|
||||
&mut self,
|
||||
id: ScheduleId,
|
||||
month_end: NaiveDate,
|
||||
) -> Result<EntryId, LedgerError> {
|
||||
self.atomically(|l| l.post_schedule_month_steps(id, month_end))
|
||||
}
|
||||
|
||||
/// The steps of [`Self::post_schedule_month`], run inside [`Self::atomically`].
|
||||
fn post_schedule_month_steps(
|
||||
&mut self,
|
||||
id: ScheduleId,
|
||||
month_end: NaiveDate,
|
||||
) -> Result<EntryId, LedgerError> {
|
||||
let (date, description, lines) = self.preview_schedule_month(id, month_end)?;
|
||||
if let Some(p) = self.closed_period_containing(date) {
|
||||
@@ -532,6 +552,18 @@ impl Ledger {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Run a command made of several steps as one: if any step is
|
||||
/// refused, the ledger is exactly as it was (no half-built draft, no
|
||||
/// id spent). Found 2026-10-13: a schedule month whose amount was out
|
||||
/// of range left a draft behind.
|
||||
fn atomically<T>(
|
||||
&mut self,
|
||||
steps: impl FnOnce(&mut Self) -> Result<T, LedgerError>,
|
||||
) -> Result<T, LedgerError> {
|
||||
let before = self.clone();
|
||||
steps(self).inspect_err(|_| *self = before)
|
||||
}
|
||||
|
||||
/// The id the next account added will receive.
|
||||
///
|
||||
/// Exposed for journaling stores that record the id before mutating.
|
||||
@@ -1062,6 +1094,16 @@ impl Ledger {
|
||||
entry: EntryId,
|
||||
date: NaiveDate,
|
||||
description: &str,
|
||||
) -> Result<EntryId, LedgerError> {
|
||||
self.atomically(|l| l.reverse_entry_steps(entry, date, description))
|
||||
}
|
||||
|
||||
/// The steps of [`Self::reverse_entry`], run inside [`Self::atomically`].
|
||||
fn reverse_entry_steps(
|
||||
&mut self,
|
||||
entry: EntryId,
|
||||
date: NaiveDate,
|
||||
description: &str,
|
||||
) -> Result<EntryId, LedgerError> {
|
||||
self.check_reversal(entry, date)?;
|
||||
let original = self
|
||||
@@ -1108,6 +1150,11 @@ impl Ledger {
|
||||
if new.cost.amount <= Decimal::ZERO {
|
||||
return Err(LedgerError::InvalidAssetCost(new.cost.amount));
|
||||
}
|
||||
// Every depreciation and disposal line derives from the cost: one
|
||||
// that no line could carry is refused here, not months later.
|
||||
if new.cost.amount > kernel::MAX_LINE_AMOUNT {
|
||||
return Err(LedgerError::AmountOutOfRange);
|
||||
}
|
||||
if new.cost.currency != self.base_currency {
|
||||
return Err(LedgerError::CurrencyMismatch {
|
||||
expected: self.base_currency.code().to_string(),
|
||||
@@ -1379,6 +1426,15 @@ impl Ledger {
|
||||
&mut self,
|
||||
id: PeriodId,
|
||||
to: &periods::CloseTo,
|
||||
) -> Result<Option<EntryId>, LedgerError> {
|
||||
self.atomically(|l| l.close_period_to_steps(id, to))
|
||||
}
|
||||
|
||||
/// The steps of [`Self::close_period_to`], run inside [`Self::atomically`].
|
||||
fn close_period_to_steps(
|
||||
&mut self,
|
||||
id: PeriodId,
|
||||
to: &periods::CloseTo,
|
||||
) -> Result<Option<EntryId>, LedgerError> {
|
||||
self.preview_close_period_to(id, to)?;
|
||||
let period = self.periods.get(&id).expect("previewed").clone();
|
||||
@@ -1892,6 +1948,17 @@ impl Ledger {
|
||||
date: NaiveDate,
|
||||
due_date: NaiveDate,
|
||||
lines: Vec<DocumentLine>,
|
||||
) -> Result<InvoiceId, LedgerError> {
|
||||
self.atomically(|l| l.create_invoice_steps(customer_id, date, due_date, lines))
|
||||
}
|
||||
|
||||
/// The steps of [`Self::create_invoice`], run inside [`Self::atomically`].
|
||||
fn create_invoice_steps(
|
||||
&mut self,
|
||||
customer_id: CustomerId,
|
||||
date: NaiveDate,
|
||||
due_date: NaiveDate,
|
||||
lines: Vec<DocumentLine>,
|
||||
) -> Result<InvoiceId, LedgerError> {
|
||||
if !self.customers.contains_key(&customer_id) {
|
||||
return Err(LedgerError::UnknownCustomer(customer_id));
|
||||
@@ -1952,6 +2019,17 @@ impl Ledger {
|
||||
date: NaiveDate,
|
||||
due_date: NaiveDate,
|
||||
lines: Vec<DocumentLine>,
|
||||
) -> Result<BillId, LedgerError> {
|
||||
self.atomically(|l| l.create_bill_steps(vendor_id, date, due_date, lines))
|
||||
}
|
||||
|
||||
/// The steps of [`Self::create_bill`], run inside [`Self::atomically`].
|
||||
fn create_bill_steps(
|
||||
&mut self,
|
||||
vendor_id: VendorId,
|
||||
date: NaiveDate,
|
||||
due_date: NaiveDate,
|
||||
lines: Vec<DocumentLine>,
|
||||
) -> Result<BillId, LedgerError> {
|
||||
if !self.vendors.contains_key(&vendor_id) {
|
||||
return Err(LedgerError::UnknownVendor(vendor_id));
|
||||
@@ -2021,6 +2099,17 @@ impl Ledger {
|
||||
date: NaiveDate,
|
||||
amount: Decimal,
|
||||
cash_account: AccountId,
|
||||
) -> Result<EntryId, LedgerError> {
|
||||
self.atomically(|l| l.receive_payment_steps(invoice_id, date, amount, cash_account))
|
||||
}
|
||||
|
||||
/// The steps of [`Self::receive_payment`], run inside [`Self::atomically`].
|
||||
fn receive_payment_steps(
|
||||
&mut self,
|
||||
invoice_id: InvoiceId,
|
||||
date: NaiveDate,
|
||||
amount: Decimal,
|
||||
cash_account: AccountId,
|
||||
) -> Result<EntryId, LedgerError> {
|
||||
if amount <= Decimal::ZERO {
|
||||
return Err(LedgerError::InvalidPaymentAmount(amount));
|
||||
@@ -2084,6 +2173,17 @@ impl Ledger {
|
||||
date: NaiveDate,
|
||||
amount: Decimal,
|
||||
cash_account: AccountId,
|
||||
) -> Result<EntryId, LedgerError> {
|
||||
self.atomically(|l| l.pay_bill_steps(bill_id, date, amount, cash_account))
|
||||
}
|
||||
|
||||
/// The steps of [`Self::pay_bill`], run inside [`Self::atomically`].
|
||||
fn pay_bill_steps(
|
||||
&mut self,
|
||||
bill_id: BillId,
|
||||
date: NaiveDate,
|
||||
amount: Decimal,
|
||||
cash_account: AccountId,
|
||||
) -> Result<EntryId, LedgerError> {
|
||||
if amount <= Decimal::ZERO {
|
||||
return Err(LedgerError::InvalidPaymentAmount(amount));
|
||||
@@ -2589,6 +2689,16 @@ impl Ledger {
|
||||
date: NaiveDate,
|
||||
lines: Vec<PayrollLine>,
|
||||
cash_account: AccountId,
|
||||
) -> Result<PayrollRunId, LedgerError> {
|
||||
self.atomically(|l| l.run_payroll_steps(date, lines, cash_account))
|
||||
}
|
||||
|
||||
/// The steps of [`Self::run_payroll`], run inside [`Self::atomically`].
|
||||
fn run_payroll_steps(
|
||||
&mut self,
|
||||
date: NaiveDate,
|
||||
lines: Vec<PayrollLine>,
|
||||
cash_account: AccountId,
|
||||
) -> Result<PayrollRunId, LedgerError> {
|
||||
self.validate_payroll(date, &lines, cash_account)?;
|
||||
let accounts = self.payroll_accounts();
|
||||
@@ -2649,6 +2759,15 @@ impl Ledger {
|
||||
&mut self,
|
||||
date: NaiveDate,
|
||||
cash_account: AccountId,
|
||||
) -> Result<EntryId, LedgerError> {
|
||||
self.atomically(|l| l.remit_payroll_taxes_steps(date, cash_account))
|
||||
}
|
||||
|
||||
/// The steps of [`Self::remit_payroll_taxes`], run inside [`Self::atomically`].
|
||||
fn remit_payroll_taxes_steps(
|
||||
&mut self,
|
||||
date: NaiveDate,
|
||||
cash_account: AccountId,
|
||||
) -> Result<EntryId, LedgerError> {
|
||||
self.chart
|
||||
.get(cash_account)
|
||||
@@ -2808,7 +2927,7 @@ impl Ledger {
|
||||
|
||||
/// Refuse an amount finer than its currency's minor unit (rules.md B-3).
|
||||
/// A currency with no defined minor unit (ISO 4217 "N.A.") is not checked.
|
||||
fn check_minor_units(amount: Money) -> Result<(), LedgerError> {
|
||||
pub(crate) fn check_minor_units(amount: Money) -> Result<(), LedgerError> {
|
||||
match amount.currency.minor_units() {
|
||||
Some(places) if amount.amount.normalize().scale() > places => {
|
||||
Err(LedgerError::FinerThanMinorUnit {
|
||||
|
||||
@@ -112,6 +112,22 @@ pub fn amortization_plan(
|
||||
|
||||
/// Validate a new schedule against the chart.
|
||||
pub fn validate_schedule(new: &NewSchedule, chart: &ChartOfAccounts) -> Result<(), LedgerError> {
|
||||
validate_schedule_shape(new, chart)?;
|
||||
// Every month's posting derives from the total: one no line could
|
||||
// carry, or finer than a cent, is refused here, not at a month end
|
||||
// (found by the store's refusal property, 2026-10-13).
|
||||
if new.total.amount > crate::kernel::MAX_LINE_AMOUNT {
|
||||
return Err(LedgerError::AmountOutOfRange);
|
||||
}
|
||||
crate::ledger::check_minor_units(new.total)
|
||||
}
|
||||
|
||||
/// [`validate_schedule`] without the total's range and minor-unit checks
|
||||
/// added on 2026-10-13, for replaying journals written before them.
|
||||
pub fn validate_schedule_shape(
|
||||
new: &NewSchedule,
|
||||
chart: &ChartOfAccounts,
|
||||
) -> Result<(), LedgerError> {
|
||||
let bad = |m: String| Err(LedgerError::InvalidSchedule(m));
|
||||
if new.description.trim().is_empty() {
|
||||
return bad("description is required".into());
|
||||
|
||||
Reference in new issue
Block a user