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 merge_with_padding_spanned, process_pads,
37};
38
39use bigdecimal::BigDecimal;
40use rust_decimal::Decimal;
41use rust_decimal::prelude::Signed;
42use rustc_hash::FxHashMap;
43use rustledger_core::{Amount, Currency, IncompleteAmount, Transaction};
44
45#[must_use]
49pub fn calculate_tolerance(amounts: &[&Amount]) -> FxHashMap<Currency, Decimal> {
50 let mut tolerances: FxHashMap<Currency, Decimal> =
52 FxHashMap::with_capacity_and_hasher(amounts.len().min(4), Default::default());
53
54 for amount in amounts {
55 let tol = amount.inferred_tolerance();
56 tolerances
57 .entry(amount.currency.clone())
58 .and_modify(|t| *t = (*t).max(tol))
59 .or_insert(tol);
60 }
61
62 tolerances
63}
64
65#[must_use]
73pub(crate) fn price_currency_of(posting: &rustledger_core::Posting) -> Option<Currency> {
74 posting
75 .price
76 .as_ref()
77 .and_then(|p| p.amount.as_ref())
78 .and_then(IncompleteAmount::as_amount)
79 .map(|a| a.currency.clone())
80}
81
82#[must_use]
93pub(crate) fn infer_cost_currency_from_postings(transaction: &Transaction) -> Option<Currency> {
94 for posting in &transaction.postings {
96 if posting.cost.is_some() {
98 continue;
99 }
100
101 if let Some(units) = &posting.units {
103 match units {
104 IncompleteAmount::Complete(amount) => {
105 if let Some(c) = price_currency_of(posting) {
108 return Some(c);
109 }
110 return Some(amount.currency.clone());
112 }
113 IncompleteAmount::CurrencyOnly(currency) => {
114 return Some(currency.clone());
115 }
116 IncompleteAmount::NumberOnly(_) => {}
117 }
118 }
119 }
120
121 for posting in &transaction.postings {
124 if let Some(cost) = &posting.cost
125 && let Some(currency) = &cost.currency
126 {
127 return Some(currency.clone());
128 }
129 }
130
131 None
132}
133
134trait WeightNum: Clone + Default + std::ops::AddAssign + std::ops::Mul<Output = Self> {
146 fn from_decimal(d: Decimal) -> Self;
147}
148
149impl WeightNum for Decimal {
150 fn from_decimal(d: Decimal) -> Self {
151 d
152 }
153}
154
155impl WeightNum for BigDecimal {
156 fn from_decimal(d: Decimal) -> Self {
157 to_big(d)
158 }
159}
160
161#[must_use]
166pub(crate) fn cost_currency_of(
167 posting: &rustledger_core::Posting,
168 infer_currency: impl FnOnce() -> Option<Currency>,
169) -> Option<Currency> {
170 let cost_spec = posting.cost.as_ref()?;
171 cost_spec
172 .currency
173 .clone()
174 .or_else(|| price_currency_of(posting))
175 .or_else(infer_currency)
176}
177
178fn cost_weight<D: WeightNum>(
187 posting: &rustledger_core::Posting,
188 units: &Amount,
189 infer_currency: impl FnOnce() -> Option<Currency>,
190) -> Option<(Currency, D)> {
191 let cost_spec = posting.cost.as_ref()?;
192 let signum = units.number.signum();
193 let weight = match cost_spec.number {
199 Some(rustledger_core::CostNumber::Total { value: total }) => {
200 D::from_decimal(total) * D::from_decimal(signum)
201 }
202 Some(rustledger_core::CostNumber::PerUnitFromTotal(b)) => {
203 D::from_decimal(b.total) * D::from_decimal(signum)
204 }
205 Some(rustledger_core::CostNumber::PerUnit { value: per_unit }) => {
206 D::from_decimal(units.number) * D::from_decimal(per_unit)
207 }
208 Some(rustledger_core::CostNumber::Compound { per_unit, total }) => {
212 let mut w = D::from_decimal(units.number) * D::from_decimal(per_unit);
213 w += D::from_decimal(total) * D::from_decimal(signum);
214 w
215 }
216 None => return None, };
218 let cost_curr = cost_currency_of(posting, infer_currency)?;
219 Some((cost_curr, weight))
220}
221
222fn residual_weight<D: WeightNum>(transaction: &Transaction) -> FxHashMap<Currency, D> {
230 let mut residuals: FxHashMap<Currency, D> =
232 FxHashMap::with_capacity_and_hasher(transaction.postings.len().min(4), Default::default());
233
234 let mut inferred_cost_currency: Option<Option<Currency>> = None;
236 let get_inferred_currency = |cache: &mut Option<Option<Currency>>| -> Option<Currency> {
237 cache
238 .get_or_insert_with(|| infer_cost_currency_from_postings(transaction))
239 .clone()
240 };
241
242 for posting in &transaction.postings {
243 let Some(IncompleteAmount::Complete(units)) = &posting.units else {
245 continue;
246 };
247 let signum = units.number.signum();
248
249 let cost_contribution = cost_weight::<D>(posting, units, || {
251 get_inferred_currency(&mut inferred_cost_currency)
252 });
253
254 if let Some((currency, amount)) = cost_contribution {
255 *residuals.entry(currency).or_default() += amount;
257 } else if posting.cost.is_some() {
258 } else if let Some(price) = &posting.price {
268 if let Some(amt) = price.amount.as_ref().and_then(IncompleteAmount::as_amount) {
270 let signed = match price.kind {
276 rustledger_core::PriceKind::Unit => {
277 D::from_decimal(units.number.abs())
278 * D::from_decimal(amt.number)
279 * D::from_decimal(signum)
280 }
281 rustledger_core::PriceKind::Total => {
282 D::from_decimal(amt.number) * D::from_decimal(signum)
283 }
284 };
285 *residuals.entry(amt.currency.clone()).or_default() += signed;
286 } else {
287 *residuals.entry(units.currency.clone()).or_default() +=
290 D::from_decimal(units.number);
291 }
292 } else {
293 *residuals.entry(units.currency.clone()).or_default() += D::from_decimal(units.number);
295 }
296 }
297
298 residuals
299}
300
301#[must_use]
315#[allow(clippy::implicit_hasher)]
318pub fn calculate_residual(transaction: &Transaction) -> FxHashMap<Currency, Decimal> {
319 residual_weight::<Decimal>(transaction)
320}
321
322fn to_big(d: Decimal) -> BigDecimal {
328 use std::str::FromStr;
329 BigDecimal::from_str(&d.to_string()).expect("Decimal always produces valid decimal string")
331}
332
333#[must_use]
339#[allow(clippy::implicit_hasher)]
340pub fn calculate_residual_precise(transaction: &Transaction) -> FxHashMap<Currency, BigDecimal> {
341 residual_weight::<BigDecimal>(transaction)
342}
343
344#[must_use]
346#[allow(clippy::implicit_hasher)]
347pub fn is_balanced(transaction: &Transaction, tolerances: &FxHashMap<Currency, Decimal>) -> bool {
348 let residuals = calculate_residual(transaction);
349
350 for (currency, residual) in residuals {
351 let tolerance = tolerances.get(¤cy).copied().unwrap_or(Decimal::ZERO); if residual.abs() > tolerance {
354 return false;
355 }
356 }
357
358 true
359}
360
361pub fn normalize_prices(txn: &mut Transaction) {
370 use rustledger_core::{PriceAnnotation, PriceKind};
371
372 for posting in &mut txn.postings {
373 if let (Some(IncompleteAmount::Complete(units)), Some(price)) =
374 (&posting.units, &posting.price)
375 && price.kind == PriceKind::Total
376 {
377 let normalized = match price.amount.as_ref().and_then(IncompleteAmount::as_amount) {
378 Some(total_amount) if !units.number.is_zero() => {
379 let per_unit = total_amount.number / units.number.abs();
380 Some(PriceAnnotation::unit(Amount::new(
381 per_unit,
382 &total_amount.currency,
383 )))
384 }
385 Some(_) => None, None => {
387 if price.amount.is_none() {
391 Some(PriceAnnotation::unit_empty())
392 } else {
393 None
394 }
395 }
396 };
397 if let Some(normalized_price) = normalized {
398 posting.price = Some(normalized_price);
399 }
400 }
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use rust_decimal_macros::dec;
408 use rustledger_core::{CostSpec, IncompleteAmount, NaiveDate, Posting, PriceAnnotation};
409
410 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
411 rustledger_core::naive_date(year, month, day).unwrap()
412 }
413
414 #[test]
419 fn test_calculate_residual_balanced() {
420 let txn = Transaction::new(date(2024, 1, 15), "Test")
421 .with_synthesized_posting(Posting::new(
422 "Expenses:Food",
423 Amount::new(dec!(50.00), "USD"),
424 ))
425 .with_synthesized_posting(Posting::new(
426 "Assets:Cash",
427 Amount::new(dec!(-50.00), "USD"),
428 ));
429
430 let residual = calculate_residual(&txn);
431 assert_eq!(residual.get("USD"), Some(&dec!(0)));
432 }
433
434 #[test]
435 fn test_calculate_residual_unbalanced() {
436 let txn = Transaction::new(date(2024, 1, 15), "Test")
437 .with_synthesized_posting(Posting::new(
438 "Expenses:Food",
439 Amount::new(dec!(50.00), "USD"),
440 ))
441 .with_synthesized_posting(Posting::new(
442 "Assets:Cash",
443 Amount::new(dec!(-45.00), "USD"),
444 ));
445
446 let residual = calculate_residual(&txn);
447 assert_eq!(residual.get("USD"), Some(&dec!(5.00)));
448 }
449
450 #[test]
451 fn test_is_balanced() {
452 let txn = Transaction::new(date(2024, 1, 15), "Test")
453 .with_synthesized_posting(Posting::new(
454 "Expenses:Food",
455 Amount::new(dec!(50.00), "USD"),
456 ))
457 .with_synthesized_posting(Posting::new(
458 "Assets:Cash",
459 Amount::new(dec!(-50.00), "USD"),
460 ));
461
462 let tolerances = calculate_tolerance(&[
463 &Amount::new(dec!(50.00), "USD"),
464 &Amount::new(dec!(-50.00), "USD"),
465 ]);
466
467 assert!(is_balanced(&txn, &tolerances));
468 }
469
470 #[test]
471 fn test_is_balanced_within_tolerance() {
472 let txn = Transaction::new(date(2024, 1, 15), "Test")
473 .with_synthesized_posting(Posting::new(
474 "Expenses:Food",
475 Amount::new(dec!(50.004), "USD"),
476 ))
477 .with_synthesized_posting(Posting::new(
478 "Assets:Cash",
479 Amount::new(dec!(-50.00), "USD"),
480 ));
481
482 let tolerances = calculate_tolerance(&[
483 &Amount::new(dec!(50.004), "USD"),
484 &Amount::new(dec!(-50.00), "USD"),
485 ]);
486
487 assert!(is_balanced(&txn, &tolerances));
489 }
490
491 #[test]
492 fn test_is_balanced_detects_imbalance() {
493 let txn = Transaction::new(date(2024, 1, 15), "Test")
498 .with_synthesized_posting(Posting::new(
499 "Expenses:Food",
500 Amount::new(dec!(50.00), "USD"),
501 ))
502 .with_synthesized_posting(Posting::new(
503 "Assets:Cash",
504 Amount::new(dec!(-49.00), "USD"),
505 ));
506 let mut tolerances = FxHashMap::default();
508 tolerances.insert(Currency::from("USD"), Decimal::ZERO);
509 assert!(
510 !is_balanced(&txn, &tolerances),
511 "a 1.00 USD residual with zero tolerance must be detected as unbalanced"
512 );
513 }
514
515 #[test]
516 fn test_is_balanced_at_exact_tolerance_boundary() {
517 let txn = Transaction::new(date(2024, 1, 15), "Test")
522 .with_synthesized_posting(Posting::new(
523 "Expenses:Food",
524 Amount::new(dec!(50.01), "USD"),
525 ))
526 .with_synthesized_posting(Posting::new(
527 "Assets:Cash",
528 Amount::new(dec!(-50.00), "USD"),
529 ));
530 let mut tolerances = FxHashMap::default();
532 tolerances.insert(Currency::from("USD"), dec!(0.01));
533 assert!(
534 is_balanced(&txn, &tolerances),
535 "a residual exactly at the tolerance must be treated as balanced"
536 );
537 }
538
539 #[test]
540 fn test_calculate_tolerance() {
541 let amounts = [
542 Amount::new(dec!(100), "USD"), Amount::new(dec!(50.00), "USD"), Amount::new(dec!(25.000), "EUR"), ];
546
547 let refs: Vec<&Amount> = amounts.iter().collect();
548 let tolerances = calculate_tolerance(&refs);
549
550 assert_eq!(tolerances.get("USD"), Some(&dec!(0.5)));
552 assert_eq!(tolerances.get("EUR"), Some(&dec!(0.0005)));
553 }
554
555 #[test]
562 fn test_calculate_residual_with_per_unit_cost() {
563 let txn = Transaction::new(date(2024, 1, 15), "Buy stock")
564 .with_synthesized_posting(
565 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
566 CostSpec::empty()
567 .with_number(rustledger_core::CostNumber::PerUnit {
568 value: dec!(150.00),
569 })
570 .with_currency("USD"),
571 ),
572 )
573 .with_synthesized_posting(Posting::new(
574 "Assets:Cash",
575 Amount::new(dec!(-1500.00), "USD"),
576 ));
577
578 let residual = calculate_residual(&txn);
579 assert_eq!(residual.get("USD"), Some(&dec!(0)));
583 assert_eq!(residual.get("AAPL"), None);
585 }
586
587 #[test]
593 fn fast_and_precise_residual_agree_across_weight_arms() {
594 use std::str::FromStr;
595
596 let txn = Transaction::new(date(2024, 1, 15), "Every weight arm")
597 .with_synthesized_posting(
599 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
600 CostSpec::empty()
601 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150.00) })
602 .with_currency("USD"),
603 ),
604 )
605 .with_synthesized_posting(
607 Posting::new("Assets:Bond", Amount::new(dec!(-3), "BOND")).with_cost(
608 CostSpec::empty()
609 .with_number(rustledger_core::CostNumber::Total { value: dec!(450.00) })
610 .with_currency("USD"),
611 ),
612 )
613 .with_synthesized_posting(
615 Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD"))
616 .with_price(PriceAnnotation::unit(Amount::new(dec!(0.85), "EUR"))),
617 )
618 .with_synthesized_posting(
620 Posting::new("Assets:GBP", Amount::new(dec!(20.00), "GBP"))
621 .with_price(PriceAnnotation::total(Amount::new(dec!(26.00), "EUR"))),
622 )
623 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-12.34), "USD")));
625
626 let fast = calculate_residual(&txn);
627 let precise = calculate_residual_precise(&txn);
628
629 assert_eq!(
630 fast.len(),
631 precise.len(),
632 "fast {fast:?} and precise {precise:?} cover different currency sets"
633 );
634 for (currency, fval) in &fast {
635 let pval = precise.get(currency).expect("currency present in precise");
636 let pval_as_dec = Decimal::from_str(&pval.to_string()).unwrap();
639 assert_eq!(
640 *fval, pval_as_dec,
641 "fast and precise residual disagree for {currency}: {fval} vs {pval}"
642 );
643 }
644 }
645
646 #[test]
649 fn test_calculate_residual_with_total_cost() {
650 let txn = Transaction::new(date(2024, 1, 15), "Buy stock")
651 .with_synthesized_posting(
652 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
653 CostSpec::empty()
654 .with_number(rustledger_core::CostNumber::Total {
655 value: dec!(1500.00),
656 })
657 .with_currency("USD"),
658 ),
659 )
660 .with_synthesized_posting(Posting::new(
661 "Assets:Cash",
662 Amount::new(dec!(-1500.00), "USD"),
663 ));
664
665 let residual = calculate_residual(&txn);
666 assert_eq!(residual.get("USD"), Some(&dec!(0)));
669 }
670
671 #[test]
673 fn test_calculate_residual_with_total_cost_negative_units() {
674 let txn = Transaction::new(date(2024, 1, 15), "Sell stock")
675 .with_synthesized_posting(
676 Posting::new("Assets:Stock", Amount::new(dec!(-10), "AAPL")).with_cost(
677 CostSpec::empty()
678 .with_number(rustledger_core::CostNumber::Total {
679 value: dec!(1500.00),
680 })
681 .with_currency("USD"),
682 ),
683 )
684 .with_synthesized_posting(Posting::new(
685 "Assets:Cash",
686 Amount::new(dec!(1500.00), "USD"),
687 ));
688
689 let residual = calculate_residual(&txn);
690 assert_eq!(residual.get("USD"), Some(&dec!(0)));
693 }
694
695 #[test]
697 fn test_calculate_residual_cost_without_amount_skips() {
698 let txn = Transaction::new(date(2024, 1, 15), "Test")
702 .with_synthesized_posting(
703 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
704 .with_cost(CostSpec::empty()), )
706 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-10), "AAPL")));
707
708 let residual = calculate_residual(&txn);
709 assert_eq!(residual.get("AAPL"), Some(&dec!(-10)));
711 }
712
713 #[test]
725 fn test_calculate_residual_empty_cost_spec_with_price_skips_not_uses_price() {
726 let txn = Transaction::new(date(2024, 1, 15), "Sale, empty cost + price")
727 .with_synthesized_posting(
728 Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
729 .with_cost(CostSpec::empty())
730 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
731 dec!(150),
732 "USD",
733 ))),
734 )
735 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")));
736
737 let residual = calculate_residual(&txn);
738 assert_eq!(residual.get("USD"), Some(&dec!(1500)));
744 }
745
746 #[test]
749 fn test_calculate_residual_precise_empty_cost_spec_with_price_skips_not_uses_price() {
750 use bigdecimal::BigDecimal;
751 use std::str::FromStr;
752
753 let txn = Transaction::new(date(2024, 1, 15), "Sale, empty cost + price")
754 .with_synthesized_posting(
755 Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
756 .with_cost(CostSpec::empty())
757 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
758 dec!(150),
759 "USD",
760 ))),
761 )
762 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")));
763
764 let residual = calculate_residual_precise(&txn);
765 assert_eq!(
766 residual.get("USD"),
767 Some(&BigDecimal::from_str("1500").unwrap())
768 );
769 }
770
771 #[test]
778 fn test_calculate_residual_with_unit_price() {
779 let txn = Transaction::new(date(2024, 1, 15), "Currency exchange")
780 .with_synthesized_posting(
781 Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD"))
782 .with_price(PriceAnnotation::unit(Amount::new(dec!(0.85), "EUR"))),
783 )
784 .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
785
786 let residual = calculate_residual(&txn);
787 assert_eq!(residual.get("EUR"), Some(&dec!(0)));
791 assert_eq!(residual.get("USD"), None);
793 }
794
795 #[test]
797 fn test_calculate_residual_with_total_price() {
798 let txn = Transaction::new(date(2024, 1, 15), "Currency exchange")
799 .with_synthesized_posting(
800 Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD"))
801 .with_price(PriceAnnotation::total(Amount::new(dec!(85.00), "EUR"))),
802 )
803 .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
804
805 let residual = calculate_residual(&txn);
806 assert_eq!(residual.get("EUR"), Some(&dec!(0)));
809 }
810
811 #[test]
813 fn test_calculate_residual_with_unit_price_positive() {
814 let txn = Transaction::new(date(2024, 1, 15), "Buy EUR")
815 .with_synthesized_posting(
816 Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR"))
817 .with_price(PriceAnnotation::unit(Amount::new(dec!(1.18), "USD"))),
818 )
819 .with_synthesized_posting(Posting::new(
820 "Assets:USD",
821 Amount::new(dec!(-100.30), "USD"),
822 ));
823
824 let residual = calculate_residual(&txn);
825 assert_eq!(residual.get("USD"), Some(&dec!(0)));
828 }
829
830 #[test]
832 fn test_calculate_residual_unit_incomplete_with_amount() {
833 let txn = Transaction::new(date(2024, 1, 15), "Exchange")
834 .with_synthesized_posting(
835 Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD")).with_price(
836 PriceAnnotation::unit_incomplete(IncompleteAmount::Complete(Amount::new(
837 dec!(0.85),
838 "EUR",
839 ))),
840 ),
841 )
842 .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
843
844 let residual = calculate_residual(&txn);
845 assert_eq!(residual.get("EUR"), Some(&dec!(0)));
846 }
847
848 #[test]
850 fn test_calculate_residual_total_incomplete_with_amount() {
851 let txn = Transaction::new(date(2024, 1, 15), "Exchange")
852 .with_synthesized_posting(
853 Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD")).with_price(
854 PriceAnnotation::total_incomplete(IncompleteAmount::Complete(Amount::new(
855 dec!(85.00),
856 "EUR",
857 ))),
858 ),
859 )
860 .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
861
862 let residual = calculate_residual(&txn);
863 assert_eq!(residual.get("EUR"), Some(&dec!(0)));
864 }
865
866 #[test]
868 fn test_calculate_residual_unit_incomplete_no_amount_fallback() {
869 let txn = Transaction::new(date(2024, 1, 15), "Test")
870 .with_synthesized_posting(
871 Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD")).with_price(
872 PriceAnnotation::unit_incomplete(IncompleteAmount::NumberOnly(dec!(0.85))),
873 ),
874 )
875 .with_synthesized_posting(Posting::new(
876 "Assets:USD",
877 Amount::new(dec!(-100.00), "USD"),
878 ));
879
880 let residual = calculate_residual(&txn);
881 assert_eq!(residual.get("USD"), Some(&dec!(0)));
883 }
884
885 #[test]
887 fn test_calculate_residual_total_incomplete_no_amount_fallback() {
888 let txn = Transaction::new(date(2024, 1, 15), "Test")
889 .with_synthesized_posting(
890 Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD")).with_price(
891 PriceAnnotation::total_incomplete(IncompleteAmount::NumberOnly(dec!(85.00))),
892 ),
893 )
894 .with_synthesized_posting(Posting::new(
895 "Assets:USD",
896 Amount::new(dec!(-100.00), "USD"),
897 ));
898
899 let residual = calculate_residual(&txn);
900 assert_eq!(residual.get("USD"), Some(&dec!(0)));
901 }
902
903 #[test]
905 fn test_calculate_residual_unit_empty_fallback() {
906 let txn = Transaction::new(date(2024, 1, 15), "Test")
907 .with_synthesized_posting(
908 Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD"))
909 .with_price(PriceAnnotation::unit_empty()),
910 )
911 .with_synthesized_posting(Posting::new(
912 "Assets:USD",
913 Amount::new(dec!(-100.00), "USD"),
914 ));
915
916 let residual = calculate_residual(&txn);
917 assert_eq!(residual.get("USD"), Some(&dec!(0)));
919 }
920
921 #[test]
923 fn test_calculate_residual_total_empty_fallback() {
924 let txn = Transaction::new(date(2024, 1, 15), "Test")
925 .with_synthesized_posting(
926 Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD"))
927 .with_price(PriceAnnotation::total_empty()),
928 )
929 .with_synthesized_posting(Posting::new(
930 "Assets:USD",
931 Amount::new(dec!(-100.00), "USD"),
932 ));
933
934 let residual = calculate_residual(&txn);
935 assert_eq!(residual.get("USD"), Some(&dec!(0)));
936 }
937
938 #[test]
944 fn test_calculate_residual_mixed_cost_and_simple() {
945 let txn = Transaction::new(date(2024, 1, 15), "Buy with fee")
946 .with_synthesized_posting(
947 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
948 CostSpec::empty()
949 .with_number(rustledger_core::CostNumber::PerUnit {
950 value: dec!(150.00),
951 })
952 .with_currency("USD"),
953 ),
954 )
955 .with_synthesized_posting(Posting::new(
956 "Expenses:Fees",
957 Amount::new(dec!(10.00), "USD"),
958 ))
959 .with_synthesized_posting(Posting::new(
960 "Assets:Cash",
961 Amount::new(dec!(-1510.00), "USD"),
962 ));
963
964 let residual = calculate_residual(&txn);
965 assert_eq!(residual.get("USD"), Some(&dec!(0)));
967 }
968
969 #[test]
971 fn test_calculate_residual_sell_with_gains() {
972 let txn = Transaction::new(date(2024, 6, 15), "Sell stock")
973 .with_synthesized_posting(
974 Posting::new("Assets:Stock", Amount::new(dec!(-10), "AAPL"))
975 .with_cost(
976 CostSpec::empty()
977 .with_number(rustledger_core::CostNumber::PerUnit {
978 value: dec!(150.00),
979 })
980 .with_currency("USD"),
981 )
982 .with_price(PriceAnnotation::unit(Amount::new(dec!(175.00), "USD"))),
983 )
984 .with_synthesized_posting(Posting::new(
985 "Assets:Cash",
986 Amount::new(dec!(1750.00), "USD"),
987 ))
988 .with_synthesized_posting(Posting::new(
989 "Income:CapitalGains",
990 Amount::new(dec!(-250.00), "USD"),
991 ));
992
993 let residual = calculate_residual(&txn);
994 assert_eq!(residual.get("USD"), Some(&dec!(0)));
999 }
1000
1001 #[test]
1003 fn test_calculate_residual_multi_currency_with_cost() {
1004 let txn = Transaction::new(date(2024, 1, 15), "Multi-currency")
1005 .with_synthesized_posting(
1006 Posting::new("Assets:Stock:US", Amount::new(dec!(10), "AAPL")).with_cost(
1007 CostSpec::empty()
1008 .with_number(rustledger_core::CostNumber::PerUnit {
1009 value: dec!(150.00),
1010 })
1011 .with_currency("USD"),
1012 ),
1013 )
1014 .with_synthesized_posting(
1015 Posting::new("Assets:Stock:EU", Amount::new(dec!(5), "SAP")).with_cost(
1016 CostSpec::empty()
1017 .with_number(rustledger_core::CostNumber::PerUnit {
1018 value: dec!(100.00),
1019 })
1020 .with_currency("EUR"),
1021 ),
1022 )
1023 .with_synthesized_posting(Posting::new(
1024 "Assets:Cash:USD",
1025 Amount::new(dec!(-1500.00), "USD"),
1026 ))
1027 .with_synthesized_posting(Posting::new(
1028 "Assets:Cash:EUR",
1029 Amount::new(dec!(-500.00), "EUR"),
1030 ));
1031
1032 let residual = calculate_residual(&txn);
1033 assert_eq!(residual.get("USD"), Some(&dec!(0)));
1034 assert_eq!(residual.get("EUR"), Some(&dec!(0)));
1035 }
1036
1037 #[test]
1039 fn test_calculate_residual_skips_incomplete_units() {
1040 let txn = Transaction::new(date(2024, 1, 15), "Test")
1041 .with_synthesized_posting(Posting::new(
1042 "Expenses:Food",
1043 Amount::new(dec!(50.00), "USD"),
1044 ))
1045 .with_synthesized_posting(Posting::auto("Assets:Cash")); let residual = calculate_residual(&txn);
1048 assert_eq!(residual.get("USD"), Some(&dec!(50.00)));
1050 }
1051
1052 #[test]
1059 fn test_calculate_residual_infers_cost_currency_from_other_posting() {
1060 let txn = Transaction::new(date(2026, 1, 1), "Opening balance")
1066 .with_synthesized_posting(
1067 Posting::new(
1068 "Assets:Vanguard:IRA:Trad:VFIFX",
1069 Amount::new(dec!(10), "VFIFX"),
1070 )
1071 .with_cost(
1072 CostSpec::empty()
1073 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) }),
1074 ),
1075 )
1076 .with_synthesized_posting(Posting::new(
1077 "Equity:Opening-Balances",
1078 Amount::new(dec!(-1000), "USD"),
1079 ));
1080
1081 let residual = calculate_residual(&txn);
1082 assert_eq!(
1086 residual.get("USD"),
1087 Some(&dec!(0)),
1088 "Should balance when cost currency is inferred from other posting"
1089 );
1090 assert_eq!(residual.get("VFIFX"), None);
1092 }
1093
1094 #[test]
1096 fn test_calculate_residual_infers_cost_currency_total_cost() {
1097 let txn = Transaction::new(date(2026, 1, 1), "Test")
1099 .with_synthesized_posting(
1100 Posting::new("Assets:Stock", Amount::new(dec!(10), "VFIFX")).with_cost(
1101 CostSpec::empty()
1102 .with_number(rustledger_core::CostNumber::Total { value: dec!(1000) }),
1103 ),
1104 )
1105 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1000), "USD")));
1106
1107 let residual = calculate_residual(&txn);
1108 assert_eq!(residual.get("USD"), Some(&dec!(0)));
1109 }
1110
1111 #[test]
1113 fn test_calculate_residual_explicit_cost_currency_takes_precedence() {
1114 let txn = Transaction::new(date(2026, 1, 1), "Test")
1116 .with_synthesized_posting(
1117 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1118 CostSpec::empty()
1119 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) })
1120 .with_currency("EUR"), ),
1122 )
1123 .with_synthesized_posting(Posting::new(
1124 "Assets:Cash",
1125 Amount::new(dec!(-1000), "USD"), ));
1127
1128 let residual = calculate_residual(&txn);
1129 assert_eq!(residual.get("EUR"), Some(&dec!(1000)));
1131 assert_eq!(residual.get("USD"), Some(&dec!(-1000)));
1132 }
1133
1134 #[test]
1136 fn test_calculate_residual_price_annotation_takes_precedence() {
1137 let txn = Transaction::new(date(2026, 1, 1), "Test")
1139 .with_synthesized_posting(
1140 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1141 .with_cost(
1142 CostSpec::empty()
1143 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) }),
1144 )
1145 .with_price(PriceAnnotation::unit(Amount::new(dec!(105), "EUR"))),
1146 )
1147 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1000), "USD")));
1148
1149 let residual = calculate_residual(&txn);
1150 assert_eq!(residual.get("EUR"), Some(&dec!(1000)));
1152 assert_eq!(residual.get("USD"), Some(&dec!(-1000)));
1153 }
1154
1155 #[test]
1161 fn test_infer_cost_currency_from_cost_spec() {
1162 let txn = Transaction::new(date(2022, 4, 16), "Free tokens")
1164 .with_synthesized_posting(
1165 Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
1166 CostSpec::empty()
1167 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
1168 .with_currency("USD"),
1169 ),
1170 )
1171 .with_synthesized_posting(Posting::auto("Income:Bonus"));
1172
1173 let inferred = infer_cost_currency_from_postings(&txn);
1174 assert_eq!(inferred.as_deref(), Some("USD"));
1175 }
1176
1177 #[test]
1179 fn test_infer_cost_currency_simple_takes_precedence() {
1180 let txn = Transaction::new(date(2022, 4, 16), "Trade")
1182 .with_synthesized_posting(
1183 Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
1184 CostSpec::empty()
1185 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(10) })
1186 .with_currency("EUR"),
1187 ),
1188 )
1189 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1000), "USD")));
1190
1191 let inferred = infer_cost_currency_from_postings(&txn);
1192 assert_eq!(inferred.as_deref(), Some("USD"));
1194 }
1195
1196 #[test]
1198 fn test_infer_cost_currency_zero_cost() {
1199 let txn = Transaction::new(date(2022, 4, 16), "Airdrop")
1201 .with_synthesized_posting(
1202 Posting::new("Assets:Crypto", Amount::new(dec!(1000), "SHIB")).with_cost(
1203 CostSpec::empty()
1204 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
1205 .with_currency("JPY"),
1206 ),
1207 )
1208 .with_synthesized_posting(Posting::auto("Income:Airdrop"));
1209
1210 let inferred = infer_cost_currency_from_postings(&txn);
1211 assert_eq!(inferred.as_deref(), Some("JPY"));
1212 }
1213}