core: no entry posts a fraction of a cent; verify compares whole ledgers
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
CI's run of the store refusal property found a payroll run that posted $65.27794: the employer tax was passed in unrounded and nothing refused it, though rules.md B-3 already says a ledger in one money holds no fractions of its smallest unit. Sums of it then differed in scale between journal replay and the SQLite read-back. - Ledger::post_entry refuses any line finer than its currency's ISO 4217 minor unit (FinerThanMinorUnit), whatever made the line: payroll, closes, disposals. add_line refuses it up front. Older journals replay as booked through replay_post_entry. Spec tests for the kernel's ten-place limit now run on the replay path. - The property's body is now run_ops, so failing sequences become named regression tests (regression_payroll_with_a_fraction_of_a_cent). The generator rounds payroll tax to the cent, as callers must. - Ledger derives PartialEq and gains differs_from; Store::verify compares the live ledger with the journal replay in full (drafts, documents, schedules, periods, settings), not just balances, and names the parts that differ. The property checks the ledger its commands built against that replay and against the reopened books. 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
4af2f6c0bb
commit
0dcf865ff8
12 files changed
+365
-100
No files matched your search
@@ -57,7 +57,7 @@ impl AccountType {
|
||||
}
|
||||
|
||||
/// A single account in the chart of accounts.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Account {
|
||||
/// Opaque handle.
|
||||
pub id: AccountId,
|
||||
@@ -84,7 +84,7 @@ pub struct Account {
|
||||
}
|
||||
|
||||
/// The registry of accounts. Owns id allocation for accounts it holds.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ChartOfAccounts {
|
||||
accounts: HashMap<AccountId, Account>,
|
||||
by_code: HashMap<String, AccountId>,
|
||||
|
||||
@@ -27,7 +27,7 @@ pub type InvoiceId = u64;
|
||||
pub type BillId = u64;
|
||||
|
||||
/// A customer: someone we invoice.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Customer {
|
||||
/// Opaque handle.
|
||||
pub id: CustomerId,
|
||||
@@ -36,7 +36,7 @@ pub struct Customer {
|
||||
}
|
||||
|
||||
/// A vendor: someone who bills us.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Vendor {
|
||||
/// Opaque handle.
|
||||
pub id: VendorId,
|
||||
@@ -64,7 +64,7 @@ pub enum DocumentStatus {
|
||||
}
|
||||
|
||||
/// One line on an invoice or bill.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DocumentLine {
|
||||
/// Description of the goods/services.
|
||||
pub description: String,
|
||||
@@ -73,7 +73,7 @@ pub struct DocumentLine {
|
||||
}
|
||||
|
||||
/// A sales invoice: we billed a customer.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Invoice {
|
||||
/// Opaque handle.
|
||||
pub id: InvoiceId,
|
||||
@@ -106,7 +106,7 @@ impl Invoice {
|
||||
}
|
||||
|
||||
/// A vendor bill: we owe a vendor.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Bill {
|
||||
/// Opaque handle.
|
||||
pub id: BillId,
|
||||
|
||||
@@ -35,6 +35,20 @@ pub enum LedgerError {
|
||||
#[error("journal line amount is out of range (at most 1000000000000000, ten decimal places)")]
|
||||
AmountOutOfRange,
|
||||
|
||||
/// An amount finer than its currency's smallest unit (ISO 4217 minor
|
||||
/// units): no one can pay a fraction of a cent (rules.md B-3).
|
||||
#[error(
|
||||
"amount {amount} {currency} has more than {places} decimal places; round it to the {currency} minor unit first"
|
||||
)]
|
||||
FinerThanMinorUnit {
|
||||
/// The amount as given.
|
||||
amount: rust_decimal::Decimal,
|
||||
/// Its currency code.
|
||||
currency: String,
|
||||
/// The decimal places the currency allows.
|
||||
places: u32,
|
||||
},
|
||||
|
||||
/// The referenced account does not exist in the chart of accounts.
|
||||
#[error("unknown account id {0}")]
|
||||
UnknownAccount(u64),
|
||||
|
||||
@@ -24,7 +24,7 @@ pub enum EntryStatus {
|
||||
}
|
||||
|
||||
/// One debit or credit line of a journal entry.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct JournalLine {
|
||||
/// The account this line posts to.
|
||||
pub account: AccountId,
|
||||
@@ -51,7 +51,7 @@ pub struct JournalLine {
|
||||
}
|
||||
|
||||
/// A balanced set of journal lines with a date and a description.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct JournalEntry {
|
||||
id: EntryId,
|
||||
date: NaiveDate,
|
||||
|
||||
@@ -166,7 +166,7 @@ pub struct ReconciliationReport {
|
||||
}
|
||||
|
||||
/// The books. Single-currency (see R-5); owns id allocation.
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Ledger {
|
||||
base_currency: Currency,
|
||||
chart: ChartOfAccounts,
|
||||
@@ -462,6 +462,76 @@ impl Ledger {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The parts of the ledger that differ from `other`, by field name
|
||||
/// (empty when equal). For diagnosing a store whose journal replay and
|
||||
/// projection disagree.
|
||||
pub fn differs_from(&self, other: &Ledger) -> Vec<&'static str> {
|
||||
[
|
||||
("base_currency", self.base_currency == other.base_currency),
|
||||
("chart", self.chart == other.chart),
|
||||
("entries", self.entries == other.entries),
|
||||
("order", self.order == other.order),
|
||||
("assets", self.assets == other.assets),
|
||||
("periods", self.periods == other.periods),
|
||||
("customers", self.customers == other.customers),
|
||||
("vendors", self.vendors == other.vendors),
|
||||
("invoices", self.invoices == other.invoices),
|
||||
("bills", self.bills == other.bills),
|
||||
("cleared", self.cleared == other.cleared),
|
||||
("fx_rates", self.fx_rates == other.fx_rates),
|
||||
("payroll_runs", self.payroll_runs == other.payroll_runs),
|
||||
("classes", self.classes == other.classes),
|
||||
(
|
||||
"next_account_id",
|
||||
self.next_account_id == other.next_account_id,
|
||||
),
|
||||
("next_entry_id", self.next_entry_id == other.next_entry_id),
|
||||
("next_asset_id", self.next_asset_id == other.next_asset_id),
|
||||
(
|
||||
"next_period_id",
|
||||
self.next_period_id == other.next_period_id,
|
||||
),
|
||||
(
|
||||
"next_customer_id",
|
||||
self.next_customer_id == other.next_customer_id,
|
||||
),
|
||||
(
|
||||
"next_vendor_id",
|
||||
self.next_vendor_id == other.next_vendor_id,
|
||||
),
|
||||
(
|
||||
"next_invoice_id",
|
||||
self.next_invoice_id == other.next_invoice_id,
|
||||
),
|
||||
("next_bill_id", self.next_bill_id == other.next_bill_id),
|
||||
(
|
||||
"next_payroll_run_id",
|
||||
self.next_payroll_run_id == other.next_payroll_run_id,
|
||||
),
|
||||
("next_class_id", self.next_class_id == other.next_class_id),
|
||||
("schedules", self.schedules == other.schedules),
|
||||
(
|
||||
"next_schedule_id",
|
||||
self.next_schedule_id == other.next_schedule_id,
|
||||
),
|
||||
("recurring", self.recurring == other.recurring),
|
||||
(
|
||||
"next_recurring_id",
|
||||
self.next_recurring_id == other.next_recurring_id,
|
||||
),
|
||||
("entry_vendors", self.entry_vendors == other.entry_vendors),
|
||||
(
|
||||
"invoice_payments",
|
||||
self.invoice_payments == other.invoice_payments,
|
||||
),
|
||||
("bill_payments", self.bill_payments == other.bill_payments),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(_, same)| !same)
|
||||
.map(|(name, _)| name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The id the next account added will receive.
|
||||
///
|
||||
/// Exposed for journaling stores that record the id before mutating.
|
||||
@@ -722,6 +792,7 @@ impl Ledger {
|
||||
amount: Money,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.check_line_account(account)?;
|
||||
check_minor_units(amount)?;
|
||||
self.replay_add_line(entry, account, side, amount)
|
||||
}
|
||||
|
||||
@@ -886,8 +957,25 @@ impl Ledger {
|
||||
/// 4. IF it doesn't balance, has fewer than two lines, a zero, negative,
|
||||
/// or out-of-range amount, or an unknown or placeholder account:
|
||||
/// SHOULD refuse with that reason.
|
||||
/// 5. IF a line is finer than its currency's minor unit (a fraction
|
||||
/// of a cent): SHOULD refuse (FinerThanMinorUnit)
|
||||
/// BECAUSE a ledger in one money holds no fractions of its
|
||||
/// smallest unit (B-3); found when a payroll run posted $65.27794
|
||||
/// (the store's refusal property in CI, 2026-10-13).
|
||||
/// ```
|
||||
pub fn post_entry(&mut self, entry: EntryId) -> Result<(), LedgerError> {
|
||||
if let Some(e) = self.entries.get(&entry) {
|
||||
for line in e.lines() {
|
||||
check_minor_units(line.amount)?;
|
||||
}
|
||||
}
|
||||
self.replay_post_entry(entry)
|
||||
}
|
||||
|
||||
/// [`Self::post_entry`] without the minor-unit check added on
|
||||
/// 2026-10-13, for replaying journals written before it. Never for new
|
||||
/// work.
|
||||
pub fn replay_post_entry(&mut self, entry: EntryId) -> Result<(), LedgerError> {
|
||||
let snapshot = self
|
||||
.entries
|
||||
.get(&entry)
|
||||
@@ -2667,3 +2755,18 @@ impl Ledger {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
match amount.currency.minor_units() {
|
||||
Some(places) if amount.amount.normalize().scale() > places => {
|
||||
Err(LedgerError::FinerThanMinorUnit {
|
||||
amount: amount.amount,
|
||||
currency: amount.currency.code().to_string(),
|
||||
places,
|
||||
})
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,7 @@ pub fn allocate_last_absorbs(
|
||||
pub const RETAINED_EARNINGS_CODE: &str = "3999";
|
||||
|
||||
/// A named accounting period: a month, quarter, or year.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Period {
|
||||
/// Opaque handle.
|
||||
pub id: PeriodId,
|
||||
|
||||
@@ -210,17 +210,68 @@ fn out_of_range_amount_is_rejected() {
|
||||
ledger.post_entry(id).unwrap_err(),
|
||||
LedgerError::AmountOutOfRange
|
||||
);
|
||||
// Too precise.
|
||||
// Too precise: refused as soon as it is added (finer than a cent)...
|
||||
let id = ledger.create_entry(date(21), "Too precise");
|
||||
let tiny = Money::new("0.00000000001".parse().unwrap(), Currency::USD);
|
||||
ledger.add_line(id, c.cash, Side::Debit, tiny).unwrap();
|
||||
ledger.add_line(id, c.equity, Side::Credit, tiny).unwrap();
|
||||
assert!(matches!(
|
||||
ledger.add_line(id, c.cash, Side::Debit, tiny),
|
||||
Err(LedgerError::FinerThanMinorUnit { places: 2, .. })
|
||||
));
|
||||
// ...and out of range even for books replayed from before that rule.
|
||||
ledger
|
||||
.replay_add_line(id, c.cash, Side::Debit, tiny)
|
||||
.unwrap();
|
||||
ledger
|
||||
.replay_add_line(id, c.equity, Side::Credit, tiny)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ledger.post_entry(id).unwrap_err(),
|
||||
ledger.replay_post_entry(id).unwrap_err(),
|
||||
LedgerError::AmountOutOfRange
|
||||
);
|
||||
}
|
||||
|
||||
/// B-3 (DECISION, Stephen 2026-10-13: a ledger in one money holds no
|
||||
/// fractions of its smallest unit). Regression: a payroll run posted
|
||||
/// $65.27794 (found by the store's refusal property in CI, 2026-10-13).
|
||||
#[test]
|
||||
fn no_entry_posts_a_fraction_of_the_minor_unit() {
|
||||
let c = fixture_company();
|
||||
let mut ledger = c.ledger;
|
||||
let usd = |s: &str| Money::new(s.parse().unwrap(), Currency::USD);
|
||||
// Whole cents, with or without trailing zeros, are fine.
|
||||
for ok in ["65.27", "65.270", "65"] {
|
||||
let id = ledger.create_entry(date(21), "cents");
|
||||
ledger.add_line(id, c.cash, Side::Debit, usd(ok)).unwrap();
|
||||
ledger
|
||||
.add_line(id, c.equity, Side::Credit, usd(ok))
|
||||
.unwrap();
|
||||
ledger.post_entry(id).unwrap();
|
||||
}
|
||||
// A fraction of a cent is refused when added...
|
||||
let id = ledger.create_entry(date(21), "sub-cent");
|
||||
assert!(matches!(
|
||||
ledger.add_line(id, c.cash, Side::Debit, usd("65.27794")),
|
||||
Err(LedgerError::FinerThanMinorUnit { places: 2, .. })
|
||||
));
|
||||
// ...and when posted, whatever path put the line on the draft.
|
||||
ledger
|
||||
.replay_add_line(id, c.cash, Side::Debit, usd("65.275"))
|
||||
.unwrap();
|
||||
ledger
|
||||
.replay_add_line(id, c.equity, Side::Credit, usd("65.275"))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
ledger.post_entry(id),
|
||||
Err(LedgerError::FinerThanMinorUnit { .. })
|
||||
));
|
||||
assert_eq!(
|
||||
ledger.get_entry(id).unwrap().status(),
|
||||
ledger_core::EntryStatus::Draft
|
||||
);
|
||||
// Books from before the rule still replay as booked.
|
||||
ledger.replay_post_entry(id).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fx_overflow_is_an_error() {
|
||||
let c = fixture_company();
|
||||
|
||||
@@ -123,6 +123,23 @@ fn try_post(
|
||||
l.post_entry(id).err()
|
||||
}
|
||||
|
||||
/// [`try_post`] through the replay path, which books written before the
|
||||
/// minor-unit rule (B-3, 2026-10-13) still take: for the kernel's own
|
||||
/// limits below a cent.
|
||||
fn try_replay_post(
|
||||
l: &mut Ledger,
|
||||
date: NaiveDate,
|
||||
lines: &[(AccountId, Side, Money)],
|
||||
) -> Option<LedgerError> {
|
||||
let id = l.try_create_entry(date, "attempt").unwrap();
|
||||
for (a, s, m) in lines {
|
||||
if let Err(e) = l.replay_add_line(id, *a, *s, *m) {
|
||||
return Some(e);
|
||||
}
|
||||
}
|
||||
l.replay_post_entry(id).err()
|
||||
}
|
||||
|
||||
/// Everything a refusal could disturb, as plain comparable data.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Observed {
|
||||
@@ -687,8 +704,9 @@ fn an_unbalanced_entry_is_refused_with_its_totals() {
|
||||
credits: dec("99.99"),
|
||||
})
|
||||
);
|
||||
// One ten-billionth is still a difference.
|
||||
let r = try_post(
|
||||
// One ten-billionth is still a difference (on replayed books; new
|
||||
// work refuses anything finer than a cent first).
|
||||
let r = try_replay_post(
|
||||
&mut b.l,
|
||||
d(2026, 3, 1),
|
||||
&[
|
||||
@@ -771,13 +789,29 @@ fn line_range_is_one_quadrillion_and_ten_places() {
|
||||
)
|
||||
};
|
||||
assert_eq!(ok(&mut b, "1000000000000000"), None);
|
||||
assert_eq!(ok(&mut b, "0.0000000001"), None);
|
||||
assert_eq!(
|
||||
ok(&mut b, "1000000000000000.01"),
|
||||
Some(LedgerError::AmountOutOfRange)
|
||||
);
|
||||
// Below a cent, new work is refused first (B-3); replayed books meet
|
||||
// the kernel's ten-place limit.
|
||||
assert!(matches!(
|
||||
ok(&mut b, "0.0000000001"),
|
||||
Some(LedgerError::FinerThanMinorUnit { .. })
|
||||
));
|
||||
let replayed = |b: &mut Books, amt: &str| {
|
||||
try_replay_post(
|
||||
&mut b.l,
|
||||
d(2026, 3, 1),
|
||||
&[
|
||||
(b.exp, Side::Debit, usd(amt)),
|
||||
(b.cash, Side::Credit, usd(amt)),
|
||||
],
|
||||
)
|
||||
};
|
||||
assert_eq!(replayed(&mut b, "0.0000000001"), None);
|
||||
assert_eq!(
|
||||
ok(&mut b, "0.00000000001"),
|
||||
replayed(&mut b, "0.00000000001"),
|
||||
Some(LedgerError::AmountOutOfRange)
|
||||
);
|
||||
assert_eq!(b.l.posted_entries().count(), 2);
|
||||
|
||||
@@ -2358,6 +2358,18 @@ impl Store {
|
||||
"sqlite projection diverged from journal".into(),
|
||||
));
|
||||
}
|
||||
// The whole ledger, not only its balances: drafts, documents,
|
||||
// schedules, periods, settings. A replay bug in any of them shows
|
||||
// here even when every balance agrees. (The SQLite read-back below
|
||||
// is partial by design; the projection is checked table by table
|
||||
// at the end.)
|
||||
let parts = replayed.differs_from(&self.ledger);
|
||||
if !parts.is_empty() {
|
||||
return Err(StoreError::VerifyMismatch(format!(
|
||||
"live ledger diverged from journal replay in: {}",
|
||||
parts.join(", ")
|
||||
)));
|
||||
}
|
||||
// The accounting snapshot ignores the asset register, so compare it
|
||||
// explicitly: journal replay, live ledger, and projection must agree.
|
||||
let assets_of = |l: &Ledger| l.registered_assets().cloned().collect::<Vec<_>>();
|
||||
@@ -2600,7 +2612,7 @@ impl Store {
|
||||
ledger.set_line_class(got, idx, Some(cid as u32))?;
|
||||
}
|
||||
}
|
||||
ledger.post_entry(got)?;
|
||||
ledger.replay_post_entry(got)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3075,7 +3087,7 @@ pub(crate) fn replay_line_into_ledger(
|
||||
)?;
|
||||
}
|
||||
}
|
||||
ledger.post_entry(id)?;
|
||||
ledger.replay_post_entry(id)?;
|
||||
}
|
||||
"entry.reversed" => {
|
||||
let want = get_u64(p, "id")?;
|
||||
|
||||
@@ -1192,7 +1192,8 @@ mod refusals {
|
||||
state_withholding: Decimal::ZERO,
|
||||
ss_employee: Decimal::ZERO,
|
||||
medicare_employee: Decimal::ZERO,
|
||||
ss_employer: Decimal::new(g, 2) * Decimal::new(62, 3),
|
||||
// 6.2%, rounded to the cent as a caller must.
|
||||
ss_employer: (Decimal::new(g, 2) * Decimal::new(62, 3)).round_dp(2),
|
||||
medicare_employer: Decimal::ZERO,
|
||||
other_deductions: Decimal::ZERO,
|
||||
}],
|
||||
@@ -1380,77 +1381,119 @@ mod refusals {
|
||||
ops in prop::collection::vec(arb_op(), 1..40),
|
||||
seed in any::<u64>(),
|
||||
) {
|
||||
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 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,
|
||||
"refused {:?} ({}) but the journal grew by:\n{}",
|
||||
op,
|
||||
e,
|
||||
after.strip_prefix(&before).unwrap_or(&after)
|
||||
);
|
||||
}
|
||||
}
|
||||
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());
|
||||
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);
|
||||
run_ops(&ops, seed)?;
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one sequence of ops against fresh books and check every law.
|
||||
fn run_ops(ops: &[Op], seed: u64) -> Result<(), TestCaseError> {
|
||||
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 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,
|
||||
"refused {:?} ({}) but the journal grew by:\n{}",
|
||||
op,
|
||||
e,
|
||||
after.strip_prefix(&before).unwrap_or(&after)
|
||||
);
|
||||
}
|
||||
}
|
||||
check_invariants(&store)?;
|
||||
// Reopening gives back exactly these books (rank 3: the live
|
||||
// ledger against journal replay).
|
||||
// The ledger the commands built against its own journal's
|
||||
// replay, every part of it (rank 3).
|
||||
let verified = store.verify();
|
||||
prop_assert!(verified.is_ok(), "live vs. replay: {:?}", verified.err());
|
||||
let before = ledger_core::reports::snapshot(store.ledger()).unwrap();
|
||||
let live = store.ledger().clone();
|
||||
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()
|
||||
);
|
||||
let reopened = reopened.unwrap();
|
||||
reopened.verify().unwrap();
|
||||
prop_assert_eq!(
|
||||
ledger_core::reports::snapshot(reopened.ledger()).unwrap(),
|
||||
before
|
||||
);
|
||||
let parts = live.differs_from(reopened.ledger());
|
||||
prop_assert!(parts.is_empty(), "reopened books differ in: {:?}", parts);
|
||||
prop_assert_eq!(reopened.list_attachments(None).unwrap().len(), attachments);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Regression (CI, 2026-10-13): a payroll run with unrounded employer
|
||||
/// tax posted $65.27794, and sums of it differed in scale between the
|
||||
/// journal replay and the SQLite read-back. Posting now refuses
|
||||
/// fractions of a cent (B-3; `no_entry_posts_a_fraction_of_the_minor_unit`);
|
||||
/// this replays the sequence with the tax rounded, as callers must.
|
||||
#[test]
|
||||
fn regression_payroll_with_a_fraction_of_a_cent() {
|
||||
run_ops(
|
||||
&[
|
||||
Op::Book(0, 1, 1, 1),
|
||||
Op::Book(0, 1, 1, 1),
|
||||
Op::CreateEntry(0),
|
||||
Op::RunPayroll(0, 105_287, 1, 1),
|
||||
Op::GoodClose(2),
|
||||
Op::Pair(6, 8, 1, 1),
|
||||
Op::Post(6),
|
||||
],
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Contract (2026-10-13): rules added to the engine never lock anyone out
|
||||
|
||||
Reference in new issue
Block a user