#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod book;
mod interpolate;
mod pad;
pub use book::{
BookedTransaction, BookingEngine, BookingError, CapitalGain, LedgerBookResult, book,
book_transactions,
};
pub use interpolate::{InterpolationError, InterpolationResult, interpolate};
pub use pad::{
PadError, PadResult, SYNTH_PAD_NARRATION_PREFIX, is_synthesized_pad, merge_with_padding,
process_pads,
};
use bigdecimal::BigDecimal;
use rust_decimal::Decimal;
use rust_decimal::prelude::Signed;
use rustledger_core::{Amount, Currency, IncompleteAmount, Transaction};
use std::collections::HashMap;
#[must_use]
pub fn calculate_tolerance(amounts: &[&Amount]) -> HashMap<Currency, Decimal> {
let mut tolerances: HashMap<Currency, Decimal> = HashMap::with_capacity(amounts.len().min(4));
for amount in amounts {
let tol = amount.inferred_tolerance();
tolerances
.entry(amount.currency.clone())
.and_modify(|t| *t = (*t).max(tol))
.or_insert(tol);
}
tolerances
}
#[must_use]
pub(crate) fn price_currency_of(posting: &rustledger_core::Posting) -> Option<Currency> {
posting
.price
.as_ref()
.and_then(|p| p.amount.as_ref())
.and_then(IncompleteAmount::as_amount)
.map(|a| a.currency.clone())
}
fn price_residual_contribution(
price: &rustledger_core::PriceAnnotation,
units: &rustledger_core::Amount,
) -> Option<(Currency, Decimal)> {
let amt = price
.amount
.as_ref()
.and_then(IncompleteAmount::as_amount)?;
let signed = match price.kind {
rustledger_core::PriceKind::Unit => units.number.abs() * amt.number * units.number.signum(),
rustledger_core::PriceKind::Total => amt.number * units.number.signum(),
};
Some((amt.currency.clone(), signed))
}
#[must_use]
pub(crate) fn infer_cost_currency_from_postings(transaction: &Transaction) -> Option<Currency> {
for posting in &transaction.postings {
if posting.cost.is_some() {
continue;
}
if let Some(units) = &posting.units {
match units {
IncompleteAmount::Complete(amount) => {
if let Some(c) = price_currency_of(posting) {
return Some(c);
}
return Some(amount.currency.clone());
}
IncompleteAmount::CurrencyOnly(currency) => {
return Some(currency.clone());
}
IncompleteAmount::NumberOnly(_) => {}
}
}
}
for posting in &transaction.postings {
if let Some(cost) = &posting.cost
&& let Some(currency) = &cost.currency
{
return Some(currency.clone());
}
}
None
}
#[must_use]
pub fn calculate_residual(transaction: &Transaction) -> HashMap<Currency, Decimal> {
let mut residuals: HashMap<Currency, Decimal> =
HashMap::with_capacity(transaction.postings.len().min(4));
let mut inferred_cost_currency: Option<Option<Currency>> = None;
let get_inferred_currency = |cache: &mut Option<Option<Currency>>| -> Option<Currency> {
cache
.get_or_insert_with(|| infer_cost_currency_from_postings(transaction))
.clone()
};
for posting in &transaction.postings {
if let Some(IncompleteAmount::Complete(units)) = &posting.units {
let cost_contribution = posting.cost.as_ref().and_then(|cost_spec| {
let inferred_currency = cost_spec
.currency
.clone()
.or_else(|| price_currency_of(posting))
.or_else(|| get_inferred_currency(&mut inferred_cost_currency));
let cost_curr = inferred_currency.as_ref()?;
match cost_spec.number {
Some(rustledger_core::CostNumber::Total { value: total }) => {
Some((cost_curr.clone(), total * units.number.signum()))
}
Some(rustledger_core::CostNumber::PerUnitFromTotal(b)) => {
Some((cost_curr.clone(), b.total * units.number.signum()))
}
Some(rustledger_core::CostNumber::PerUnit { value: per_unit }) => {
let cost_amount = units.number * per_unit;
Some((cost_curr.clone(), cost_amount))
}
None => None, }
});
if let Some((currency, amount)) = cost_contribution {
*residuals.entry(currency).or_default() += amount;
} else if posting.cost.is_some() {
} else if let Some(price) = &posting.price {
if let Some((curr, contribution)) = price_residual_contribution(price, units) {
*residuals.entry(curr).or_default() += contribution;
} else {
*residuals.entry(units.currency.clone()).or_default() += units.number;
}
} else {
*residuals.entry(units.currency.clone()).or_default() += units.number;
}
}
}
residuals
}
fn to_big(d: Decimal) -> BigDecimal {
use std::str::FromStr;
BigDecimal::from_str(&d.to_string()).expect("Decimal always produces valid decimal string")
}
#[must_use]
pub fn calculate_residual_precise(transaction: &Transaction) -> HashMap<Currency, BigDecimal> {
let mut residuals: HashMap<Currency, BigDecimal> =
HashMap::with_capacity(transaction.postings.len().min(4));
let mut inferred_cost_currency: Option<Option<Currency>> = None;
let get_inferred_currency = |cache: &mut Option<Option<Currency>>| -> Option<Currency> {
cache
.get_or_insert_with(|| infer_cost_currency_from_postings(transaction))
.clone()
};
for posting in &transaction.postings {
if let Some(IncompleteAmount::Complete(units)) = &posting.units {
let units_number = to_big(units.number);
let cost_contribution = posting.cost.as_ref().and_then(|cost_spec| {
let inferred_currency = cost_spec
.currency
.clone()
.or_else(|| price_currency_of(posting))
.or_else(|| get_inferred_currency(&mut inferred_cost_currency));
let cost_curr = inferred_currency.as_ref()?;
match cost_spec.number {
Some(rustledger_core::CostNumber::Total { value: total }) => Some((
cost_curr.clone(),
to_big(total) * to_big(units.number.signum()),
)),
Some(rustledger_core::CostNumber::PerUnitFromTotal(b)) => Some((
cost_curr.clone(),
to_big(b.total) * to_big(units.number.signum()),
)),
Some(rustledger_core::CostNumber::PerUnit { value: per_unit }) => {
let cost_amount = &units_number * to_big(per_unit);
Some((cost_curr.clone(), cost_amount))
}
None => None,
}
});
if let Some((currency, amount)) = cost_contribution {
*residuals.entry(currency).or_default() += amount;
} else if posting.cost.is_some() {
} else if let Some(price) = &posting.price {
if let Some(amt) = price.amount.as_ref().and_then(IncompleteAmount::as_amount) {
let signed = match price.kind {
rustledger_core::PriceKind::Unit => {
units_number.abs() * to_big(amt.number) * to_big(units.number.signum())
}
rustledger_core::PriceKind::Total => {
to_big(amt.number) * to_big(units.number.signum())
}
};
*residuals.entry(amt.currency.clone()).or_default() += signed;
} else {
*residuals.entry(units.currency.clone()).or_default() += units_number.clone();
}
} else {
*residuals.entry(units.currency.clone()).or_default() += units_number;
}
}
}
residuals
}
#[must_use]
#[allow(clippy::implicit_hasher)]
pub fn is_balanced(transaction: &Transaction, tolerances: &HashMap<Currency, Decimal>) -> bool {
let residuals = calculate_residual(transaction);
for (currency, residual) in residuals {
let tolerance = tolerances.get(¤cy).copied().unwrap_or(Decimal::ZERO);
if residual.abs() > tolerance {
return false;
}
}
true
}
pub fn normalize_prices(txn: &mut Transaction) {
use rustledger_core::{PriceAnnotation, PriceKind};
for posting in &mut txn.postings {
if let (Some(IncompleteAmount::Complete(units)), Some(price)) =
(&posting.units, &posting.price)
&& price.kind == PriceKind::Total
{
let normalized = match price.amount.as_ref().and_then(IncompleteAmount::as_amount) {
Some(total_amount) if !units.number.is_zero() => {
let per_unit = total_amount.number / units.number.abs();
Some(PriceAnnotation::unit(Amount::new(
per_unit,
&total_amount.currency,
)))
}
Some(_) => None, None => {
if price.amount.is_none() {
Some(PriceAnnotation::unit_empty())
} else {
None
}
}
};
if let Some(normalized_price) = normalized {
posting.price = Some(normalized_price);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
use rustledger_core::{CostSpec, IncompleteAmount, NaiveDate, Posting, PriceAnnotation};
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
rustledger_core::naive_date(year, month, day).unwrap()
}
#[test]
fn test_calculate_residual_balanced() {
let txn = Transaction::new(date(2024, 1, 15), "Test")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50.00), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-50.00), "USD"),
));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("USD"), Some(&dec!(0)));
}
#[test]
fn test_calculate_residual_unbalanced() {
let txn = Transaction::new(date(2024, 1, 15), "Test")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50.00), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-45.00), "USD"),
));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("USD"), Some(&dec!(5.00)));
}
#[test]
fn test_is_balanced() {
let txn = Transaction::new(date(2024, 1, 15), "Test")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50.00), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-50.00), "USD"),
));
let tolerances = calculate_tolerance(&[
&Amount::new(dec!(50.00), "USD"),
&Amount::new(dec!(-50.00), "USD"),
]);
assert!(is_balanced(&txn, &tolerances));
}
#[test]
fn test_is_balanced_within_tolerance() {
let txn = Transaction::new(date(2024, 1, 15), "Test")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50.004), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-50.00), "USD"),
));
let tolerances = calculate_tolerance(&[
&Amount::new(dec!(50.004), "USD"),
&Amount::new(dec!(-50.00), "USD"),
]);
assert!(is_balanced(&txn, &tolerances));
}
#[test]
fn test_is_balanced_detects_imbalance() {
let txn = Transaction::new(date(2024, 1, 15), "Test")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50.00), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-49.00), "USD"),
));
let mut tolerances = HashMap::new();
tolerances.insert(Currency::from("USD"), Decimal::ZERO);
assert!(
!is_balanced(&txn, &tolerances),
"a 1.00 USD residual with zero tolerance must be detected as unbalanced"
);
}
#[test]
fn test_is_balanced_at_exact_tolerance_boundary() {
let txn = Transaction::new(date(2024, 1, 15), "Test")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50.01), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-50.00), "USD"),
));
let mut tolerances = HashMap::new();
tolerances.insert(Currency::from("USD"), dec!(0.01));
assert!(
is_balanced(&txn, &tolerances),
"a residual exactly at the tolerance must be treated as balanced"
);
}
#[test]
fn test_calculate_tolerance() {
let amounts = [
Amount::new(dec!(100), "USD"), Amount::new(dec!(50.00), "USD"), Amount::new(dec!(25.000), "EUR"), ];
let refs: Vec<&Amount> = amounts.iter().collect();
let tolerances = calculate_tolerance(&refs);
assert_eq!(tolerances.get("USD"), Some(&dec!(0.5)));
assert_eq!(tolerances.get("EUR"), Some(&dec!(0.0005)));
}
#[test]
fn test_calculate_residual_with_per_unit_cost() {
let txn = Transaction::new(date(2024, 1, 15), "Buy stock")
.with_synthesized_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit {
value: dec!(150.00),
})
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-1500.00), "USD"),
));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("USD"), Some(&dec!(0)));
assert_eq!(residual.get("AAPL"), None);
}
#[test]
fn test_calculate_residual_with_total_cost() {
let txn = Transaction::new(date(2024, 1, 15), "Buy stock")
.with_synthesized_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::Total {
value: dec!(1500.00),
})
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-1500.00), "USD"),
));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("USD"), Some(&dec!(0)));
}
#[test]
fn test_calculate_residual_with_total_cost_negative_units() {
let txn = Transaction::new(date(2024, 1, 15), "Sell stock")
.with_synthesized_posting(
Posting::new("Assets:Stock", Amount::new(dec!(-10), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::Total {
value: dec!(1500.00),
})
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(1500.00), "USD"),
));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("USD"), Some(&dec!(0)));
}
#[test]
fn test_calculate_residual_cost_without_amount_skips() {
let txn = Transaction::new(date(2024, 1, 15), "Test")
.with_synthesized_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
.with_cost(CostSpec::empty()), )
.with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-10), "AAPL")));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("AAPL"), Some(&dec!(-10)));
}
#[test]
fn test_calculate_residual_empty_cost_spec_with_price_skips_not_uses_price() {
let txn = Transaction::new(date(2024, 1, 15), "Sale, empty cost + price")
.with_synthesized_posting(
Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
.with_cost(CostSpec::empty())
.with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
dec!(150),
"USD",
))),
)
.with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("USD"), Some(&dec!(1500)));
}
#[test]
fn test_calculate_residual_precise_empty_cost_spec_with_price_skips_not_uses_price() {
use bigdecimal::BigDecimal;
use std::str::FromStr;
let txn = Transaction::new(date(2024, 1, 15), "Sale, empty cost + price")
.with_synthesized_posting(
Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
.with_cost(CostSpec::empty())
.with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
dec!(150),
"USD",
))),
)
.with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")));
let residual = calculate_residual_precise(&txn);
assert_eq!(
residual.get("USD"),
Some(&BigDecimal::from_str("1500").unwrap())
);
}
#[test]
fn test_calculate_residual_with_unit_price() {
let txn = Transaction::new(date(2024, 1, 15), "Currency exchange")
.with_synthesized_posting(
Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD"))
.with_price(PriceAnnotation::unit(Amount::new(dec!(0.85), "EUR"))),
)
.with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("EUR"), Some(&dec!(0)));
assert_eq!(residual.get("USD"), None);
}
#[test]
fn test_calculate_residual_with_total_price() {
let txn = Transaction::new(date(2024, 1, 15), "Currency exchange")
.with_synthesized_posting(
Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD"))
.with_price(PriceAnnotation::total(Amount::new(dec!(85.00), "EUR"))),
)
.with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("EUR"), Some(&dec!(0)));
}
#[test]
fn test_calculate_residual_with_unit_price_positive() {
let txn = Transaction::new(date(2024, 1, 15), "Buy EUR")
.with_synthesized_posting(
Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR"))
.with_price(PriceAnnotation::unit(Amount::new(dec!(1.18), "USD"))),
)
.with_synthesized_posting(Posting::new(
"Assets:USD",
Amount::new(dec!(-100.30), "USD"),
));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("USD"), Some(&dec!(0)));
}
#[test]
fn test_calculate_residual_unit_incomplete_with_amount() {
let txn = Transaction::new(date(2024, 1, 15), "Exchange")
.with_synthesized_posting(
Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD")).with_price(
PriceAnnotation::unit_incomplete(IncompleteAmount::Complete(Amount::new(
dec!(0.85),
"EUR",
))),
),
)
.with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("EUR"), Some(&dec!(0)));
}
#[test]
fn test_calculate_residual_total_incomplete_with_amount() {
let txn = Transaction::new(date(2024, 1, 15), "Exchange")
.with_synthesized_posting(
Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD")).with_price(
PriceAnnotation::total_incomplete(IncompleteAmount::Complete(Amount::new(
dec!(85.00),
"EUR",
))),
),
)
.with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("EUR"), Some(&dec!(0)));
}
#[test]
fn test_calculate_residual_unit_incomplete_no_amount_fallback() {
let txn = Transaction::new(date(2024, 1, 15), "Test")
.with_synthesized_posting(
Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD")).with_price(
PriceAnnotation::unit_incomplete(IncompleteAmount::NumberOnly(dec!(0.85))),
),
)
.with_synthesized_posting(Posting::new(
"Assets:USD",
Amount::new(dec!(-100.00), "USD"),
));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("USD"), Some(&dec!(0)));
}
#[test]
fn test_calculate_residual_total_incomplete_no_amount_fallback() {
let txn = Transaction::new(date(2024, 1, 15), "Test")
.with_synthesized_posting(
Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD")).with_price(
PriceAnnotation::total_incomplete(IncompleteAmount::NumberOnly(dec!(85.00))),
),
)
.with_synthesized_posting(Posting::new(
"Assets:USD",
Amount::new(dec!(-100.00), "USD"),
));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("USD"), Some(&dec!(0)));
}
#[test]
fn test_calculate_residual_unit_empty_fallback() {
let txn = Transaction::new(date(2024, 1, 15), "Test")
.with_synthesized_posting(
Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD"))
.with_price(PriceAnnotation::unit_empty()),
)
.with_synthesized_posting(Posting::new(
"Assets:USD",
Amount::new(dec!(-100.00), "USD"),
));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("USD"), Some(&dec!(0)));
}
#[test]
fn test_calculate_residual_total_empty_fallback() {
let txn = Transaction::new(date(2024, 1, 15), "Test")
.with_synthesized_posting(
Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD"))
.with_price(PriceAnnotation::total_empty()),
)
.with_synthesized_posting(Posting::new(
"Assets:USD",
Amount::new(dec!(-100.00), "USD"),
));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("USD"), Some(&dec!(0)));
}
#[test]
fn test_calculate_residual_mixed_cost_and_simple() {
let txn = Transaction::new(date(2024, 1, 15), "Buy with fee")
.with_synthesized_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit {
value: dec!(150.00),
})
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::new(
"Expenses:Fees",
Amount::new(dec!(10.00), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-1510.00), "USD"),
));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("USD"), Some(&dec!(0)));
}
#[test]
fn test_calculate_residual_sell_with_gains() {
let txn = Transaction::new(date(2024, 6, 15), "Sell stock")
.with_synthesized_posting(
Posting::new("Assets:Stock", Amount::new(dec!(-10), "AAPL"))
.with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit {
value: dec!(150.00),
})
.with_currency("USD"),
)
.with_price(PriceAnnotation::unit(Amount::new(dec!(175.00), "USD"))),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(1750.00), "USD"),
))
.with_synthesized_posting(Posting::new(
"Income:CapitalGains",
Amount::new(dec!(-250.00), "USD"),
));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("USD"), Some(&dec!(0)));
}
#[test]
fn test_calculate_residual_multi_currency_with_cost() {
let txn = Transaction::new(date(2024, 1, 15), "Multi-currency")
.with_synthesized_posting(
Posting::new("Assets:Stock:US", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit {
value: dec!(150.00),
})
.with_currency("USD"),
),
)
.with_synthesized_posting(
Posting::new("Assets:Stock:EU", Amount::new(dec!(5), "SAP")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit {
value: dec!(100.00),
})
.with_currency("EUR"),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash:USD",
Amount::new(dec!(-1500.00), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Cash:EUR",
Amount::new(dec!(-500.00), "EUR"),
));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("USD"), Some(&dec!(0)));
assert_eq!(residual.get("EUR"), Some(&dec!(0)));
}
#[test]
fn test_calculate_residual_skips_incomplete_units() {
let txn = Transaction::new(date(2024, 1, 15), "Test")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50.00), "USD"),
))
.with_synthesized_posting(Posting::auto("Assets:Cash"));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("USD"), Some(&dec!(50.00)));
}
#[test]
fn test_calculate_residual_infers_cost_currency_from_other_posting() {
let txn = Transaction::new(date(2026, 1, 1), "Opening balance")
.with_synthesized_posting(
Posting::new(
"Assets:Vanguard:IRA:Trad:VFIFX",
Amount::new(dec!(10), "VFIFX"),
)
.with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) }),
),
)
.with_synthesized_posting(Posting::new(
"Equity:Opening-Balances",
Amount::new(dec!(-1000), "USD"),
));
let residual = calculate_residual(&txn);
assert_eq!(
residual.get("USD"),
Some(&dec!(0)),
"Should balance when cost currency is inferred from other posting"
);
assert_eq!(residual.get("VFIFX"), None);
}
#[test]
fn test_calculate_residual_infers_cost_currency_total_cost() {
let txn = Transaction::new(date(2026, 1, 1), "Test")
.with_synthesized_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "VFIFX")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::Total { value: dec!(1000) }),
),
)
.with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1000), "USD")));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("USD"), Some(&dec!(0)));
}
#[test]
fn test_calculate_residual_explicit_cost_currency_takes_precedence() {
let txn = Transaction::new(date(2026, 1, 1), "Test")
.with_synthesized_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) })
.with_currency("EUR"), ),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-1000), "USD"), ));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("EUR"), Some(&dec!(1000)));
assert_eq!(residual.get("USD"), Some(&dec!(-1000)));
}
#[test]
fn test_calculate_residual_price_annotation_takes_precedence() {
let txn = Transaction::new(date(2026, 1, 1), "Test")
.with_synthesized_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
.with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) }),
)
.with_price(PriceAnnotation::unit(Amount::new(dec!(105), "EUR"))),
)
.with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1000), "USD")));
let residual = calculate_residual(&txn);
assert_eq!(residual.get("EUR"), Some(&dec!(1000)));
assert_eq!(residual.get("USD"), Some(&dec!(-1000)));
}
#[test]
fn test_infer_cost_currency_from_cost_spec() {
let txn = Transaction::new(date(2022, 4, 16), "Free tokens")
.with_synthesized_posting(
Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::auto("Income:Bonus"));
let inferred = infer_cost_currency_from_postings(&txn);
assert_eq!(inferred.as_deref(), Some("USD"));
}
#[test]
fn test_infer_cost_currency_simple_takes_precedence() {
let txn = Transaction::new(date(2022, 4, 16), "Trade")
.with_synthesized_posting(
Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(10) })
.with_currency("EUR"),
),
)
.with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1000), "USD")));
let inferred = infer_cost_currency_from_postings(&txn);
assert_eq!(inferred.as_deref(), Some("USD"));
}
#[test]
fn test_infer_cost_currency_zero_cost() {
let txn = Transaction::new(date(2022, 4, 16), "Airdrop")
.with_synthesized_posting(
Posting::new("Assets:Crypto", Amount::new(dec!(1000), "SHIB")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
.with_currency("JPY"),
),
)
.with_synthesized_posting(Posting::auto("Income:Airdrop"));
let inferred = infer_cost_currency_from_postings(&txn);
assert_eq!(inferred.as_deref(), Some("JPY"));
}
}