use std::collections::HashMap;
use std::sync::Arc;
use crate::decimal::Decimal;
use crate::parser::posting::{Amount, Costs, Posting};
use crate::parser::transaction::Transaction;
use super::error::{BookError, BookErrorKind, Residual};
pub(super) fn balance_tx(
tx: &mut Transaction,
file: &Arc<str>,
start_line: usize,
end_line: usize,
) -> Result<(), BookError> {
let mut sums: HashMap<String, Decimal> = HashMap::new();
let mut max_decimals: HashMap<String, usize> = HashMap::new();
let mut missing_idx: Option<usize> = None;
let err = |kind: BookErrorKind| BookError::new(file.clone(), start_line, end_line, kind);
for (i, lp) in tx.postings.iter().enumerate() {
let p = &lp.value;
if p.is_virtual && !p.balanced {
continue;
}
match effective_amount(p) {
Some(eff) => {
*sums.entry(eff.commodity.clone()).or_insert(Decimal::zero()) += eff.value;
let entry = max_decimals.entry(eff.commodity.clone()).or_insert(0);
if eff.decimals > *entry {
*entry = eff.decimals;
}
}
None => {
if p.balance_assertion.is_some() {
continue;
}
if missing_idx.is_some() {
return Err(err(BookErrorKind::MultipleMissing));
}
missing_idx = Some(i);
}
}
}
if let Some(idx) = missing_idx {
match sums.len() {
0 => return Err(err(BookErrorKind::NoAmountsToInfer)),
1 => {
let (commodity, sum) = sums.into_iter().next().unwrap();
let decimals = max_decimals.get(&commodity).copied().unwrap_or(0);
tx.postings[idx].value.amount = Some(Amount {
commodity,
value: -sum,
decimals,
});
}
_ => {
let template = tx.postings[idx].clone();
let mut rows: Vec<(String, Decimal)> = sums.into_iter().collect();
rows.sort_by(|a, b| a.0.cmp(&b.0));
let replacements: Vec<_> = rows
.into_iter()
.map(|(commodity, sum)| {
let decimals = max_decimals.get(&commodity).copied().unwrap_or(0);
let mut lp = template.clone();
lp.value.amount = Some(Amount {
commodity,
value: -sum,
decimals,
});
lp
})
.collect();
tx.postings.splice(idx..=idx, replacements);
}
}
} else if sums.len() == 1 {
let (commodity, sum) = sums.into_iter().next().unwrap();
let decimals = max_decimals.get(&commodity).copied().unwrap_or(0);
if !sum.is_display_zero(decimals) {
return Err(err(BookErrorKind::Unbalanced {
residuals: vec![Residual {
commodity,
value: sum,
decimals,
}],
}));
}
}
Ok(())
}
fn effective_amount(p: &Posting) -> Option<Amount> {
let amt = p.amount.as_ref()?;
if let Some(lot) = &p.lot_cost {
let cost = lot.amount();
return Some(Amount {
commodity: cost.commodity.clone(),
value: amt.value.mul_rounded(cost.value),
decimals: 0,
});
}
Some(match &p.costs {
None => amt.clone(),
Some(Costs::PerUnit(cost)) => Amount {
commodity: cost.commodity.clone(),
value: amt.value.mul_rounded(cost.value),
decimals: 0,
},
Some(Costs::Total(cost)) => {
let signed = if amt.value.is_negative() {
-cost.value
} else {
cost.value
};
Amount {
commodity: cost.commodity.clone(),
value: signed,
decimals: 0,
}
}
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser;
use crate::parser::located::Located;
use crate::resolver;
fn balance_one(src: &str) -> Result<Transaction, BookError> {
let entries = parser::parse(src).unwrap();
let resolved = resolver::resolve(entries).unwrap();
let mut first: Located<Transaction> = resolved.transactions.into_iter().next().unwrap();
let end = first
.value
.postings
.iter()
.map(|p| p.line)
.max()
.unwrap_or(first.line);
balance_tx(&mut first.value, &first.file, first.line, end)?;
Ok(first.value)
}
#[test]
fn infers_single_missing_amount() {
let src = "2024-06-15 * Coffee\n expenses:food 5 USD\n assets:cash\n";
let tx = balance_one(src).unwrap();
let inferred = tx.postings[1].value.amount.as_ref().unwrap();
assert_eq!(inferred.commodity, "USD");
assert_eq!(inferred.value, Decimal::from(-5));
}
#[test]
fn inferred_amount_inherits_max_decimals() {
let src = "2024-06-15 * X\n expenses:food 5.00 USD\n assets:cash\n";
let tx = balance_one(src).unwrap();
assert_eq!(tx.postings[1].value.amount.as_ref().unwrap().decimals, 2);
}
#[test]
fn accepts_already_balanced() {
let src = "2024-06-15 * X\n expenses:food 5 USD\n assets:cash -5 USD\n";
assert!(balance_one(src).is_ok());
}
#[test]
fn errors_on_multiple_missing() {
let src = "2024-06-15 * X\n expenses:food\n assets:cash\n";
let err = balance_one(src).unwrap_err();
assert!(matches!(err.kind, BookErrorKind::MultipleMissing));
}
#[test]
fn errors_on_unbalanced_sum() {
let src = "2024-06-15 * X\n expenses:food 5 USD\n assets:cash -3 USD\n";
let err = balance_one(src).unwrap_err();
assert!(matches!(err.kind, BookErrorKind::Unbalanced { .. }));
}
#[test]
fn missing_in_multi_commodity_expands_to_per_commodity_postings() {
let src = "2024-06-15 * X\n expenses:food 5 USD\n assets:eur -5 EUR\n assets:other\n";
let tx = balance_one(src).unwrap();
assert_eq!(tx.postings.len(), 4);
let last_two = &tx.postings[2..];
assert!(last_two.iter().all(|lp| lp.value.account == "assets:other"));
let commodities: Vec<&str> = last_two
.iter()
.map(|lp| lp.value.amount.as_ref().unwrap().commodity.as_str())
.collect();
assert!(commodities.contains(&"EUR"));
assert!(commodities.contains(&"USD"));
for lp in last_two {
let amt = lp.value.amount.as_ref().unwrap();
match amt.commodity.as_str() {
"USD" => assert_eq!(amt.value, Decimal::from(-5)),
"EUR" => assert_eq!(amt.value, Decimal::from(5)),
_ => panic!("unexpected commodity"),
}
}
}
#[test]
fn multi_commodity_balanced_is_accepted() {
let src = "2024-06-15 * X\n a:x 5 USD\n a:y -5 USD\n a:z 10 EUR\n a:w -10 EUR\n";
assert!(balance_one(src).is_ok());
}
#[test]
fn parens_virtual_excluded_from_balance_check() {
let src = "2024-06-15 * X\n (virtual:off) -5 USD\n expenses:food 5 USD\n assets:cash -5 USD\n";
assert!(balance_one(src).is_ok());
}
#[test]
fn bracket_virtual_participates_in_balance() {
let src = "2024-06-15 * X\n [virtual:on] 5 USD\n assets:cash -5 USD\n";
assert!(balance_one(src).is_ok());
}
#[test]
fn per_unit_cost_balances_across_commodities() {
let src = "2024-06-15 * X\n expenses:food 5 USD @ 0.92 EUR\n assets:eur -4.60 EUR\n";
assert!(balance_one(src).is_ok());
}
#[test]
fn total_cost_balances() {
let src = "2024-06-15 * X\n expenses:food 5 USD @@ 4.60 EUR\n assets:eur -4.60 EUR\n";
assert!(balance_one(src).is_ok());
}
#[test]
fn per_unit_cost_infers_missing_in_cost_commodity() {
let src = "2024-06-15 * X\n expenses:food 5 USD @ 0.92 EUR\n assets:eur\n";
let tx = balance_one(src).unwrap();
let inferred = tx.postings[1].value.amount.as_ref().unwrap();
assert_eq!(inferred.commodity, "EUR");
assert_eq!(inferred.value, Decimal::parse("-4.60").unwrap());
}
#[test]
fn per_unit_cost_unbalanced_errors() {
let src = "2024-06-15 * X\n expenses:food 5 USD @ 0.92 EUR\n assets:eur -4 EUR\n";
assert!(balance_one(src).is_err());
}
#[test]
fn total_cost_with_negative_posting() {
let src = "2024-06-15 * X\n assets:usd -5 USD @@ 4.60 EUR\n assets:eur 4.60 EUR\n";
assert!(balance_one(src).is_ok());
}
#[test]
fn lot_cost_overrides_at_cost_for_balance() {
let src = "2018-01-18 * sell\n\
\tassets:eth ETH-1 {BTC 0.0904} @ BTC 0.0907\n\
\tassets:btc BTC 0.0907\n\
\tin:trade BTC -0.0003\n";
assert!(balance_one(src).is_ok());
}
#[test]
fn lot_cost_fixed_variant_also_used() {
let src = "2018-01-18 * sell\n\
\tassets:eth ETH-1 {=BTC 0.0904} @ BTC 0.0907\n\
\tassets:btc BTC 0.0907\n\
\tin:trade BTC -0.0003\n";
assert!(balance_one(src).is_ok());
}
#[test]
fn high_precision_lot_cost_does_not_tighten_tolerance() {
let src = "2020-09-02 * wizzair\n\
\tassets:czk CZK-7524 {=€0.0380117}\n\
\texpenses:t €286.00\n";
assert!(balance_one(src).is_ok());
}
#[test]
fn high_precision_source_does_not_tighten_tolerance() {
let src = "2018-01-11 * rs\n\
\tassets:btc BTC 0.26184800 @ €11292.58\n\
\tassets:eur €-3001.00\n\
\texpenses:t €44.06\n";
assert!(balance_one(src).is_ok());
}
#[test]
fn residual_within_display_precision_is_accepted() {
let src = "2024-06-15 * rounding\n\
\tassets:btc 0.26184800 BTC @ €11292.58\n\
\tassets:eur €-3001.00\n\
\texpenses:fee €44.06\n";
assert!(balance_one(src).is_ok());
}
#[test]
fn assertion_only_posting_does_not_participate() {
let src = "2024-06-15 * X\n assets:bank = 100 USD\n assets:bank 5 USD\n expenses:food -5 USD\n";
let tx = balance_one(src).unwrap();
assert!(tx.postings[0].value.amount.is_none());
}
}