#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod book;
mod interpolate;
mod pad;
pub use book::{BookedTransaction, BookingEngine, BookingError, CapitalGain, book_transactions};
pub use interpolate::{InterpolationError, InterpolationResult, interpolate};
pub use pad::{PadError, PadResult, expand_pads, merge_with_padding, process_pads};
use bigdecimal::BigDecimal;
use rust_decimal::Decimal;
use rust_decimal::prelude::Signed;
use rustledger_core::{Amount, IncompleteAmount, InternedStr, Transaction};
use std::collections::HashMap;
#[must_use]
pub fn calculate_tolerance(amounts: &[&Amount]) -> HashMap<InternedStr, Decimal> {
let mut tolerances: HashMap<InternedStr, 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 infer_cost_currency_from_postings(transaction: &Transaction) -> Option<InternedStr> {
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(price) = &posting.price {
match price {
rustledger_core::PriceAnnotation::Unit(a)
| rustledger_core::PriceAnnotation::Total(a) => {
return Some(a.currency.clone());
}
rustledger_core::PriceAnnotation::UnitIncomplete(inc)
| rustledger_core::PriceAnnotation::TotalIncomplete(inc) => {
if let Some(a) = inc.as_amount() {
return Some(a.currency.clone());
}
}
_ => {}
}
}
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<InternedStr, Decimal> {
let mut residuals: HashMap<InternedStr, Decimal> =
HashMap::with_capacity(transaction.postings.len().min(4));
let mut inferred_cost_currency: Option<Option<InternedStr>> = None;
let get_inferred_currency = |cache: &mut Option<Option<InternedStr>>| -> Option<InternedStr> {
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 price_currency = 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,
});
let inferred_currency = cost_spec
.currency
.clone()
.or(price_currency)
.or_else(|| get_inferred_currency(&mut inferred_cost_currency));
if let (Some(total), Some(cost_curr)) =
(&cost_spec.number_total, &inferred_currency)
{
Some((cost_curr.clone(), *total * units.number.signum()))
} else if let (Some(per_unit), Some(cost_curr)) =
(&cost_spec.number_per, &inferred_currency)
{
let cost_amount = units.number * per_unit;
Some((cost_curr.clone(), cost_amount))
} else {
None }
});
if let Some((currency, amount)) = cost_contribution {
*residuals.entry(currency).or_default() += amount;
} else if let Some(price) = &posting.price {
match price {
rustledger_core::PriceAnnotation::Unit(price_amt) => {
let converted = units.number.abs() * price_amt.number;
*residuals.entry(price_amt.currency.clone()).or_default() +=
converted * units.number.signum();
}
rustledger_core::PriceAnnotation::Total(price_amt) => {
*residuals.entry(price_amt.currency.clone()).or_default() +=
price_amt.number * units.number.signum();
}
rustledger_core::PriceAnnotation::UnitIncomplete(inc) => {
if let Some(price_amt) = inc.as_amount() {
let converted = units.number.abs() * price_amt.number;
*residuals.entry(price_amt.currency.clone()).or_default() +=
converted * units.number.signum();
} else {
*residuals.entry(units.currency.clone()).or_default() += units.number;
}
}
rustledger_core::PriceAnnotation::TotalIncomplete(inc) => {
if let Some(price_amt) = inc.as_amount() {
*residuals.entry(price_amt.currency.clone()).or_default() +=
price_amt.number * units.number.signum();
} else {
*residuals.entry(units.currency.clone()).or_default() += units.number;
}
}
rustledger_core::PriceAnnotation::UnitEmpty
| rustledger_core::PriceAnnotation::TotalEmpty => {
*residuals.entry(units.currency.clone()).or_default() += units.number;
}
}
} else if posting.cost.is_some() {
} 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<InternedStr, BigDecimal> {
let mut residuals: HashMap<InternedStr, BigDecimal> =
HashMap::with_capacity(transaction.postings.len().min(4));
let mut inferred_cost_currency: Option<Option<InternedStr>> = None;
let get_inferred_currency = |cache: &mut Option<Option<InternedStr>>| -> Option<InternedStr> {
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 price_currency = 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,
});
let inferred_currency = cost_spec
.currency
.clone()
.or(price_currency)
.or_else(|| get_inferred_currency(&mut inferred_cost_currency));
if let (Some(total), Some(cost_curr)) =
(&cost_spec.number_total, &inferred_currency)
{
Some((
cost_curr.clone(),
to_big(*total) * to_big(units.number.signum()),
))
} else if let (Some(per_unit), Some(cost_curr)) =
(&cost_spec.number_per, &inferred_currency)
{
let cost_amount = &units_number * to_big(*per_unit);
Some((cost_curr.clone(), cost_amount))
} else {
None
}
});
if let Some((currency, amount)) = cost_contribution {
*residuals.entry(currency).or_default() += amount;
} else if let Some(price) = &posting.price {
match price {
rustledger_core::PriceAnnotation::Unit(price_amt) => {
let converted = units_number.abs() * to_big(price_amt.number);
*residuals.entry(price_amt.currency.clone()).or_default() +=
converted * to_big(units.number.signum());
}
rustledger_core::PriceAnnotation::Total(price_amt) => {
*residuals.entry(price_amt.currency.clone()).or_default() +=
to_big(price_amt.number) * to_big(units.number.signum());
}
rustledger_core::PriceAnnotation::UnitIncomplete(inc) => {
if let Some(price_amt) = inc.as_amount() {
let converted = units_number.abs() * to_big(price_amt.number);
*residuals.entry(price_amt.currency.clone()).or_default() +=
converted * to_big(units.number.signum());
} else {
*residuals.entry(units.currency.clone()).or_default() +=
units_number.clone();
}
}
rustledger_core::PriceAnnotation::TotalIncomplete(inc) => {
if let Some(price_amt) = inc.as_amount() {
*residuals.entry(price_amt.currency.clone()).or_default() +=
to_big(price_amt.number) * to_big(units.number.signum());
} else {
*residuals.entry(units.currency.clone()).or_default() +=
units_number.clone();
}
}
rustledger_core::PriceAnnotation::UnitEmpty
| rustledger_core::PriceAnnotation::TotalEmpty => {
*residuals.entry(units.currency.clone()).or_default() +=
units_number.clone();
}
}
} else if posting.cost.is_some() {
} 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<InternedStr, 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;
for posting in &mut txn.postings {
if let (Some(IncompleteAmount::Complete(units)), Some(price)) =
(&posting.units, &posting.price)
{
let normalized = match price {
PriceAnnotation::Total(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,
)))
}
PriceAnnotation::TotalIncomplete(inc) if !units.number.is_zero() => {
if let Some(total_amount) = inc.as_amount() {
let per_unit = total_amount.number / units.number.abs();
Some(PriceAnnotation::Unit(Amount::new(
per_unit,
&total_amount.currency,
)))
} else {
None
}
}
PriceAnnotation::TotalEmpty => Some(PriceAnnotation::UnitEmpty),
_ => 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_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50.00), "USD"),
))
.with_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_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50.00), "USD"),
))
.with_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_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50.00), "USD"),
))
.with_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_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50.004), "USD"),
))
.with_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_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_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number_per(dec!(150.00))
.with_currency("USD"),
),
)
.with_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_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number_total(dec!(1500.00))
.with_currency("USD"),
),
)
.with_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_posting(
Posting::new("Assets:Stock", Amount::new(dec!(-10), "AAPL")).with_cost(
CostSpec::empty()
.with_number_total(dec!(1500.00))
.with_currency("USD"),
),
)
.with_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_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
.with_cost(CostSpec::empty()), )
.with_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_with_unit_price() {
let txn = Transaction::new(date(2024, 1, 15), "Currency exchange")
.with_posting(
Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD"))
.with_price(PriceAnnotation::Unit(Amount::new(dec!(0.85), "EUR"))),
)
.with_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_posting(
Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD"))
.with_price(PriceAnnotation::Total(Amount::new(dec!(85.00), "EUR"))),
)
.with_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_posting(
Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR"))
.with_price(PriceAnnotation::Unit(Amount::new(dec!(1.18), "USD"))),
)
.with_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_posting(
Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD")).with_price(
PriceAnnotation::UnitIncomplete(IncompleteAmount::Complete(Amount::new(
dec!(0.85),
"EUR",
))),
),
)
.with_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_posting(
Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD")).with_price(
PriceAnnotation::TotalIncomplete(IncompleteAmount::Complete(Amount::new(
dec!(85.00),
"EUR",
))),
),
)
.with_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_posting(
Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD")).with_price(
PriceAnnotation::UnitIncomplete(IncompleteAmount::NumberOnly(dec!(0.85))),
),
)
.with_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_posting(
Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD")).with_price(
PriceAnnotation::TotalIncomplete(IncompleteAmount::NumberOnly(dec!(85.00))),
),
)
.with_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_posting(
Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD"))
.with_price(PriceAnnotation::UnitEmpty),
)
.with_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_posting(
Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD"))
.with_price(PriceAnnotation::TotalEmpty),
)
.with_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_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number_per(dec!(150.00))
.with_currency("USD"),
),
)
.with_posting(Posting::new(
"Expenses:Fees",
Amount::new(dec!(10.00), "USD"),
))
.with_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_posting(
Posting::new("Assets:Stock", Amount::new(dec!(-10), "AAPL"))
.with_cost(
CostSpec::empty()
.with_number_per(dec!(150.00))
.with_currency("USD"),
)
.with_price(PriceAnnotation::Unit(Amount::new(dec!(175.00), "USD"))),
)
.with_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(1750.00), "USD"),
))
.with_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_posting(
Posting::new("Assets:Stock:US", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number_per(dec!(150.00))
.with_currency("USD"),
),
)
.with_posting(
Posting::new("Assets:Stock:EU", Amount::new(dec!(5), "SAP")).with_cost(
CostSpec::empty()
.with_number_per(dec!(100.00))
.with_currency("EUR"),
),
)
.with_posting(Posting::new(
"Assets:Cash:USD",
Amount::new(dec!(-1500.00), "USD"),
))
.with_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_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50.00), "USD"),
))
.with_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_posting(
Posting::new(
"Assets:Vanguard:IRA:Trad:VFIFX",
Amount::new(dec!(10), "VFIFX"),
)
.with_cost(CostSpec::empty().with_number_per(dec!(100))),
)
.with_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_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "VFIFX"))
.with_cost(CostSpec::empty().with_number_total(dec!(1000))),
)
.with_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_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number_per(dec!(100))
.with_currency("EUR"), ),
)
.with_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_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
.with_cost(CostSpec::empty().with_number_per(dec!(100)))
.with_price(PriceAnnotation::Unit(Amount::new(dec!(105), "EUR"))),
)
.with_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_posting(
Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
CostSpec::empty()
.with_number_per(dec!(0))
.with_currency("USD"),
),
)
.with_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_posting(
Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
CostSpec::empty()
.with_number_per(dec!(10))
.with_currency("EUR"),
),
)
.with_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_posting(
Posting::new("Assets:Crypto", Amount::new(dec!(1000), "SHIB")).with_cost(
CostSpec::empty()
.with_number_per(dec!(0))
.with_currency("JPY"),
),
)
.with_posting(Posting::auto("Income:Airdrop"));
let inferred = infer_cost_currency_from_postings(&txn);
assert_eq!(inferred.as_deref(), Some("JPY"));
}
}