use rust_decimal::{Decimal, MathematicalOps};
use rustledger_core::{Amount, Balance, Pad, Position, is_subaccount_or_equal};
use crate::error::{ErrorCode, ValidationError};
use crate::{LedgerState, PendingPad};
use rustc_hash::FxHashMap;
use rustledger_core::Inventory;
const BALANCE_TOLERANCE_MULTIPLIER: Decimal = Decimal::TWO;
#[must_use]
pub fn balance_tolerance(
expected: Decimal,
explicit: Option<Decimal>,
tolerance_multiplier: Decimal,
) -> Decimal {
if let Some(t) = explicit {
return t;
}
let scale = expected.scale();
if scale > 0 {
let quantum = DECIMAL_TEN.powi(-i64::from(scale));
tolerance_multiplier * BALANCE_TOLERANCE_MULTIPLIER * quantum
} else {
Decimal::ZERO
}
}
fn sum_account_and_subaccounts(
inventories: &FxHashMap<rustledger_core::Account, Inventory>,
account: &rustledger_core::Account,
currency: &rustledger_core::Currency,
) -> Decimal {
let account_str = account.as_str();
let mut total = Decimal::ZERO;
for (inv_account, inv) in inventories {
if is_subaccount_or_equal(inv_account.as_str(), account_str) {
total += inv.units(currency);
}
}
total
}
const DECIMAL_TEN: Decimal = Decimal::TEN;
pub fn validate_pad(state: &mut LedgerState, pad: &Pad, errors: &mut Vec<ValidationError>) {
if !state.accounts.contains_key(&pad.account) {
errors.push(ValidationError::new(
ErrorCode::AccountNotOpen,
format!("Pad target account {} was never opened", pad.account),
pad.date,
));
return;
}
if !state.accounts.contains_key(&pad.source_account) {
errors.push(ValidationError::new(
ErrorCode::AccountNotOpen,
format!("Pad source account {} was never opened", pad.source_account),
pad.date,
));
return;
}
let pending_pad = PendingPad {
source_account: pad.source_account.clone(),
date: pad.date,
padded_currencies: rustc_hash::FxHashSet::default(),
};
state
.pending_pads
.entry(pad.account.clone())
.or_default()
.push(pending_pad);
}
pub fn validate_balance_early(
state: &LedgerState,
bal: &Balance,
errors: &mut Vec<ValidationError>,
) {
if !state.accounts.contains_key(&bal.account) {
errors.push(ValidationError::new(
ErrorCode::AccountNotOpen,
format!("Account {} was never opened", bal.account),
bal.date,
));
}
}
pub fn validate_balance_late(
state: &mut LedgerState,
bal: &Balance,
errors: &mut Vec<ValidationError>,
) {
if !state.accounts.contains_key(&bal.account) {
return;
}
if let Some(pending_pads) = state.pending_pads.get_mut(&bal.account) {
pending_pads.retain(|p| !p.padded_currencies.contains(&bal.amount.currency));
let effective_idx: Vec<usize> = pending_pads
.iter()
.enumerate()
.filter(|(_, p)| p.date < bal.date)
.map(|(i, _)| i)
.collect();
if effective_idx.len() > 1 {
errors.push(
ValidationError::new(
ErrorCode::MultiplePadForBalance,
format!(
"Multiple pad directives for {} {} before balance assertion",
bal.account, bal.amount.currency
),
bal.date,
)
.with_context(format!(
"pad dates: {}",
effective_idx
.iter()
.map(|&i| pending_pads[i].date.to_string())
.collect::<Vec<_>>()
.join(", ")
)),
);
}
if let Some(pending_pad) = effective_idx.last().and_then(|&i| pending_pads.get_mut(i)) {
let actual =
sum_account_and_subaccounts(&state.inventories, &bal.account, &bal.amount.currency);
{
let expected = bal.amount.number;
let difference = expected - actual;
if difference != Decimal::ZERO {
if let Some(target_inv) = state.inventories.get_mut(&bal.account) {
target_inv.add(Position::simple(Amount::new(
difference,
&bal.amount.currency,
)));
}
if let Some(source_inv) = state.inventories.get_mut(&pending_pad.source_account)
{
source_inv.add(Position::simple(Amount::new(
-difference,
&bal.amount.currency,
)));
}
pending_pad
.padded_currencies
.insert(bal.amount.currency.clone());
}
}
return;
}
}
let actual =
sum_account_and_subaccounts(&state.inventories, &bal.account, &bal.amount.currency);
let expected = bal.amount.number;
let difference = (actual - expected).abs();
let is_explicit = bal.tolerance.is_some();
let tolerance = balance_tolerance(expected, bal.tolerance, state.options.tolerance_multiplier);
if difference > tolerance {
let error_code = if is_explicit {
ErrorCode::BalanceToleranceExceeded
} else {
ErrorCode::BalanceAssertionFailed
};
let message = if is_explicit {
format!(
"Balance exceeds explicit tolerance for {}: expected {} {} ~ {}, got {} {} (difference: {})",
bal.account,
expected,
bal.amount.currency,
tolerance,
actual,
bal.amount.currency,
difference
)
} else {
format!(
"Balance failed for {}: expected {} {}, got {} {}",
bal.account, expected, bal.amount.currency, actual, bal.amount.currency
)
};
errors.push(
ValidationError::new(error_code, message, bal.date)
.with_context(format!("difference: {difference}, tolerance: {tolerance}")),
);
}
}
#[cfg(test)]
mod tolerance_tests {
use super::*;
use rust_decimal_macros::dec;
fn default_mul() -> Decimal {
dec!(0.5)
}
#[test]
fn explicit_tolerance_always_wins() {
assert_eq!(
balance_tolerance(dec!(100.00), Some(dec!(0.001)), default_mul()),
dec!(0.001)
);
assert_eq!(
balance_tolerance(dec!(100.00), Some(dec!(50)), default_mul()),
dec!(50)
);
}
#[test]
fn integer_amount_requires_exact_match() {
assert_eq!(
balance_tolerance(dec!(100), None, default_mul()),
Decimal::ZERO
);
}
#[test]
fn two_decimal_amount_uses_default_quantum() {
assert_eq!(
balance_tolerance(dec!(100.00), None, default_mul()),
dec!(0.01)
);
}
#[test]
fn higher_precision_scales_down() {
assert_eq!(
balance_tolerance(dec!(100.0000), None, default_mul()),
dec!(0.0001)
);
}
#[test]
fn multiplier_one_doubles_default() {
assert_eq!(balance_tolerance(dec!(100.00), None, dec!(1.0)), dec!(0.02));
}
#[test]
fn multiplier_zero_forces_strict_match() {
assert_eq!(
balance_tolerance(dec!(100.00), None, dec!(0.0)),
Decimal::ZERO
);
}
#[test]
fn negative_amount_uses_same_scale_logic() {
assert_eq!(
balance_tolerance(dec!(-100.00), None, default_mul()),
dec!(0.01)
);
}
}