use rust_decimal::Decimal;
use rustc_hash::FxHashMap;
use rustledger_core::{
Amount, BookingMethod, InternedStr, Inventory, Posting, ReductionScope, Transaction,
};
use std::collections::HashMap;
use crate::error::{ErrorCode, ValidationError};
use crate::{AccountState, LedgerState, ValidationOptions};
pub fn validate_transaction(
state: &mut LedgerState,
txn: &Transaction,
errors: &mut Vec<ValidationError>,
) {
if !validate_transaction_structure(txn, errors) {
return; }
validate_posting_accounts(state, txn, errors);
let tolerances = calculate_tolerances(txn, &state.options);
validate_transaction_balance(txn, &tolerances, errors);
for (currency, tolerance) in tolerances {
state
.tolerances
.entry(currency)
.and_modify(|t| *t = (*t).max(tolerance))
.or_insert(tolerance);
}
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_per.is_some_and(|n| n.is_zero()) || c.number_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<&InternedStr>, 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 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());
if let Some(per) = cost.number_per
&& per < Decimal::ZERO
{
errors.push(ValidationError::new(
ErrorCode::NegativeCost,
format!(
"Cost is negative: per-unit cost ({per} {cost_currency}) for {units_str} in posting to {}",
posting.account
),
txn.date,
));
}
if let Some(total) = cost.number_total
&& total < Decimal::ZERO
{
errors.push(ValidationError::new(
ErrorCode::NegativeCost,
format!(
"Cost is negative: total cost ({total} {cost_currency}) for {units_str} in posting to {}",
posting.account
),
txn.date,
));
}
}
}
true
}
pub fn validate_posting_accounts(
state: &LedgerState,
txn: &Transaction,
errors: &mut Vec<ValidationError>,
) {
for posting in &txn.postings {
match state.accounts.get(&posting.account) {
Some(account_state) => {
validate_account_lifecycle(txn, posting, account_state, errors);
validate_posting_currency(state, txn, posting, account_state, errors);
}
None => {
errors.push(ValidationError::new(
ErrorCode::AccountNotOpen,
format!("Account {} was never opened", posting.account),
txn.date,
));
}
}
}
}
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<InternedStr, Decimal>,
errors: &mut Vec<ValidationError>,
) {
let has_empty_cost_spec = txn.postings.iter().any(|p| {
if let Some(cost) = &p.cost {
cost.number_per.is_none() && cost.number_total.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<InternedStr, Decimal> {
let mut tolerances: HashMap<InternedStr, 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<InternedStr, 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_per
&& 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 {
match price {
rustledger_core::PriceAnnotation::Unit(price_amt) => {
let price_tolerance = tolerance * price_amt.number;
*cost_tolerances
.entry(price_amt.currency.clone())
.or_default() += price_tolerance;
}
rustledger_core::PriceAnnotation::Total(price_amt) => {
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 = InternedStr::new(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 =
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_per.is_none()
&& cost.number_total.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);
}