use rust_decimal::Decimal;
use rustledger_core::{Amount, BookingMethod, InternedStr, Inventory, Posting, 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);
validate_transaction_balance(txn, &state.options, errors);
let tolerances = calculate_tolerances(txn, &state.options);
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,
));
}
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,
options: &ValidationOptions,
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 residuals = rustledger_booking::calculate_residual_precise(txn);
let tolerances = calculate_tolerances(txn, options);
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 = units.number.is_sign_negative() && posting.cost.is_some();
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>,
) {
match inv.reduce(units, posting.cost.as_ref(), booking_method) {
Ok(_) => {}
Err(err @ rustledger_core::BookingError::InsufficientUnits { .. }) => {
errors.push(
ValidationError::new(
ErrorCode::InsufficientUnits,
format!("{}", err.with_account(posting.account.clone())),
txn.date,
)
.with_context(format!("currency: {}", units.currency)),
);
}
Err(err @ rustledger_core::BookingError::NoMatchingLot { .. }) => {
let has_positive_lots = inv
.positions()
.iter()
.any(|p| p.units.currency == units.currency && p.units.number > Decimal::ZERO);
if booking_method == BookingMethod::Strict
&& !has_positive_lots
&& let Some(cost_spec) = &posting.cost
{
let cost_number = cost_spec
.number_per
.or_else(|| cost_spec.number_total.map(|t| t / units.number.abs()));
let cost_currency = cost_spec.currency.clone().or_else(|| {
posting.price.as_ref().and_then(|p| match p {
rustledger_core::PriceAnnotation::Unit(a)
| rustledger_core::PriceAnnotation::Total(a) => Some(a.currency.clone()),
rustledger_core::PriceAnnotation::UnitIncomplete(inc)
| rustledger_core::PriceAnnotation::TotalIncomplete(inc) => {
inc.as_amount().map(|a| a.currency.clone())
}
_ => None,
})
});
if let (Some(number), Some(curr)) = (cost_number, cost_currency) {
let cost = rustledger_core::Cost::new(number, curr)
.with_date(cost_spec.date.unwrap_or(txn.date));
let cost = if let Some(label) = &cost_spec.label {
cost.with_label(label.clone())
} else {
cost
};
let position = rustledger_core::Position::with_cost(units.clone(), cost);
inv.add(position);
return; }
}
errors.push(
ValidationError::new(
ErrorCode::NoMatchingLot,
format!("{}", err.with_account(posting.account.clone())),
txn.date,
)
.with_context(format!("cost spec: {:?}", posting.cost)),
);
}
Err(err @ rustledger_core::BookingError::AmbiguousMatch { .. }) => {
errors.push(
ValidationError::new(
ErrorCode::AmbiguousLotMatch,
format!("{}", err.with_account(posting.account.clone())),
txn.date,
)
.with_context("Specify cost, date, or label to disambiguate".to_string()),
);
}
Err(err @ rustledger_core::BookingError::CurrencyMismatch { .. }) => {
errors.push(
ValidationError::new(
ErrorCode::NoMatchingLot,
format!("{}", err.with_account(posting.account.clone())),
txn.date,
)
.with_context(format!("currency: {}", units.currency)),
);
}
}
}
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);
}