use rust_decimal::Decimal;
use rustc_hash::FxHashMap;
use rustledger_core::{Amount, BookingMethod, Inventory, Posting, ReductionScope, Transaction};
use std::collections::HashMap;
use crate::error::{ErrorCode, ValidationError};
use crate::{AccountState, LedgerState, ValidationOptions};
pub fn validate_transaction_early(
state: &LedgerState,
txn: &Transaction,
errors: &mut Vec<ValidationError>,
) {
if !validate_transaction_structure(txn, errors) {
return;
}
for posting in &txn.postings {
match state.accounts.get(&posting.account) {
Some(account_state) => {
validate_account_lifecycle(txn, posting, account_state, errors);
}
None => {
errors.push(ValidationError::new(
ErrorCode::AccountNotOpen,
format!("Account {} was never opened", posting.account),
txn.date,
));
}
}
}
}
pub fn validate_transaction_late(
state: &mut LedgerState,
txn: &Transaction,
errors: &mut Vec<ValidationError>,
) {
for posting in &txn.postings {
if let Some(account_state) = state.accounts.get(&posting.account) {
validate_posting_currency(state, txn, posting, account_state, errors);
}
}
let tolerances = calculate_tolerances(txn, &state.options);
validate_transaction_balance(txn, &tolerances, errors);
update_inventories(state, txn, errors);
}
pub fn validate_transaction_structure(
txn: &Transaction,
errors: &mut Vec<ValidationError>,
) -> bool {
if txn.postings.is_empty() {
return false;
}
let is_zero_cost_single = txn.postings.len() == 1
&& txn.postings[0].cost.as_ref().is_some_and(|c| {
c.number.is_some_and(|cn| {
cn.per_unit().is_some_and(|n| n.is_zero())
|| cn.total().is_some_and(|n| n.is_zero())
})
});
if txn.postings.len() == 1 && !is_zero_cost_single {
errors.push(ValidationError::new(
ErrorCode::SinglePosting,
"Transaction has only one posting".to_string(),
txn.date,
));
}
{
let mut missing_count: FxHashMap<Option<&rustledger_core::Currency>, u32> =
FxHashMap::default();
for posting in &txn.postings {
if posting.amount().is_none() {
let currency = posting
.units
.as_ref()
.and_then(|u| u.as_amount())
.map(|a| &a.currency);
*missing_count.entry(currency).or_default() += 1;
}
}
let total_missing: u32 = missing_count.values().sum();
if total_missing > 1 {
errors.push(ValidationError::new(
ErrorCode::MultipleInterpolation,
format!(
"Transaction has {total_missing} postings with missing amounts; at most one is allowed"
),
txn.date,
));
}
}
for posting in &txn.postings {
if let Some(cost) = &posting.cost
&& let Some(cn) = cost.number
{
let (label, value) = match cn {
rustledger_core::CostNumber::PerUnit { value } => ("per-unit", value),
rustledger_core::CostNumber::Total { value }
| rustledger_core::CostNumber::PerUnitFromTotal(rustledger_core::BookedCost {
total: value,
..
}) => ("total", value),
};
if value < Decimal::ZERO {
let units_str = posting.amount().map_or_else(
|| "?".to_string(),
|a| format!("{} {}", a.number, a.currency),
);
let cost_currency = cost.currency.as_ref().map_or("?", |c| c.as_str());
errors.push(ValidationError::new(
ErrorCode::NegativeCost,
format!(
"Cost is negative: {label} cost ({value} {cost_currency}) for {units_str} in posting to {}",
posting.account
),
txn.date,
));
}
}
}
true
}
pub fn validate_account_lifecycle(
txn: &Transaction,
posting: &Posting,
account_state: &AccountState,
errors: &mut Vec<ValidationError>,
) {
if txn.date < account_state.opened {
errors.push(ValidationError::new(
ErrorCode::AccountNotOpen,
format!(
"Account {} used on {} but not opened until {}",
posting.account, txn.date, account_state.opened
),
txn.date,
));
}
if let Some(closed) = account_state.closed
&& txn.date >= closed
{
errors.push(ValidationError::new(
ErrorCode::AccountClosed,
format!(
"Posting to inactive account {} on {} (closed on {})",
posting.account, txn.date, closed
),
txn.date,
));
}
}
pub fn validate_posting_currency(
state: &LedgerState,
txn: &Transaction,
posting: &Posting,
account_state: &AccountState,
errors: &mut Vec<ValidationError>,
) {
let Some(units) = posting.amount() else {
return;
};
if !account_state.currencies.is_empty() && !account_state.currencies.contains(&units.currency) {
errors.push(ValidationError::new(
ErrorCode::CurrencyNotAllowed,
format!(
"Invalid currency {} not allowed in account {}",
units.currency, posting.account
),
txn.date,
));
}
if state.options.require_commodities && !state.commodities.contains(&units.currency) {
errors.push(ValidationError::new(
ErrorCode::UndeclaredCurrency,
format!("Currency {} not declared", units.currency),
txn.date,
));
}
}
pub fn validate_transaction_balance(
txn: &Transaction,
tolerances: &HashMap<rustledger_core::Currency, Decimal>,
errors: &mut Vec<ValidationError>,
) {
let has_empty_cost_spec = txn.postings.iter().any(|p| {
if let Some(cost) = &p.cost {
cost.number.is_none()
} else {
false
}
});
if has_empty_cost_spec {
return;
}
let fast_residuals = rustledger_booking::calculate_residual(txn);
let all_zero = fast_residuals
.values()
.all(|residual| *residual == Decimal::ZERO);
if all_zero {
return;
}
let residuals = rustledger_booking::calculate_residual_precise(txn);
for (currency, residual) in &residuals {
let tolerance: bigdecimal::BigDecimal = tolerances
.get(currency)
.map(|d| {
use std::str::FromStr;
bigdecimal::BigDecimal::from_str(&d.to_string()).unwrap_or_default()
})
.unwrap_or_default();
if residual.abs() > tolerance {
errors.push(ValidationError::new(
ErrorCode::TransactionUnbalanced,
format!("Transaction does not balance: residual {residual} {currency}"),
txn.date,
));
}
}
}
pub fn decimal_quantum(value: Decimal) -> Decimal {
let scale = value.scale();
if scale == 0 {
Decimal::ONE
} else {
Decimal::new(1, scale)
}
}
pub fn calculate_tolerances(
txn: &Transaction,
options: &ValidationOptions,
) -> HashMap<rustledger_core::Currency, Decimal> {
let mut tolerances: HashMap<rustledger_core::Currency, Decimal> =
HashMap::with_capacity(txn.postings.len().min(4));
for posting in &txn.postings {
if let Some(units) = posting.amount()
&& units.number.scale() > 0
{
let quantum = decimal_quantum(units.number);
let base_tolerance = quantum * options.tolerance_multiplier;
tolerances
.entry(units.currency.clone())
.and_modify(|t| *t = (*t).max(base_tolerance))
.or_insert(base_tolerance);
}
}
if options.infer_tolerance_from_cost {
let mut cost_tolerances: HashMap<rustledger_core::Currency, Decimal> = HashMap::new();
for posting in &txn.postings {
if let Some(units) = posting.amount() {
if units.number.scale() == 0 {
continue;
}
let units_quantum = decimal_quantum(units.number);
let tolerance = units_quantum * options.tolerance_multiplier;
if let Some(cost_spec) = &posting.cost
&& let Some(cost_per_unit) = cost_spec.number.and_then(|cn| cn.per_unit())
&& let Some(cost_currency) = &cost_spec.currency
{
let cost_tolerance = tolerance * cost_per_unit;
*cost_tolerances.entry(cost_currency.clone()).or_default() += cost_tolerance;
}
if let Some(price) = &posting.price
&& let Some(price_amt) = price
.amount
.as_ref()
.and_then(rustledger_core::IncompleteAmount::as_amount)
{
let price_tolerance = tolerance * price_amt.number;
*cost_tolerances
.entry(price_amt.currency.clone())
.or_default() += price_tolerance;
}
}
}
for (currency, cost_tol) in cost_tolerances {
tolerances
.entry(currency)
.and_modify(|t| *t = (*t).max(cost_tol))
.or_insert(cost_tol);
}
}
if !options.inferred_tolerance_default.is_empty() {
if let Some(wildcard_default) = options.inferred_tolerance_default.get("*") {
for posting in &txn.postings {
if let Some(units) = posting.amount() {
tolerances
.entry(units.currency.clone())
.and_modify(|t| *t = (*t).max(*wildcard_default))
.or_insert(*wildcard_default);
}
}
}
for (currency_str, default_tol) in &options.inferred_tolerance_default {
if currency_str == "*" {
continue;
}
let currency = rustledger_core::Currency::from(currency_str.as_str());
tolerances
.entry(currency)
.and_modify(|t| *t = (*t).max(*default_tol))
.or_insert(*default_tol);
}
}
tolerances
}
pub fn update_inventories(
state: &mut LedgerState,
txn: &Transaction,
errors: &mut Vec<ValidationError>,
) {
for posting in &txn.postings {
let Some(units) = posting.amount() else {
continue;
};
let Some(inv) = state.inventories.get_mut(&posting.account) else {
continue;
};
let booking_method = state
.accounts
.get(&posting.account)
.map(|a| a.booking)
.unwrap_or_default();
let is_reduction = booking_method != BookingMethod::None
&& posting.cost.is_some()
&& inv.is_reduced_by(units, ReductionScope::CostBearingOnly);
if is_reduction {
process_inventory_reduction(inv, posting, units, booking_method, txn, errors);
} else {
process_inventory_addition(inv, posting, units, txn);
}
}
}
pub fn process_inventory_reduction(
inv: &mut Inventory,
posting: &Posting,
units: &Amount,
booking_method: BookingMethod,
txn: &Transaction,
errors: &mut Vec<ValidationError>,
) {
if let Some(cost) = &posting.cost
&& cost.number.is_none()
{
return;
}
match inv.reduce(units, posting.cost.as_ref(), booking_method) {
Ok(_) => {}
Err(err) => {
let (code, context) = match &err {
rustledger_core::BookingError::InsufficientUnits { .. } => (
ErrorCode::InsufficientUnits,
format!("currency: {}", units.currency),
),
rustledger_core::BookingError::AmbiguousMatch { .. } => (
ErrorCode::AmbiguousLotMatch,
"Specify cost, date, or label to disambiguate".to_string(),
),
rustledger_core::BookingError::NoMatchingLot { .. }
| rustledger_core::BookingError::CurrencyMismatch { .. } => (
ErrorCode::NoMatchingLot,
format!("cost spec: {:?}", posting.cost),
),
};
errors.push(
ValidationError::new(
code,
format!("{}", err.with_account(posting.account.clone())),
txn.date,
)
.with_context(context),
);
}
}
}
pub fn process_inventory_addition(
inv: &mut Inventory,
posting: &Posting,
units: &Amount,
txn: &Transaction,
) {
let position = if let Some(cost_spec) = &posting.cost {
if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
rustledger_core::Position::with_cost(units.clone(), cost)
} else {
rustledger_core::Position::simple(units.clone())
}
} else {
rustledger_core::Position::simple(units.clone())
};
inv.add(position);
}
#[cfg(test)]
mod tolerance_tests {
use super::*;
use rust_decimal_macros::dec;
fn cur(s: &str) -> rustledger_core::Currency {
rustledger_core::Currency::from(s)
}
fn mk_txn(postings: Vec<Posting>) -> Transaction {
let mut t = Transaction::new(rustledger_core::naive_date(2024, 1, 1).unwrap(), "t");
for p in postings {
t = t.with_synthesized_posting(p);
}
t
}
#[test]
fn decimal_quantum_reflects_scale() {
assert_eq!(decimal_quantum(dec!(100.00)), dec!(0.01)); assert_eq!(decimal_quantum(dec!(10.436)), dec!(0.001)); assert_eq!(decimal_quantum(dec!(5)), dec!(1)); }
#[test]
fn tolerance_base_is_quantum_times_multiplier_max() {
let t = calculate_tolerances(
&mk_txn(vec![
Posting::new("Assets:A", Amount::new(dec!(10.00), "USD")),
Posting::new("Assets:B", Amount::new(dec!(5.000), "USD")),
Posting::new("Assets:C", Amount::new(dec!(100), "CAD")),
]),
&ValidationOptions::default(),
);
assert_eq!(t.get(&cur("USD")), Some(&dec!(0.005)));
assert!(
!t.contains_key(&cur("CAD")),
"integer-only currency gets no tolerance"
);
assert_eq!(t.len(), 1);
}
#[test]
fn tolerance_cost_inferred_is_units_quantum_times_mult_times_cost() {
let opts = ValidationOptions {
infer_tolerance_from_cost: true,
..ValidationOptions::default()
};
let p = Posting::new("Assets:Stock", Amount::new(dec!(10.00), "STK")).with_cost(
rustledger_core::CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(2.00) })
.with_currency("USD"),
);
let t = calculate_tolerances(&mk_txn(vec![p]), &opts);
assert_eq!(t.get(&cur("USD")), Some(&dec!(0.01)));
assert_eq!(t.get(&cur("STK")), Some(&dec!(0.005)));
assert_eq!(t.len(), 2);
}
#[test]
fn tolerance_price_inferred_is_units_quantum_times_mult_times_price() {
let opts = ValidationOptions {
infer_tolerance_from_cost: true,
..ValidationOptions::default()
};
let p = Posting::new("Assets:Stock", Amount::new(dec!(10.00), "STK")).with_price(
rustledger_core::PriceAnnotation::unit(Amount::new(dec!(3.00), "USD")),
);
let t = calculate_tolerances(&mk_txn(vec![p]), &opts);
assert_eq!(t.get(&cur("USD")), Some(&dec!(0.015)));
assert_eq!(t.get(&cur("STK")), Some(&dec!(0.005)));
assert_eq!(t.len(), 2);
}
#[test]
fn tolerance_per_currency_default_acts_as_floor() {
let mut opts = ValidationOptions::default();
opts.inferred_tolerance_default
.insert("USD".to_string(), dec!(0.1));
let t = calculate_tolerances(
&mk_txn(vec![Posting::new(
"Assets:A",
Amount::new(dec!(10.00), "USD"),
)]),
&opts,
);
assert_eq!(t.get(&cur("USD")), Some(&dec!(0.1)));
assert_eq!(t.len(), 1, "only the USD currency should appear");
}
#[test]
fn tolerance_wildcard_default_applies_to_all_currencies() {
let mut opts = ValidationOptions::default();
opts.inferred_tolerance_default
.insert("*".to_string(), dec!(0.2));
let t = calculate_tolerances(
&mk_txn(vec![Posting::new(
"Assets:A",
Amount::new(dec!(10.00), "USD"),
)]),
&opts,
);
assert_eq!(t.get(&cur("USD")), Some(&dec!(0.2)));
assert_eq!(t.len(), 1, "only the USD currency should appear");
}
}
#[cfg(test)]
mod validator_comparison_tests {
use super::*;
use crate::AccountState;
use rust_decimal_macros::dec;
fn d(y: i32, m: u32, day: u32) -> rustledger_core::NaiveDate {
rustledger_core::naive_date(y, m, day).unwrap()
}
fn acct(opened: rustledger_core::NaiveDate) -> AccountState {
AccountState {
opened,
closed: None,
currencies: rustc_hash::FxHashSet::default(),
booking: BookingMethod::default(),
}
}
fn has(errs: &[ValidationError], code: ErrorCode) -> bool {
errs.iter().any(|e| e.code == code)
}
#[test]
fn lifecycle_posting_on_open_date_is_allowed() {
let a = acct(d(2024, 1, 1));
let p = Posting::new("Assets:A", Amount::new(dec!(1), "USD"));
let txn = Transaction::new(d(2024, 1, 1), "on open date");
let mut errs = Vec::new();
validate_account_lifecycle(&txn, &p, &a, &mut errs);
assert!(
!has(&errs, ErrorCode::AccountNotOpen),
"a posting on the open date must be allowed: {errs:?}"
);
}
#[test]
fn lifecycle_posting_before_open_errors() {
let a = acct(d(2024, 1, 10));
let p = Posting::new("Assets:A", Amount::new(dec!(1), "USD"));
let txn = Transaction::new(d(2024, 1, 1), "before open");
let mut errs = Vec::new();
validate_account_lifecycle(&txn, &p, &a, &mut errs);
assert!(
has(&errs, ErrorCode::AccountNotOpen),
"a posting before the open date must error: {errs:?}"
);
}
fn usd_tol(t: Decimal) -> HashMap<rustledger_core::Currency, Decimal> {
let mut m = HashMap::new();
m.insert(rustledger_core::Currency::from("USD"), t);
m
}
#[test]
fn balance_residual_equal_to_tolerance_is_ok() {
let txn = Transaction::new(d(2024, 1, 1), "edge")
.with_synthesized_posting(Posting::new("Assets:A", Amount::new(dec!(0.01), "USD")));
let mut errs = Vec::new();
validate_transaction_balance(&txn, &usd_tol(dec!(0.01)), &mut errs);
assert!(
!has(&errs, ErrorCode::TransactionUnbalanced),
"a residual exactly at tolerance must pass: {errs:?}"
);
}
#[test]
fn balance_residual_above_tolerance_errors() {
let txn = Transaction::new(d(2024, 1, 1), "unbalanced")
.with_synthesized_posting(Posting::new("Assets:A", Amount::new(dec!(0.02), "USD")));
let mut errs = Vec::new();
validate_transaction_balance(&txn, &usd_tol(dec!(0.01)), &mut errs);
assert!(
has(&errs, ErrorCode::TransactionUnbalanced),
"a residual above tolerance must error: {errs:?}"
);
}
fn cost_posting(cost: Decimal) -> Posting {
Posting::new("Assets:Stock", Amount::new(dec!(10), "STK")).with_cost(
rustledger_core::CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: cost })
.with_currency("USD"),
)
}
#[test]
fn structure_zero_cost_is_not_negative() {
let txn = Transaction::new(d(2024, 1, 1), "zero cost")
.with_synthesized_posting(cost_posting(dec!(0)));
let mut errs = Vec::new();
validate_transaction_structure(&txn, &mut errs);
assert!(
!has(&errs, ErrorCode::NegativeCost),
"a zero cost is not negative: {errs:?}"
);
}
#[test]
fn structure_negative_cost_errors() {
let txn = Transaction::new(d(2024, 1, 1), "neg cost")
.with_synthesized_posting(cost_posting(dec!(-5)));
let mut errs = Vec::new();
validate_transaction_structure(&txn, &mut errs);
assert!(
has(&errs, ErrorCode::NegativeCost),
"a negative cost must error: {errs:?}"
);
}
}