#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::arithmetic_side_effects
)]
use doubleentry::account::AccountRegistry;
use doubleentry::balance::TrialBalance;
use doubleentry::canonical::Canonical;
use doubleentry::entry::{Draft, LedgerPolicy, SealContext};
use doubleentry::hash::Hash;
use doubleentry::merkle::MerkleLog;
use doubleentry::period::{LedgerId, PeriodCalendar};
use doubleentry::{
AccountId, Amount, BalanceQuery, Currency, Direction, Entry, EntryId, IdempotencyKey, Journal,
Layer, MoneyError, Posting, Rounding, ValidationError,
};
use proptest::prelude::*;
use time::macros::date;
fn test_ledger() -> LedgerId {
LedgerId::new("test-ledger").expect("valid")
}
type Eur = Amount<2>;
proptest! {
#[test]
fn allocate_conserves_the_total(
minor in -1_000_000_000i64..1_000_000_000,
weights in prop::collection::vec(0u64..1000, 1..24),
) {
let total = Eur::from_minor(minor);
match total.allocate(&weights) {
Ok(parts) => {
prop_assert_eq!(parts.len(), weights.len());
let sum = Eur::checked_sum(parts.iter().copied()).expect("no overflow");
prop_assert_eq!(sum, total);
}
Err(e) => prop_assert_eq!(e, MoneyError::ZeroWeight),
}
}
#[test]
fn distribute_conserves_and_is_even(
minor in -1_000_000_000i64..1_000_000_000,
n in 1usize..64,
) {
let total = Eur::from_minor(minor);
let parts = total.distribute(n).expect("valid split");
prop_assert_eq!(Eur::checked_sum(parts.iter().copied()).expect("ok"), total);
let max = parts.iter().map(|p| p.to_minor()).max().unwrap_or(0);
let min = parts.iter().map(|p| p.to_minor()).min().unwrap_or(0);
prop_assert!(max - min <= 1, "parts differ by more than one minor unit");
}
#[test]
fn allocate_preserves_sign(
minor in 0i64..1_000_000_000,
weights in prop::collection::vec(1u64..1000, 1..16),
) {
let parts = Eur::from_minor(minor).allocate(&weights).expect("valid split");
prop_assert!(parts.iter().all(|p| !p.is_negative()));
}
#[test]
fn parse_display_round_trips(minor in -9_000_000_000_000i64..9_000_000_000_000) {
let a = Eur::from_minor(minor);
prop_assert_eq!(Eur::parse(&a.to_string()).expect("round trips"), a);
}
#[test]
fn addition_is_total(a in any::<i64>(), b in any::<i64>()) {
let result = Eur::from_minor(a).checked_add(Eur::from_minor(b));
match a.checked_add(b) {
Some(expected) => prop_assert_eq!(result.expect("fits"), Eur::from_minor(expected)),
None => prop_assert_eq!(result, Err(MoneyError::Overflow)),
}
}
}
fn any_rounding() -> impl Strategy<Value = Rounding> {
prop_oneof![
Just(Rounding::HalfUp),
Just(Rounding::HalfDown),
Just(Rounding::HalfEven),
Just(Rounding::TowardZero),
Just(Rounding::AwayFromZero),
Just(Rounding::Floor),
Just(Rounding::Ceiling),
]
}
proptest! {
#[test]
fn a_rounded_ratio_brackets_the_exact_value(
minor in -1_000_000_000i64..1_000_000_000,
numerator in -100_000i64..100_000,
denominator in prop::sample::select(vec![-997i64, -100, -7, -1, 1, 3, 7, 100, 365, 1_000_000]),
rounding in any_rounding(),
) {
let exact = i128::from(minor) * i128::from(numerator);
let got = Eur::from_minor(minor)
.checked_mul_ratio(numerator, denominator, rounding)
.expect("in range for these bounds");
let reconstructed = i128::from(got.to_minor()) * i128::from(denominator);
prop_assert!(
(reconstructed - exact).abs() < i128::from(denominator).abs(),
"{rounding:?}: {minor} * {numerator} / {denominator} landed on {got}"
);
let scaled_up = i128::from(got.to_minor()) * i128::from(denominator).abs();
let exact_up = if denominator < 0 { -exact } else { exact };
match rounding {
Rounding::Floor => prop_assert!(scaled_up <= exact_up),
Rounding::Ceiling => prop_assert!(scaled_up >= exact_up),
Rounding::TowardZero => prop_assert!(scaled_up.abs() <= exact_up.abs()),
Rounding::AwayFromZero => prop_assert!(scaled_up.abs() >= exact_up.abs()),
_ => {}
}
}
#[test]
fn sign_symmetric_rounding_does_not_favour_a_sign(
minor in -1_000_000_000i64..1_000_000_000,
numerator in 1i64..100_000,
denominator in 1i64..100_000,
rounding in prop_oneof![
Just(Rounding::HalfUp),
Just(Rounding::HalfDown),
Just(Rounding::HalfEven),
Just(Rounding::TowardZero),
Just(Rounding::AwayFromZero),
],
) {
let positive = Eur::from_minor(minor)
.checked_mul_ratio(numerator, denominator, rounding)
.expect("in range");
let negative = Eur::from_minor(-minor)
.checked_mul_ratio(numerator, denominator, rounding)
.expect("in range");
prop_assert_eq!(negative, positive.checked_neg().expect("in range"));
}
#[test]
fn rescaling_wider_round_trips(
minor in -100_000_000i64..100_000_000,
rounding in any_rounding(),
) {
let original = Eur::from_minor(minor);
let wide = original.rescale::<6>(rounding).expect("in range");
prop_assert_eq!(wide.rescale::<2>(rounding).expect("in range"), original);
}
}
fn leaf(i: u64) -> Hash {
let mut bytes = [0u8; 32];
bytes[..8].copy_from_slice(&i.to_le_bytes());
Hash::from_bytes(bytes)
}
fn size_and_index(max: u64) -> impl Strategy<Value = (u64, u64)> {
(1u64..max).prop_flat_map(|n| (Just(n), 0u64..n))
}
fn size_and_distinct_indices(max: u64) -> impl Strategy<Value = (u64, u64, u64)> {
(2u64..max)
.prop_flat_map(|n| (Just(n), 0u64..n, 1u64..n))
.prop_map(|(n, i, step)| (n, i, (i + step) % n))
}
fn size_and_nonempty_prefix(max: u64) -> impl Strategy<Value = (u64, u64)> {
(1u64..max).prop_flat_map(|n| (Just(n), 1u64..=n))
}
fn size_and_any_prefix(max: u64) -> impl Strategy<Value = (u64, u64)> {
(0u64..max).prop_flat_map(|n| (Just(n), 0u64..=n))
}
fn log_of(n: u64) -> MerkleLog {
MerkleLog::from_leaves((0..n).map(leaf).collect())
}
proptest! {
#[test]
fn inclusion_proofs_always_verify((n, i) in size_and_index(80)) {
let log = log_of(n);
let proof = log.inclusion_proof(i).expect("in range");
prop_assert!(proof.verify(&leaf(i), &log.head()));
}
#[test]
fn inclusion_proofs_reject_other_leaves((n, i, j) in size_and_distinct_indices(60)) {
let log = log_of(n);
let proof = log.inclusion_proof(i).expect("in range");
prop_assert!(!proof.verify(&leaf(j), &log.head()));
}
#[test]
fn inclusion_proofs_reject_deformed_paths(
(n, i) in size_and_index(60),
at in 0usize..8,
junk in 0u64..1000,
) {
let log = log_of(n);
let proof = log.inclusion_proof(i).expect("in range");
let at = at % (proof.path.len() + 1);
let mut padded = proof.clone();
padded.path.insert(at, leaf(junk));
prop_assert!(!padded.verify(&leaf(i), &log.head()));
let mut duplicated = proof.clone();
if let Some(&real) = proof.path.get(at.min(proof.path.len().saturating_sub(1))) {
duplicated.path.insert(at, real);
prop_assert!(!duplicated.verify(&leaf(i), &log.head()));
}
let mut truncated = proof.clone();
if at < truncated.path.len() {
truncated.path.remove(at);
prop_assert!(!truncated.verify(&leaf(i), &log.head()));
}
}
#[test]
fn inclusion_proofs_are_labelled_uniquely_under_a_head(
(n, i) in size_and_index(40),
(size, index) in (1u64..48).prop_flat_map(|s| (Just(s), 0u64..s)),
) {
let log = log_of(n);
let head = log.head();
let mut proof = log.inclusion_proof(i).expect("in range");
proof.leaf_index = index;
proof.tree_size = size;
prop_assert_eq!(
proof.verify(&leaf(i), &head),
index == i && size == n
);
}
#[test]
fn consistency_proofs_are_bound_to_both_heads(
(n, m) in size_and_nonempty_prefix(40),
old_size in 0u64..48,
new_size in 0u64..48,
) {
let log = log_of(n);
let old_head = log.head_at(m).expect("in range");
let new_head = log.head();
let mut proof = log.consistency_proof(m).expect("in range");
proof.old_size = old_size;
proof.new_size = new_size;
prop_assert_eq!(
proof.verify(&old_head, &new_head),
old_size == m && new_size == n
);
}
#[test]
fn a_proof_from_the_empty_tree_is_never_available(n in 1u64..60) {
let log = log_of(n);
let refused = matches!(
log.consistency_proof(0),
Err(doubleentry::ProofError::EmptyOldTree { .. })
);
prop_assert!(refused);
let empty = log.head_at(0).expect("in range");
for path in [vec![], vec![leaf(1)], vec![leaf(1), leaf(2)]] {
let forged = doubleentry::ConsistencyProof { old_size: 0, new_size: n, path };
prop_assert!(!forged.verify(&empty, &log.head()));
}
}
#[test]
fn consistency_proofs_always_verify((n, m) in size_and_nonempty_prefix(80)) {
let log = log_of(n);
let old_head = log.head_at(m).expect("in range");
let proof = log.consistency_proof(m).expect("in range");
prop_assert!(proof.verify(&old_head, &log.head()));
}
#[test]
fn tampering_is_always_detected((n, i) in size_and_index(60)) {
let original = log_of(n);
let mut leaves: Vec<Hash> = (0..n).map(leaf).collect();
leaves[i as usize] = leaf(9_999_999);
let tampered = MerkleLog::from_leaves(leaves);
prop_assert_ne!(original.root(), tampered.root());
prop_assert_ne!(original.nodes(), tampered.nodes());
}
#[test]
fn historical_roots_match_replay((n, m) in size_and_any_prefix(48)) {
prop_assert_eq!(log_of(n).root_at(m).expect("in range"), log_of(m).root());
}
}
struct Fixture {
accounts: AccountRegistry,
calendar: PeriodCalendar,
policy: LedgerPolicy,
ids: Vec<AccountId>,
}
impl Fixture {
fn new(account_count: usize) -> Self {
let mut accounts = AccountRegistry::new();
let ids = (0..account_count)
.map(|i| {
accounts
.register_path(&format!("Accounts:A{i}"), date!(2020 - 01 - 01))
.expect("registers")
})
.collect();
Self {
accounts,
calendar: PeriodCalendar::new(),
policy: LedgerPolicy::default(),
ids,
}
}
fn ctx(&self) -> SealContext<'_> {
SealContext {
accounts: &self.accounts,
calendar: &self.calendar,
policy: &self.policy,
}
}
fn account(&self, i: usize) -> AccountId {
*self.ids.get(i % self.ids.len()).expect("non-empty")
}
fn journal(&self) -> Journal<2> {
let mut journal = Journal::<2>::new(test_ledger());
for record in self.accounts.records() {
journal
.restore_account(record)
.expect("restores at its own handle");
}
journal
}
}
fn draft(key: &[u8]) -> Entry<Draft, 2> {
Entry::new(
EntryId::generate(),
IdempotencyKey::new(key.to_vec()).expect("valid"),
date!(2026 - 03 - 15),
)
}
proptest! {
#[test]
fn balanced_postings_always_seal(
amounts in prop::collection::vec(1i64..1_000_000, 1..8),
) {
let f = Fixture::new(4);
let total: i64 = amounts.iter().sum();
let mut e = draft(b"k");
for (i, a) in amounts.iter().enumerate() {
e = e.debit(f.account(i), Eur::from_minor(*a), Currency::EUR);
}
e = e.credit(f.account(amounts.len()), Eur::from_minor(total), Currency::EUR);
prop_assert!(e.seal(&f.ctx()).is_ok());
}
#[test]
fn unbalanced_postings_never_seal(
debit in 1i64..1_000_000,
credit in 1i64..1_000_000,
) {
prop_assume!(debit != credit);
let f = Fixture::new(2);
let err = draft(b"k")
.debit(f.account(0), Eur::from_minor(debit), Currency::EUR)
.credit(f.account(1), Eur::from_minor(credit), Currency::EUR)
.seal(&f.ctx())
.expect_err("must not balance");
let names_the_imbalance = err.any(|e| matches!(e, ValidationError::Unbalanced { .. }));
prop_assert!(names_the_imbalance);
}
#[test]
fn an_entry_that_nets_across_layers_never_seals(
amount in 1i64..1_000_000,
pending_first in any::<bool>(),
) {
let f = Fixture::new(2);
let pending = Posting::debit(f.account(0), Eur::from_minor(amount), Currency::EUR)
.in_layer(Layer::Pending);
let settled = Posting::credit(f.account(1), Eur::from_minor(amount), Currency::EUR);
let e = if pending_first {
draft(b"k").post(pending).post(settled)
} else {
draft(b"k").post(settled).post(pending)
};
let err = e.seal(&f.ctx()).expect_err("the layers do not balance apart");
for want in [Layer::Settled, Layer::Pending] {
let named = err.any(|e| match e {
ValidationError::Unbalanced { layer, .. } => *layer == want,
_ => false,
});
prop_assert!(named);
}
}
#[test]
fn each_layer_of_the_journal_balances(
amounts in prop::collection::vec(1i64..1_000_000, 1..8),
layers in prop::collection::vec(any::<bool>(), 1..8),
) {
let mut journal = Journal::<2>::new(test_ledger());
for path in ["P:A", "P:B", "P:C"] {
journal.register_path(path, date!(2000 - 01 - 01)).expect("registers");
}
for (i, amount) in amounts.iter().enumerate() {
let layer = if *layers.get(i).unwrap_or(&false) { Layer::Pending } else { Layer::Settled };
let key = format!("k{i}");
let draft = Entry::<Draft, 2>::new(
EntryId::generate(),
IdempotencyKey::new(key.into_bytes()).expect("valid"),
date!(2026 - 03 - 15),
)
.post(Posting::debit(AccountId::from_index(0), Eur::from_minor(*amount), Currency::EUR).in_layer(layer))
.post(Posting::credit(AccountId::from_index(1), Eur::from_minor(*amount), Currency::EUR).in_layer(layer));
journal.record(draft).expect("records");
}
prop_assert!(journal.verify_balanced().expect("no overflow"));
prop_assert!(journal.verify_balances().expect("no overflow"));
}
#[test]
fn the_content_hash_ignores_nothing_semantic(amount in 1i64..1_000_000) {
let f = Fixture::new(2);
let build = || {
draft(b"k")
.debit(f.account(0), Eur::from_minor(amount), Currency::EUR)
.credit(f.account(1), Eur::from_minor(amount), Currency::EUR)
.seal(&f.ctx())
.expect("balances")
};
let a = build();
let b = build();
prop_assert_ne!(a.id(), b.id());
prop_assert_eq!(a.content_hash(), b.content_hash());
}
#[test]
fn canonical_encoding_is_deterministic(amount in 1i64..1_000_000) {
let f = Fixture::new(2);
let build = || {
draft(b"k")
.debit(f.account(0), Eur::from_minor(amount), Currency::EUR)
.credit(f.account(1), Eur::from_minor(amount), Currency::EUR)
.seal(&f.ctx())
.expect("balances")
.to_canonical_bytes()
};
prop_assert_eq!(build(), build());
}
#[test]
fn the_journal_folds_to_a_balanced_trial_balance(
amounts in prop::collection::vec(1i64..100_000, 1..24),
) {
let f = Fixture::new(5);
let mut j = f.journal();
for (i, a) in amounts.iter().enumerate() {
let entry = draft(format!("k{i}").as_bytes())
.debit(f.account(i), Eur::from_minor(*a), Currency::EUR)
.credit(f.account(i + 1), Eur::from_minor(*a), Currency::EUR)
.seal(&f.ctx())
.expect("balances");
j.record_validated(entry).expect("records");
}
prop_assert_eq!(j.len(), amounts.len());
prop_assert!(j.verify_balanced().expect("no overflow"));
prop_assert!(j.verify_log());
let totals = j
.trial_balance(BalanceQuery::all())
.expect("no overflow")
.totals(Currency::EUR, Layer::Settled)
.expect("no overflow");
prop_assert!(totals.is_balanced());
prop_assert_eq!(totals.debits.to_minor(), amounts.iter().sum::<i64>());
}
#[test]
fn every_journal_entry_is_provable(count in 1usize..40) {
let f = Fixture::new(3);
let mut j = f.journal();
for i in 0..count {
let entry = draft(format!("k{i}").as_bytes())
.debit(f.account(i), Eur::from_minor(100), Currency::EUR)
.credit(f.account(i + 1), Eur::from_minor(100), Currency::EUR)
.seal(&f.ctx())
.expect("balances");
j.record_validated(entry).expect("records");
}
let head = j.head();
for (i, entry) in j.entries().iter().enumerate() {
let proof = j
.prove_inclusion(doubleentry::LogIndex::from(i as u64))
.expect("in range");
prop_assert!(proof.verify(&entry.content_hash(), &head));
}
}
#[test]
fn a_reversal_restores_every_net_balance(amount in 1i64..1_000_000) {
let f = Fixture::new(2);
let mut j = f.journal();
let original = draft(b"orig")
.debit(f.account(0), Eur::from_minor(amount), Currency::EUR)
.credit(f.account(1), Eur::from_minor(amount), Currency::EUR)
.seal(&f.ctx())
.expect("balances");
j.record_validated(original.clone()).expect("records");
let reversal = original
.reverse(
EntryId::generate(),
IdempotencyKey::new(b"rev".to_vec()).expect("valid"),
date!(2026 - 04 - 01),
)
.seal(&f.ctx())
.expect("balances");
j.record_validated(reversal).expect("records");
let tb = j.trial_balance(BalanceQuery::all()).expect("no overflow");
for (_, balance) in tb.iter() {
prop_assert_eq!(balance.signed_net().expect("ok"), Eur::ZERO);
prop_assert!(!balance.is_empty(), "gross turnover must remain visible");
}
}
#[test]
fn replays_are_idempotent(repeats in 1usize..10, amount in 1i64..1_000_000) {
let f = Fixture::new(2);
let mut j = f.journal();
let mut first_index = None;
for _ in 0..repeats {
let entry = draft(b"stable-key")
.debit(f.account(0), Eur::from_minor(amount), Currency::EUR)
.credit(f.account(1), Eur::from_minor(amount), Currency::EUR)
.seal(&f.ctx())
.expect("balances");
let recorded = j.record_validated(entry).expect("records or replays");
match first_index {
None => first_index = Some(recorded.index),
Some(idx) => {
prop_assert_eq!(recorded.index, idx);
prop_assert!(!recorded.is_new);
}
}
}
prop_assert_eq!(j.len(), 1);
}
#[test]
fn prefix_folds_match_replayed_history(
amounts in prop::collection::vec(1i64..100_000, 1..16),
) {
let f = Fixture::new(4);
let build = |take: usize| {
let mut j = f.journal();
for (i, a) in amounts.iter().take(take).enumerate() {
let entry = draft(format!("k{i}").as_bytes())
.debit(f.account(i), Eur::from_minor(*a), Currency::EUR)
.credit(f.account(i + 1), Eur::from_minor(*a), Currency::EUR)
.seal(&f.ctx())
.expect("balances");
j.record_validated(entry).expect("records");
}
j
};
let full = build(amounts.len());
for take in 1..=amounts.len() {
let prefix = build(take);
let from_prefix: TrialBalance<2> = prefix.trial_balance(BalanceQuery::all()).expect("ok");
let from_full: TrialBalance<2> = full.trial_balance(BalanceQuery::over_prefix(take as u64)).expect("ok");
prop_assert_eq!(from_prefix, from_full);
}
}
}
proptest! {
#[test]
fn inverting_twice_is_the_identity(amount in 0i64..1_000_000, debit in any::<bool>()) {
let account = AccountId::from_index(0);
let direction = if debit { Direction::Debit } else { Direction::Credit };
let p = Posting::<2>::new(account, direction, Eur::from_minor(amount), Currency::EUR);
prop_assert_eq!(p.inverted().inverted(), p);
}
#[test]
fn a_posting_and_its_inverse_cancel(amount in 0i64..1_000_000, debit in any::<bool>()) {
let account = AccountId::from_index(0);
let direction = if debit { Direction::Debit } else { Direction::Credit };
let p = Posting::<2>::new(account, direction, Eur::from_minor(amount), Currency::EUR);
let sum = p
.signed()
.expect("ok")
.checked_add(p.inverted().signed().expect("ok"))
.expect("ok");
prop_assert_eq!(sum, Eur::ZERO);
}
}
proptest! {
#[test]
fn seals_detect_any_edit(size in 1u64..64, debits in 1i64..1_000_000) {
use doubleentry::merkle::TreeHead;
use doubleentry::period::PeriodId;
use doubleentry::seal::PeriodCoverage;
use doubleentry::{Balance, BalanceKey, Seal, TrialBalance};
let mut tb = TrialBalance::<2>::new();
let key = BalanceKey {
account: AccountId::from_index(0),
currency: Currency::EUR,
layer: Layer::Settled,
};
let mut balance = Balance::<2>::ZERO;
balance.add(Direction::Debit, Eur::from_minor(debits)).expect("ok");
tb.set(key, balance);
let seal = Seal::build(
test_ledger(),
PeriodId::new("p").expect("valid"),
PeriodCoverage::spanning(0, size.saturating_sub(1), size),
&log_of(size),
&tb,
TreeHead { size: 1, root: leaf(7) },
None,
).expect("builds");
prop_assert!(seal.is_self_consistent());
let mut edited = seal.clone();
edited.last_index = Some(size);
prop_assert!(!edited.is_self_consistent());
let mut restated = seal;
restated.trial_balance.root = leaf(999_999);
prop_assert!(!restated.is_self_consistent());
}
#[test]
fn the_trial_balance_head_sees_gross_movement(volume in 1i64..1_000_000) {
use doubleentry::seal::trial_balance_head;
use doubleentry::{Balance, BalanceKey, TrialBalance};
let key = BalanceKey {
account: AccountId::from_index(0),
currency: Currency::EUR,
layer: Layer::Settled,
};
let mut quiet = TrialBalance::<2>::new();
quiet.set(key, Balance::<2>::ZERO);
let mut busy = TrialBalance::<2>::new();
let mut b = Balance::<2>::ZERO;
b.add(Direction::Debit, Eur::from_minor(volume)).expect("ok");
b.add(Direction::Credit, Eur::from_minor(volume)).expect("ok");
busy.set(key, b);
prop_assert_eq!(
quiet.get(&key).expect("set").signed_net().expect("ok"),
busy.get(&key).expect("set").signed_net().expect("ok")
);
prop_assert_ne!(trial_balance_head(&quiet), trial_balance_head(&busy));
}
#[test]
fn checkpoints_always_re_derive(amounts in prop::collection::vec(1i64..100_000, 1..16)) {
use doubleentry::BalanceKey;
let f = Fixture::new(3);
let mut j = f.journal();
for (i, a) in amounts.iter().enumerate() {
let entry = draft(format!("k{i}").as_bytes())
.debit(f.account(i), Eur::from_minor(*a), Currency::EUR)
.credit(f.account(i + 1), Eur::from_minor(*a), Currency::EUR)
.seal(&f.ctx())
.expect("balances");
j.record_validated(entry).expect("records");
}
let key = BalanceKey {
account: f.account(0),
currency: Currency::EUR,
layer: Layer::Settled,
};
let cp = j.checkpoint(&key).expect("no overflow");
prop_assert!(j.verify_checkpoint(&cp).is_ok());
}
#[test]
fn assertions_hold_only_on_the_true_net(
amount in 1i64..1_000_000,
offset in -1000i64..1000,
) {
use doubleentry::{BalanceAssertion, BalanceKey};
let f = Fixture::new(2);
let mut j = f.journal();
let entry = draft(b"k")
.debit(f.account(0), Eur::from_minor(amount), Currency::EUR)
.credit(f.account(1), Eur::from_minor(amount), Currency::EUR)
.seal(&f.ctx())
.expect("balances");
j.record_validated(entry).expect("records");
let key = BalanceKey {
account: f.account(0),
currency: Currency::EUR,
layer: Layer::Settled,
};
let claimed = amount.saturating_add(offset);
let outcome = j
.check_assertion(&BalanceAssertion::net(key, Eur::from_minor(claimed)))
.expect("no overflow");
prop_assert_eq!(outcome.held(), offset == 0);
}
}
#[cfg(feature = "serde")]
proptest! {
#[test]
fn amounts_round_trip_through_json(minor in -9_000_000_000_000i64..9_000_000_000_000) {
let a = Eur::from_minor(minor);
let json = serde_json::to_string(&a).expect("serialises");
prop_assert!(json.starts_with('"'), "expected a string, got {json}");
prop_assert_eq!(serde_json::from_str::<Eur>(&json).expect("parses"), a);
}
#[test]
fn entries_round_trip_and_re_seal_identically(amount in 1i64..1_000_000) {
let f = Fixture::new(2);
let original = draft(b"k")
.debit(f.account(0), Eur::from_minor(amount), Currency::EUR)
.credit(f.account(1), Eur::from_minor(amount), Currency::EUR)
.seal(&f.ctx())
.expect("balances");
let json = serde_json::to_string(&original).expect("serialises");
let received: Entry<Draft, 2> = serde_json::from_str(&json).expect("parses as a draft");
let resealed = received.seal(&f.ctx()).expect("still balances");
prop_assert_eq!(resealed.content_hash(), original.content_hash());
}
}
#[cfg(feature = "serde")]
#[test]
fn deserialisation_re_runs_validation() {
use doubleentry::{Currency, Label};
assert!(serde_json::from_str::<Currency>("\"eur\"").is_err());
assert!(serde_json::from_str::<Currency>("\"EURO\"").is_err());
assert!(serde_json::from_str::<Label>("\"\"").is_err());
assert!(serde_json::from_str::<Label>("\"bad\\nvalue\"").is_err());
assert!(serde_json::from_str::<Eur>("\"1.234\"").is_err());
assert!(serde_json::from_str::<Eur>("123").is_err());
assert_eq!(
serde_json::from_str::<Currency>("\"EUR\"").expect("valid"),
Currency::EUR
);
}
proptest! {
#[test]
fn clearing_never_over_applies(
invoice in 100i64..1_000_000,
payments in prop::collection::vec(1i64..200_000, 1..8),
) {
use doubleentry::clearing::{Clearing, ClearingId, PostingRef};
use doubleentry::BalanceKey;
let f = Fixture::new(2);
let mut j = f.journal();
let inv = draft(b"invoice")
.debit(f.account(0), Eur::from_minor(invoice), Currency::EUR)
.credit(f.account(1), Eur::from_minor(invoice), Currency::EUR)
.seal(&f.ctx())
.expect("balances");
let invoice_ref = PostingRef::new(inv.id(), 0);
j.record_validated(inv).expect("records");
let key = BalanceKey {
account: f.account(0),
currency: Currency::EUR,
layer: Layer::Settled,
};
let mut applied_total = 0i64;
for (i, pay) in payments.iter().enumerate() {
let p = draft(format!("pay{i}").as_bytes())
.credit(f.account(0), Eur::from_minor(*pay), Currency::EUR)
.debit(f.account(1), Eur::from_minor(*pay), Currency::EUR)
.seal(&f.ctx())
.expect("balances");
let pay_ref = PostingRef::new(p.id(), 0);
j.record_validated(p).expect("records");
let room = invoice.saturating_sub(applied_total).min(*pay);
if room <= 0 {
continue;
}
j.clear(
Clearing::new(ClearingId::generate(), key, date!(2026 - 03 - 20))
.apply(invoice_ref, Eur::from_minor(room))
.apply(pay_ref, Eur::from_minor(room)),
)
.expect("within the residual");
applied_total = applied_total.saturating_add(room);
}
prop_assert!(applied_total <= invoice);
prop_assert_eq!(
j.clearings().applied_to(invoice_ref),
Eur::from_minor(applied_total)
);
let open = j.open_items(&key).expect("ok");
let invoice_open = open.iter().find(|i| i.posting == invoice_ref);
if applied_total == invoice {
prop_assert!(invoice_open.is_none());
} else {
let item = invoice_open.expect("still open");
prop_assert_eq!(item.residual, Eur::from_minor(invoice - applied_total));
}
}
#[test]
fn clearing_never_moves_money(amount in 1i64..1_000_000) {
use doubleentry::clearing::{Clearing, ClearingId, PostingRef};
let f = Fixture::new(2);
let mut j = f.journal();
let a = draft(b"a")
.debit(f.account(0), Eur::from_minor(amount), Currency::EUR)
.credit(f.account(1), Eur::from_minor(amount), Currency::EUR)
.seal(&f.ctx())
.expect("balances");
let a_ref = PostingRef::new(a.id(), 0);
j.record_validated(a).expect("records");
let b = draft(b"b")
.credit(f.account(0), Eur::from_minor(amount), Currency::EUR)
.debit(f.account(1), Eur::from_minor(amount), Currency::EUR)
.seal(&f.ctx())
.expect("balances");
let b_ref = PostingRef::new(b.id(), 0);
j.record_validated(b).expect("records");
let before = j.trial_balance(BalanceQuery::all()).expect("ok");
let id = ClearingId::generate();
let key = doubleentry::BalanceKey {
account: f.account(0),
currency: Currency::EUR,
layer: Layer::Settled,
};
j.clear(
Clearing::new(id, key, date!(2026 - 03 - 20))
.apply(a_ref, Eur::from_minor(amount))
.apply(b_ref, Eur::from_minor(amount)),
)
.expect("clears");
prop_assert_eq!(&before, &j.trial_balance(BalanceQuery::all()).expect("ok"));
j.reset_clearing(id, date!(2026 - 04 - 01)).expect("resets");
prop_assert_eq!(j.clearings().applied_to(a_ref), Eur::ZERO);
prop_assert_eq!(&before, &j.trial_balance(BalanceQuery::all()).expect("ok"));
}
}
proptest! {
#[test]
fn closing_balances_and_flattens(
revenue in 1i64..1_000_000,
expense in 1i64..1_000_000,
) {
use doubleentry::account::{Account, AccountKind, AccountPath};
use doubleentry::{BalanceKey, TrialBalance, closing_postings};
let mut accounts = AccountRegistry::new();
let mut register = |path: &str, kind: AccountKind| {
accounts
.register(
Account::new(AccountPath::parse(path).expect("valid"), date!(2020 - 01 - 01))
.with_kind(kind),
)
.expect("registers")
};
let income = register("Income:Sales", AccountKind::Income);
let cost = register("Expense:Rent", AccountKind::Expense);
let equity = register("Equity:Retained", AccountKind::Equity);
let mut tb = TrialBalance::<2>::new();
tb.apply(&Posting::credit(income, Eur::from_minor(revenue), Currency::EUR)).expect("ok");
tb.apply(&Posting::debit(cost, Eur::from_minor(expense), Currency::EUR)).expect("ok");
let postings = closing_postings(
&tb,
&accounts,
&[AccountKind::Income, AccountKind::Expense],
equity,
Layer::Settled,
)
.expect("closes");
let mut check = TrialBalance::<2>::new();
for p in &postings {
check.apply(p).expect("ok");
}
prop_assert!(check.totals(Currency::EUR, Layer::Settled).expect("ok").is_balanced());
let mut after = tb;
for p in &postings {
after.apply(p).expect("ok");
}
for account in [income, cost] {
let key = BalanceKey { account, currency: Currency::EUR, layer: Layer::Settled };
prop_assert_eq!(
after.get_or_zero(&key).signed_net().expect("ok"),
Eur::ZERO
);
}
let equity_key = BalanceKey { account: equity, currency: Currency::EUR, layer: Layer::Settled };
prop_assert_eq!(
after.get_or_zero(&equity_key).signed_net().expect("ok"),
Eur::from_minor(expense - revenue)
);
let again = closing_postings(
&after,
&accounts,
&[AccountKind::Income, AccountKind::Expense],
equity,
Layer::Settled,
)
.expect("ok");
prop_assert!(again.is_empty());
}
}
proptest! {
#[test]
fn a_statement_opens_where_the_period_before_it_closed(
rows in prop::collection::vec(
(0i64..90, 0i64..90, 1i64..10_000),
0..24,
),
window in (0i64..90, 0i64..90),
by_value in any::<bool>(),
) {
let f = Fixture::new(2);
let (left, right) = (f.account(0), f.account(1));
let mut journal = f.journal();
let epoch = date!(2026 - 01 - 01);
let day = |offset: i64| epoch.saturating_add(time::Duration::days(offset));
for (i, (booked, valued, minor)) in rows.iter().enumerate() {
let amount = Eur::from_minor(*minor);
let entry = Entry::<Draft, 2>::new(
EntryId::generate(),
IdempotencyKey::new(format!("s{i}").into_bytes()).expect("valid"),
day(*booked),
)
.with_value_date(day(*valued))
.debit(left, amount, Currency::EUR)
.credit(right, amount, Currency::EUR);
journal.record(entry).expect("records");
}
let (a, b) = window;
let (start, end) = (day(a.min(b)), day(a.max(b)));
let query = if by_value {
BalanceQuery::between(start, end).by_value_date()
} else {
BalanceQuery::between(start, end)
};
let key = doubleentry::BalanceKey {
account: left,
currency: Currency::EUR,
layer: Layer::Settled,
};
let lines = journal.statement(&key, query).expect("no overflow");
let opening = journal.statement_opening(&key, query, None).expect("no overflow");
let mut folded = opening;
for line in &lines {
folded.add(line.direction, line.amount).expect("no overflow");
prop_assert_eq!(folded, line.running);
}
let through = if by_value {
BalanceQuery::through(end).by_value_date()
} else {
BalanceQuery::through(end)
};
prop_assert_eq!(folded, journal.balance(&key, through).expect("no overflow"));
for line in &lines {
prop_assert_eq!(
journal
.statement_opening(&key, query, Some(line.position()))
.expect("no overflow"),
line.running
);
}
}
}
proptest! {
#[test]
fn a_rollup_redistributes_the_trial_balance_without_changing_it(
rows in prop::collection::vec(
((0usize..3, 0usize..3, 0usize..3), 1i64..100_000),
1..20,
),
depth in 0usize..5,
) {
let mut accounts = AccountRegistry::new();
let mut balances = TrialBalance::<2>::new();
for ((a, b, c), minor) in &rows {
let path = format!("Root{a}:Mid{b}:Leaf{c}");
let id = match accounts.id_of(&path.parse().expect("valid")) {
Some(id) => id,
None => accounts
.register_path(&path, date!(2020 - 01 - 01))
.expect("registers"),
};
balances
.apply(&Posting::debit(id, Eur::from_minor(*minor), Currency::EUR))
.expect("no overflow");
balances
.apply(&Posting::credit(id, Eur::from_minor(*minor), Currency::EUR))
.expect("no overflow");
}
let report = doubleentry::Rollup::of(
&balances,
&accounts,
Currency::EUR,
Layer::Settled,
)
.expect("no overflow");
prop_assert_eq!(
report.total().expect("no overflow"),
balances.totals(Currency::EUR, Layer::Settled).expect("no overflow")
);
for node in report.nodes() {
let mut folded = node.own;
for child in report.nodes() {
if child.path.parent().as_ref() == Some(&node.path) {
folded = folded.checked_add(&child.subtree).expect("no overflow");
}
}
prop_assert_eq!(folded, node.subtree);
}
for pair in report.nodes().windows(2) {
prop_assert!(pair[0].path < pair[1].path);
}
let short = report.to_depth(depth);
prop_assert!(short.nodes().iter().all(|n| n.depth() <= depth));
if depth >= 1 {
prop_assert_eq!(
short.total().expect("no overflow"),
report.total().expect("no overflow")
);
}
}
}