use rust_decimal::{Decimal, MathematicalOps};
use rustledger_core::{Amount, Balance, Pad, Position};
use crate::error::{ErrorCode, ValidationError};
use crate::{LedgerState, PendingPad};
use rustc_hash::FxHashMap;
use rustledger_core::{InternedStr, Inventory};
const BALANCE_TOLERANCE_MULTIPLIER: Decimal = Decimal::TWO;
fn sum_account_and_subaccounts(
inventories: &FxHashMap<InternedStr, Inventory>,
account: &InternedStr,
currency: &InternedStr,
) -> Decimal {
let account_str = account.as_str();
let mut total = Decimal::ZERO;
for (inv_account, inv) in inventories {
if inv_account == account
|| (inv_account.starts_with(account_str)
&& inv_account.as_bytes().get(account_str.len()) == Some(&b':'))
{
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 (tolerance, is_explicit) = if let Some(t) = bal.tolerance {
(t, true)
} else {
let scale = expected.scale();
if scale > 0 {
let quantum = DECIMAL_TEN.powi(-i64::from(scale));
(
state.options.tolerance_multiplier * BALANCE_TOLERANCE_MULTIPLIER * quantum,
false,
)
} else {
(Decimal::ZERO, false)
}
};
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}")),
);
}
}