1#![forbid(unsafe_code)]
23#![warn(missing_docs)]
24
25mod book;
26mod interpolate;
27mod pad;
28
29pub use book::{
30 BookedTransaction, BookingEngine, BookingError, CapitalGain, LedgerBookResult, book,
31 book_transactions,
32};
33pub use interpolate::{InterpolationError, InterpolationResult, interpolate};
34pub use pad::{
35 PadError, PadResult, SYNTH_PAD_NARRATION_PREFIX, is_synthesized_pad, merge_with_padding,
36 process_pads,
37};
38
39use bigdecimal::BigDecimal;
40use rust_decimal::Decimal;
41use rust_decimal::prelude::Signed;
42use rustledger_core::{Amount, Currency, IncompleteAmount, Transaction};
43use std::collections::HashMap;
44
45#[must_use]
49pub fn calculate_tolerance(amounts: &[&Amount]) -> HashMap<Currency, Decimal> {
50 let mut tolerances: HashMap<Currency, Decimal> = HashMap::with_capacity(amounts.len().min(4));
52
53 for amount in amounts {
54 let tol = amount.inferred_tolerance();
55 tolerances
56 .entry(amount.currency.clone())
57 .and_modify(|t| *t = (*t).max(tol))
58 .or_insert(tol);
59 }
60
61 tolerances
62}
63
64#[must_use]
72pub(crate) fn price_currency_of(posting: &rustledger_core::Posting) -> Option<Currency> {
73 posting
74 .price
75 .as_ref()
76 .and_then(|p| p.amount.as_ref())
77 .and_then(IncompleteAmount::as_amount)
78 .map(|a| a.currency.clone())
79}
80
81fn price_residual_contribution(
93 price: &rustledger_core::PriceAnnotation,
94 units: &rustledger_core::Amount,
95) -> Option<(Currency, Decimal)> {
96 let amt = price
97 .amount
98 .as_ref()
99 .and_then(IncompleteAmount::as_amount)?;
100 let signed = match price.kind {
101 rustledger_core::PriceKind::Unit => units.number.abs() * amt.number * units.number.signum(),
102 rustledger_core::PriceKind::Total => amt.number * units.number.signum(),
103 };
104 Some((amt.currency.clone(), signed))
105}
106
107#[must_use]
118pub(crate) fn infer_cost_currency_from_postings(transaction: &Transaction) -> Option<Currency> {
119 for posting in &transaction.postings {
121 if posting.cost.is_some() {
123 continue;
124 }
125
126 if let Some(units) = &posting.units {
128 match units {
129 IncompleteAmount::Complete(amount) => {
130 if let Some(c) = price_currency_of(posting) {
133 return Some(c);
134 }
135 return Some(amount.currency.clone());
137 }
138 IncompleteAmount::CurrencyOnly(currency) => {
139 return Some(currency.clone());
140 }
141 IncompleteAmount::NumberOnly(_) => {}
142 }
143 }
144 }
145
146 for posting in &transaction.postings {
149 if let Some(cost) = &posting.cost
150 && let Some(currency) = &cost.currency
151 {
152 return Some(currency.clone());
153 }
154 }
155
156 None
157}
158
159#[must_use]
173pub fn calculate_residual(transaction: &Transaction) -> HashMap<Currency, Decimal> {
174 let mut residuals: HashMap<Currency, Decimal> =
176 HashMap::with_capacity(transaction.postings.len().min(4));
177
178 let mut inferred_cost_currency: Option<Option<Currency>> = None;
180 let get_inferred_currency = |cache: &mut Option<Option<Currency>>| -> Option<Currency> {
181 cache
182 .get_or_insert_with(|| infer_cost_currency_from_postings(transaction))
183 .clone()
184 };
185
186 for posting in &transaction.postings {
187 if let Some(IncompleteAmount::Complete(units)) = &posting.units {
189 let cost_contribution = posting.cost.as_ref().and_then(|cost_spec| {
199 let inferred_currency = cost_spec
201 .currency
202 .clone()
203 .or_else(|| price_currency_of(posting))
204 .or_else(|| get_inferred_currency(&mut inferred_cost_currency));
205
206 let cost_curr = inferred_currency.as_ref()?;
214 match cost_spec.number {
215 Some(rustledger_core::CostNumber::Total { value: total }) => {
216 Some((cost_curr.clone(), total * units.number.signum()))
217 }
218 Some(rustledger_core::CostNumber::PerUnitFromTotal(b)) => {
219 Some((cost_curr.clone(), b.total * units.number.signum()))
220 }
221 Some(rustledger_core::CostNumber::PerUnit { value: per_unit }) => {
222 let cost_amount = units.number * per_unit;
223 Some((cost_curr.clone(), cost_amount))
224 }
225 None => None, }
227 });
228
229 if let Some((currency, amount)) = cost_contribution {
230 *residuals.entry(currency).or_default() += amount;
232 } else if posting.cost.is_some() {
233 } else if let Some(price) = &posting.price {
243 if let Some((curr, contribution)) = price_residual_contribution(price, units) {
246 *residuals.entry(curr).or_default() += contribution;
247 } else {
248 *residuals.entry(units.currency.clone()).or_default() += units.number;
251 }
252 } else {
253 *residuals.entry(units.currency.clone()).or_default() += units.number;
255 }
256 }
257 }
258
259 residuals
260}
261
262fn to_big(d: Decimal) -> BigDecimal {
268 use std::str::FromStr;
269 BigDecimal::from_str(&d.to_string()).expect("Decimal always produces valid decimal string")
271}
272
273#[must_use]
279pub fn calculate_residual_precise(transaction: &Transaction) -> HashMap<Currency, BigDecimal> {
280 let mut residuals: HashMap<Currency, BigDecimal> =
281 HashMap::with_capacity(transaction.postings.len().min(4));
282
283 let mut inferred_cost_currency: Option<Option<Currency>> = None;
284 let get_inferred_currency = |cache: &mut Option<Option<Currency>>| -> Option<Currency> {
285 cache
286 .get_or_insert_with(|| infer_cost_currency_from_postings(transaction))
287 .clone()
288 };
289
290 for posting in &transaction.postings {
291 if let Some(IncompleteAmount::Complete(units)) = &posting.units {
292 let units_number = to_big(units.number);
293
294 let cost_contribution = posting.cost.as_ref().and_then(|cost_spec| {
295 let inferred_currency = cost_spec
296 .currency
297 .clone()
298 .or_else(|| price_currency_of(posting))
299 .or_else(|| get_inferred_currency(&mut inferred_cost_currency));
300
301 let cost_curr = inferred_currency.as_ref()?;
305 match cost_spec.number {
306 Some(rustledger_core::CostNumber::Total { value: total }) => Some((
307 cost_curr.clone(),
308 to_big(total) * to_big(units.number.signum()),
309 )),
310 Some(rustledger_core::CostNumber::PerUnitFromTotal(b)) => Some((
311 cost_curr.clone(),
312 to_big(b.total) * to_big(units.number.signum()),
313 )),
314 Some(rustledger_core::CostNumber::PerUnit { value: per_unit }) => {
315 let cost_amount = &units_number * to_big(per_unit);
316 Some((cost_curr.clone(), cost_amount))
317 }
318 None => None,
319 }
320 });
321
322 if let Some((currency, amount)) = cost_contribution {
323 *residuals.entry(currency).or_default() += amount;
324 } else if posting.cost.is_some() {
325 } else if let Some(price) = &posting.price {
331 if let Some(amt) = price.amount.as_ref().and_then(IncompleteAmount::as_amount) {
337 let signed = match price.kind {
338 rustledger_core::PriceKind::Unit => {
339 units_number.abs() * to_big(amt.number) * to_big(units.number.signum())
340 }
341 rustledger_core::PriceKind::Total => {
342 to_big(amt.number) * to_big(units.number.signum())
343 }
344 };
345 *residuals.entry(amt.currency.clone()).or_default() += signed;
346 } else {
347 *residuals.entry(units.currency.clone()).or_default() += units_number.clone();
348 }
349 } else {
350 *residuals.entry(units.currency.clone()).or_default() += units_number;
351 }
352 }
353 }
354
355 residuals
356}
357
358#[must_use]
360#[allow(clippy::implicit_hasher)]
361pub fn is_balanced(transaction: &Transaction, tolerances: &HashMap<Currency, Decimal>) -> bool {
362 let residuals = calculate_residual(transaction);
363
364 for (currency, residual) in residuals {
365 let tolerance = tolerances.get(¤cy).copied().unwrap_or(Decimal::ZERO); if residual.abs() > tolerance {
368 return false;
369 }
370 }
371
372 true
373}
374
375pub fn normalize_prices(txn: &mut Transaction) {
384 use rustledger_core::{PriceAnnotation, PriceKind};
385
386 for posting in &mut txn.postings {
387 if let (Some(IncompleteAmount::Complete(units)), Some(price)) =
388 (&posting.units, &posting.price)
389 && price.kind == PriceKind::Total
390 {
391 let normalized = match price.amount.as_ref().and_then(IncompleteAmount::as_amount) {
392 Some(total_amount) if !units.number.is_zero() => {
393 let per_unit = total_amount.number / units.number.abs();
394 Some(PriceAnnotation::unit(Amount::new(
395 per_unit,
396 &total_amount.currency,
397 )))
398 }
399 Some(_) => None, None => {
401 if price.amount.is_none() {
405 Some(PriceAnnotation::unit_empty())
406 } else {
407 None
408 }
409 }
410 };
411 if let Some(normalized_price) = normalized {
412 posting.price = Some(normalized_price);
413 }
414 }
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::*;
421 use rust_decimal_macros::dec;
422 use rustledger_core::{CostSpec, IncompleteAmount, NaiveDate, Posting, PriceAnnotation};
423
424 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
425 rustledger_core::naive_date(year, month, day).unwrap()
426 }
427
428 #[test]
433 fn test_calculate_residual_balanced() {
434 let txn = Transaction::new(date(2024, 1, 15), "Test")
435 .with_synthesized_posting(Posting::new(
436 "Expenses:Food",
437 Amount::new(dec!(50.00), "USD"),
438 ))
439 .with_synthesized_posting(Posting::new(
440 "Assets:Cash",
441 Amount::new(dec!(-50.00), "USD"),
442 ));
443
444 let residual = calculate_residual(&txn);
445 assert_eq!(residual.get("USD"), Some(&dec!(0)));
446 }
447
448 #[test]
449 fn test_calculate_residual_unbalanced() {
450 let txn = Transaction::new(date(2024, 1, 15), "Test")
451 .with_synthesized_posting(Posting::new(
452 "Expenses:Food",
453 Amount::new(dec!(50.00), "USD"),
454 ))
455 .with_synthesized_posting(Posting::new(
456 "Assets:Cash",
457 Amount::new(dec!(-45.00), "USD"),
458 ));
459
460 let residual = calculate_residual(&txn);
461 assert_eq!(residual.get("USD"), Some(&dec!(5.00)));
462 }
463
464 #[test]
465 fn test_is_balanced() {
466 let txn = Transaction::new(date(2024, 1, 15), "Test")
467 .with_synthesized_posting(Posting::new(
468 "Expenses:Food",
469 Amount::new(dec!(50.00), "USD"),
470 ))
471 .with_synthesized_posting(Posting::new(
472 "Assets:Cash",
473 Amount::new(dec!(-50.00), "USD"),
474 ));
475
476 let tolerances = calculate_tolerance(&[
477 &Amount::new(dec!(50.00), "USD"),
478 &Amount::new(dec!(-50.00), "USD"),
479 ]);
480
481 assert!(is_balanced(&txn, &tolerances));
482 }
483
484 #[test]
485 fn test_is_balanced_within_tolerance() {
486 let txn = Transaction::new(date(2024, 1, 15), "Test")
487 .with_synthesized_posting(Posting::new(
488 "Expenses:Food",
489 Amount::new(dec!(50.004), "USD"),
490 ))
491 .with_synthesized_posting(Posting::new(
492 "Assets:Cash",
493 Amount::new(dec!(-50.00), "USD"),
494 ));
495
496 let tolerances = calculate_tolerance(&[
497 &Amount::new(dec!(50.004), "USD"),
498 &Amount::new(dec!(-50.00), "USD"),
499 ]);
500
501 assert!(is_balanced(&txn, &tolerances));
503 }
504
505 #[test]
506 fn test_is_balanced_detects_imbalance() {
507 let txn = Transaction::new(date(2024, 1, 15), "Test")
512 .with_synthesized_posting(Posting::new(
513 "Expenses:Food",
514 Amount::new(dec!(50.00), "USD"),
515 ))
516 .with_synthesized_posting(Posting::new(
517 "Assets:Cash",
518 Amount::new(dec!(-49.00), "USD"),
519 ));
520 let mut tolerances = HashMap::new();
522 tolerances.insert(Currency::from("USD"), Decimal::ZERO);
523 assert!(
524 !is_balanced(&txn, &tolerances),
525 "a 1.00 USD residual with zero tolerance must be detected as unbalanced"
526 );
527 }
528
529 #[test]
530 fn test_is_balanced_at_exact_tolerance_boundary() {
531 let txn = Transaction::new(date(2024, 1, 15), "Test")
536 .with_synthesized_posting(Posting::new(
537 "Expenses:Food",
538 Amount::new(dec!(50.01), "USD"),
539 ))
540 .with_synthesized_posting(Posting::new(
541 "Assets:Cash",
542 Amount::new(dec!(-50.00), "USD"),
543 ));
544 let mut tolerances = HashMap::new();
546 tolerances.insert(Currency::from("USD"), dec!(0.01));
547 assert!(
548 is_balanced(&txn, &tolerances),
549 "a residual exactly at the tolerance must be treated as balanced"
550 );
551 }
552
553 #[test]
554 fn test_calculate_tolerance() {
555 let amounts = [
556 Amount::new(dec!(100), "USD"), Amount::new(dec!(50.00), "USD"), Amount::new(dec!(25.000), "EUR"), ];
560
561 let refs: Vec<&Amount> = amounts.iter().collect();
562 let tolerances = calculate_tolerance(&refs);
563
564 assert_eq!(tolerances.get("USD"), Some(&dec!(0.5)));
566 assert_eq!(tolerances.get("EUR"), Some(&dec!(0.0005)));
567 }
568
569 #[test]
576 fn test_calculate_residual_with_per_unit_cost() {
577 let txn = Transaction::new(date(2024, 1, 15), "Buy stock")
578 .with_synthesized_posting(
579 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
580 CostSpec::empty()
581 .with_number(rustledger_core::CostNumber::PerUnit {
582 value: dec!(150.00),
583 })
584 .with_currency("USD"),
585 ),
586 )
587 .with_synthesized_posting(Posting::new(
588 "Assets:Cash",
589 Amount::new(dec!(-1500.00), "USD"),
590 ));
591
592 let residual = calculate_residual(&txn);
593 assert_eq!(residual.get("USD"), Some(&dec!(0)));
597 assert_eq!(residual.get("AAPL"), None);
599 }
600
601 #[test]
604 fn test_calculate_residual_with_total_cost() {
605 let txn = Transaction::new(date(2024, 1, 15), "Buy stock")
606 .with_synthesized_posting(
607 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
608 CostSpec::empty()
609 .with_number(rustledger_core::CostNumber::Total {
610 value: dec!(1500.00),
611 })
612 .with_currency("USD"),
613 ),
614 )
615 .with_synthesized_posting(Posting::new(
616 "Assets:Cash",
617 Amount::new(dec!(-1500.00), "USD"),
618 ));
619
620 let residual = calculate_residual(&txn);
621 assert_eq!(residual.get("USD"), Some(&dec!(0)));
624 }
625
626 #[test]
628 fn test_calculate_residual_with_total_cost_negative_units() {
629 let txn = Transaction::new(date(2024, 1, 15), "Sell stock")
630 .with_synthesized_posting(
631 Posting::new("Assets:Stock", Amount::new(dec!(-10), "AAPL")).with_cost(
632 CostSpec::empty()
633 .with_number(rustledger_core::CostNumber::Total {
634 value: dec!(1500.00),
635 })
636 .with_currency("USD"),
637 ),
638 )
639 .with_synthesized_posting(Posting::new(
640 "Assets:Cash",
641 Amount::new(dec!(1500.00), "USD"),
642 ));
643
644 let residual = calculate_residual(&txn);
645 assert_eq!(residual.get("USD"), Some(&dec!(0)));
648 }
649
650 #[test]
652 fn test_calculate_residual_cost_without_amount_skips() {
653 let txn = Transaction::new(date(2024, 1, 15), "Test")
657 .with_synthesized_posting(
658 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
659 .with_cost(CostSpec::empty()), )
661 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-10), "AAPL")));
662
663 let residual = calculate_residual(&txn);
664 assert_eq!(residual.get("AAPL"), Some(&dec!(-10)));
666 }
667
668 #[test]
680 fn test_calculate_residual_empty_cost_spec_with_price_skips_not_uses_price() {
681 let txn = Transaction::new(date(2024, 1, 15), "Sale, empty cost + price")
682 .with_synthesized_posting(
683 Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
684 .with_cost(CostSpec::empty())
685 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
686 dec!(150),
687 "USD",
688 ))),
689 )
690 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")));
691
692 let residual = calculate_residual(&txn);
693 assert_eq!(residual.get("USD"), Some(&dec!(1500)));
699 }
700
701 #[test]
704 fn test_calculate_residual_precise_empty_cost_spec_with_price_skips_not_uses_price() {
705 use bigdecimal::BigDecimal;
706 use std::str::FromStr;
707
708 let txn = Transaction::new(date(2024, 1, 15), "Sale, empty cost + price")
709 .with_synthesized_posting(
710 Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
711 .with_cost(CostSpec::empty())
712 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
713 dec!(150),
714 "USD",
715 ))),
716 )
717 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")));
718
719 let residual = calculate_residual_precise(&txn);
720 assert_eq!(
721 residual.get("USD"),
722 Some(&BigDecimal::from_str("1500").unwrap())
723 );
724 }
725
726 #[test]
733 fn test_calculate_residual_with_unit_price() {
734 let txn = Transaction::new(date(2024, 1, 15), "Currency exchange")
735 .with_synthesized_posting(
736 Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD"))
737 .with_price(PriceAnnotation::unit(Amount::new(dec!(0.85), "EUR"))),
738 )
739 .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
740
741 let residual = calculate_residual(&txn);
742 assert_eq!(residual.get("EUR"), Some(&dec!(0)));
746 assert_eq!(residual.get("USD"), None);
748 }
749
750 #[test]
752 fn test_calculate_residual_with_total_price() {
753 let txn = Transaction::new(date(2024, 1, 15), "Currency exchange")
754 .with_synthesized_posting(
755 Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD"))
756 .with_price(PriceAnnotation::total(Amount::new(dec!(85.00), "EUR"))),
757 )
758 .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
759
760 let residual = calculate_residual(&txn);
761 assert_eq!(residual.get("EUR"), Some(&dec!(0)));
764 }
765
766 #[test]
768 fn test_calculate_residual_with_unit_price_positive() {
769 let txn = Transaction::new(date(2024, 1, 15), "Buy EUR")
770 .with_synthesized_posting(
771 Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR"))
772 .with_price(PriceAnnotation::unit(Amount::new(dec!(1.18), "USD"))),
773 )
774 .with_synthesized_posting(Posting::new(
775 "Assets:USD",
776 Amount::new(dec!(-100.30), "USD"),
777 ));
778
779 let residual = calculate_residual(&txn);
780 assert_eq!(residual.get("USD"), Some(&dec!(0)));
783 }
784
785 #[test]
787 fn test_calculate_residual_unit_incomplete_with_amount() {
788 let txn = Transaction::new(date(2024, 1, 15), "Exchange")
789 .with_synthesized_posting(
790 Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD")).with_price(
791 PriceAnnotation::unit_incomplete(IncompleteAmount::Complete(Amount::new(
792 dec!(0.85),
793 "EUR",
794 ))),
795 ),
796 )
797 .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
798
799 let residual = calculate_residual(&txn);
800 assert_eq!(residual.get("EUR"), Some(&dec!(0)));
801 }
802
803 #[test]
805 fn test_calculate_residual_total_incomplete_with_amount() {
806 let txn = Transaction::new(date(2024, 1, 15), "Exchange")
807 .with_synthesized_posting(
808 Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD")).with_price(
809 PriceAnnotation::total_incomplete(IncompleteAmount::Complete(Amount::new(
810 dec!(85.00),
811 "EUR",
812 ))),
813 ),
814 )
815 .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
816
817 let residual = calculate_residual(&txn);
818 assert_eq!(residual.get("EUR"), Some(&dec!(0)));
819 }
820
821 #[test]
823 fn test_calculate_residual_unit_incomplete_no_amount_fallback() {
824 let txn = Transaction::new(date(2024, 1, 15), "Test")
825 .with_synthesized_posting(
826 Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD")).with_price(
827 PriceAnnotation::unit_incomplete(IncompleteAmount::NumberOnly(dec!(0.85))),
828 ),
829 )
830 .with_synthesized_posting(Posting::new(
831 "Assets:USD",
832 Amount::new(dec!(-100.00), "USD"),
833 ));
834
835 let residual = calculate_residual(&txn);
836 assert_eq!(residual.get("USD"), Some(&dec!(0)));
838 }
839
840 #[test]
842 fn test_calculate_residual_total_incomplete_no_amount_fallback() {
843 let txn = Transaction::new(date(2024, 1, 15), "Test")
844 .with_synthesized_posting(
845 Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD")).with_price(
846 PriceAnnotation::total_incomplete(IncompleteAmount::NumberOnly(dec!(85.00))),
847 ),
848 )
849 .with_synthesized_posting(Posting::new(
850 "Assets:USD",
851 Amount::new(dec!(-100.00), "USD"),
852 ));
853
854 let residual = calculate_residual(&txn);
855 assert_eq!(residual.get("USD"), Some(&dec!(0)));
856 }
857
858 #[test]
860 fn test_calculate_residual_unit_empty_fallback() {
861 let txn = Transaction::new(date(2024, 1, 15), "Test")
862 .with_synthesized_posting(
863 Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD"))
864 .with_price(PriceAnnotation::unit_empty()),
865 )
866 .with_synthesized_posting(Posting::new(
867 "Assets:USD",
868 Amount::new(dec!(-100.00), "USD"),
869 ));
870
871 let residual = calculate_residual(&txn);
872 assert_eq!(residual.get("USD"), Some(&dec!(0)));
874 }
875
876 #[test]
878 fn test_calculate_residual_total_empty_fallback() {
879 let txn = Transaction::new(date(2024, 1, 15), "Test")
880 .with_synthesized_posting(
881 Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD"))
882 .with_price(PriceAnnotation::total_empty()),
883 )
884 .with_synthesized_posting(Posting::new(
885 "Assets:USD",
886 Amount::new(dec!(-100.00), "USD"),
887 ));
888
889 let residual = calculate_residual(&txn);
890 assert_eq!(residual.get("USD"), Some(&dec!(0)));
891 }
892
893 #[test]
899 fn test_calculate_residual_mixed_cost_and_simple() {
900 let txn = Transaction::new(date(2024, 1, 15), "Buy with fee")
901 .with_synthesized_posting(
902 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
903 CostSpec::empty()
904 .with_number(rustledger_core::CostNumber::PerUnit {
905 value: dec!(150.00),
906 })
907 .with_currency("USD"),
908 ),
909 )
910 .with_synthesized_posting(Posting::new(
911 "Expenses:Fees",
912 Amount::new(dec!(10.00), "USD"),
913 ))
914 .with_synthesized_posting(Posting::new(
915 "Assets:Cash",
916 Amount::new(dec!(-1510.00), "USD"),
917 ));
918
919 let residual = calculate_residual(&txn);
920 assert_eq!(residual.get("USD"), Some(&dec!(0)));
922 }
923
924 #[test]
926 fn test_calculate_residual_sell_with_gains() {
927 let txn = Transaction::new(date(2024, 6, 15), "Sell stock")
928 .with_synthesized_posting(
929 Posting::new("Assets:Stock", Amount::new(dec!(-10), "AAPL"))
930 .with_cost(
931 CostSpec::empty()
932 .with_number(rustledger_core::CostNumber::PerUnit {
933 value: dec!(150.00),
934 })
935 .with_currency("USD"),
936 )
937 .with_price(PriceAnnotation::unit(Amount::new(dec!(175.00), "USD"))),
938 )
939 .with_synthesized_posting(Posting::new(
940 "Assets:Cash",
941 Amount::new(dec!(1750.00), "USD"),
942 ))
943 .with_synthesized_posting(Posting::new(
944 "Income:CapitalGains",
945 Amount::new(dec!(-250.00), "USD"),
946 ));
947
948 let residual = calculate_residual(&txn);
949 assert_eq!(residual.get("USD"), Some(&dec!(0)));
954 }
955
956 #[test]
958 fn test_calculate_residual_multi_currency_with_cost() {
959 let txn = Transaction::new(date(2024, 1, 15), "Multi-currency")
960 .with_synthesized_posting(
961 Posting::new("Assets:Stock:US", Amount::new(dec!(10), "AAPL")).with_cost(
962 CostSpec::empty()
963 .with_number(rustledger_core::CostNumber::PerUnit {
964 value: dec!(150.00),
965 })
966 .with_currency("USD"),
967 ),
968 )
969 .with_synthesized_posting(
970 Posting::new("Assets:Stock:EU", Amount::new(dec!(5), "SAP")).with_cost(
971 CostSpec::empty()
972 .with_number(rustledger_core::CostNumber::PerUnit {
973 value: dec!(100.00),
974 })
975 .with_currency("EUR"),
976 ),
977 )
978 .with_synthesized_posting(Posting::new(
979 "Assets:Cash:USD",
980 Amount::new(dec!(-1500.00), "USD"),
981 ))
982 .with_synthesized_posting(Posting::new(
983 "Assets:Cash:EUR",
984 Amount::new(dec!(-500.00), "EUR"),
985 ));
986
987 let residual = calculate_residual(&txn);
988 assert_eq!(residual.get("USD"), Some(&dec!(0)));
989 assert_eq!(residual.get("EUR"), Some(&dec!(0)));
990 }
991
992 #[test]
994 fn test_calculate_residual_skips_incomplete_units() {
995 let txn = Transaction::new(date(2024, 1, 15), "Test")
996 .with_synthesized_posting(Posting::new(
997 "Expenses:Food",
998 Amount::new(dec!(50.00), "USD"),
999 ))
1000 .with_synthesized_posting(Posting::auto("Assets:Cash")); let residual = calculate_residual(&txn);
1003 assert_eq!(residual.get("USD"), Some(&dec!(50.00)));
1005 }
1006
1007 #[test]
1014 fn test_calculate_residual_infers_cost_currency_from_other_posting() {
1015 let txn = Transaction::new(date(2026, 1, 1), "Opening balance")
1021 .with_synthesized_posting(
1022 Posting::new(
1023 "Assets:Vanguard:IRA:Trad:VFIFX",
1024 Amount::new(dec!(10), "VFIFX"),
1025 )
1026 .with_cost(
1027 CostSpec::empty()
1028 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) }),
1029 ),
1030 )
1031 .with_synthesized_posting(Posting::new(
1032 "Equity:Opening-Balances",
1033 Amount::new(dec!(-1000), "USD"),
1034 ));
1035
1036 let residual = calculate_residual(&txn);
1037 assert_eq!(
1041 residual.get("USD"),
1042 Some(&dec!(0)),
1043 "Should balance when cost currency is inferred from other posting"
1044 );
1045 assert_eq!(residual.get("VFIFX"), None);
1047 }
1048
1049 #[test]
1051 fn test_calculate_residual_infers_cost_currency_total_cost() {
1052 let txn = Transaction::new(date(2026, 1, 1), "Test")
1054 .with_synthesized_posting(
1055 Posting::new("Assets:Stock", Amount::new(dec!(10), "VFIFX")).with_cost(
1056 CostSpec::empty()
1057 .with_number(rustledger_core::CostNumber::Total { value: dec!(1000) }),
1058 ),
1059 )
1060 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1000), "USD")));
1061
1062 let residual = calculate_residual(&txn);
1063 assert_eq!(residual.get("USD"), Some(&dec!(0)));
1064 }
1065
1066 #[test]
1068 fn test_calculate_residual_explicit_cost_currency_takes_precedence() {
1069 let txn = Transaction::new(date(2026, 1, 1), "Test")
1071 .with_synthesized_posting(
1072 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1073 CostSpec::empty()
1074 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) })
1075 .with_currency("EUR"), ),
1077 )
1078 .with_synthesized_posting(Posting::new(
1079 "Assets:Cash",
1080 Amount::new(dec!(-1000), "USD"), ));
1082
1083 let residual = calculate_residual(&txn);
1084 assert_eq!(residual.get("EUR"), Some(&dec!(1000)));
1086 assert_eq!(residual.get("USD"), Some(&dec!(-1000)));
1087 }
1088
1089 #[test]
1091 fn test_calculate_residual_price_annotation_takes_precedence() {
1092 let txn = Transaction::new(date(2026, 1, 1), "Test")
1094 .with_synthesized_posting(
1095 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1096 .with_cost(
1097 CostSpec::empty()
1098 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) }),
1099 )
1100 .with_price(PriceAnnotation::unit(Amount::new(dec!(105), "EUR"))),
1101 )
1102 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1000), "USD")));
1103
1104 let residual = calculate_residual(&txn);
1105 assert_eq!(residual.get("EUR"), Some(&dec!(1000)));
1107 assert_eq!(residual.get("USD"), Some(&dec!(-1000)));
1108 }
1109
1110 #[test]
1116 fn test_infer_cost_currency_from_cost_spec() {
1117 let txn = Transaction::new(date(2022, 4, 16), "Free tokens")
1119 .with_synthesized_posting(
1120 Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
1121 CostSpec::empty()
1122 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
1123 .with_currency("USD"),
1124 ),
1125 )
1126 .with_synthesized_posting(Posting::auto("Income:Bonus"));
1127
1128 let inferred = infer_cost_currency_from_postings(&txn);
1129 assert_eq!(inferred.as_deref(), Some("USD"));
1130 }
1131
1132 #[test]
1134 fn test_infer_cost_currency_simple_takes_precedence() {
1135 let txn = Transaction::new(date(2022, 4, 16), "Trade")
1137 .with_synthesized_posting(
1138 Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
1139 CostSpec::empty()
1140 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(10) })
1141 .with_currency("EUR"),
1142 ),
1143 )
1144 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1000), "USD")));
1145
1146 let inferred = infer_cost_currency_from_postings(&txn);
1147 assert_eq!(inferred.as_deref(), Some("USD"));
1149 }
1150
1151 #[test]
1153 fn test_infer_cost_currency_zero_cost() {
1154 let txn = Transaction::new(date(2022, 4, 16), "Airdrop")
1156 .with_synthesized_posting(
1157 Posting::new("Assets:Crypto", Amount::new(dec!(1000), "SHIB")).with_cost(
1158 CostSpec::empty()
1159 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
1160 .with_currency("JPY"),
1161 ),
1162 )
1163 .with_synthesized_posting(Posting::auto("Income:Airdrop"));
1164
1165 let inferred = infer_cost_currency_from_postings(&txn);
1166 assert_eq!(inferred.as_deref(), Some("JPY"));
1167 }
1168}