core: no entry posts a fraction of a cent; verify compares whole ledgers
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
54f632dfea
commit
e2c421be35
12 files changed
+365
-100
No files matched your search
@@ -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, owner 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);
|
||||
|
||||
Reference in new issue
Block a user