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 None => return None, };
210 let cost_curr = cost_currency_of(posting, infer_currency)?;
211 Some((cost_curr, weight))
212}
213
214fn residual_weight<D: WeightNum>(transaction: &Transaction) -> FxHashMap<Currency, D> {
222 let mut residuals: FxHashMap<Currency, D> =
224 FxHashMap::with_capacity_and_hasher(transaction.postings.len().min(4), Default::default());
225
226 let mut inferred_cost_currency: Option<Option<Currency>> = None;
228 let get_inferred_currency = |cache: &mut Option<Option<Currency>>| -> Option<Currency> {
229 cache
230 .get_or_insert_with(|| infer_cost_currency_from_postings(transaction))
231 .clone()
232 };
233
234 for posting in &transaction.postings {
235 let Some(IncompleteAmount::Complete(units)) = &posting.units else {
237 continue;
238 };
239 let signum = units.number.signum();
240
241 let cost_contribution = cost_weight::<D>(posting, units, || {
243 get_inferred_currency(&mut inferred_cost_currency)
244 });
245
246 if let Some((currency, amount)) = cost_contribution {
247 *residuals.entry(currency).or_default() += amount;
249 } else if posting.cost.is_some() {
250 } else if let Some(price) = &posting.price {
260 if let Some(amt) = price.amount.as_ref().and_then(IncompleteAmount::as_amount) {
262 let signed = match price.kind {
268 rustledger_core::PriceKind::Unit => {
269 D::from_decimal(units.number.abs())
270 * D::from_decimal(amt.number)
271 * D::from_decimal(signum)
272 }
273 rustledger_core::PriceKind::Total => {
274 D::from_decimal(amt.number) * D::from_decimal(signum)
275 }
276 };
277 *residuals.entry(amt.currency.clone()).or_default() += signed;
278 } else {
279 *residuals.entry(units.currency.clone()).or_default() +=
282 D::from_decimal(units.number);
283 }
284 } else {
285 *residuals.entry(units.currency.clone()).or_default() += D::from_decimal(units.number);
287 }
288 }
289
290 residuals
291}
292
293#[must_use]
307#[allow(clippy::implicit_hasher)]
310pub fn calculate_residual(transaction: &Transaction) -> FxHashMap<Currency, Decimal> {
311 residual_weight::<Decimal>(transaction)
312}
313
314fn to_big(d: Decimal) -> BigDecimal {
320 use std::str::FromStr;
321 BigDecimal::from_str(&d.to_string()).expect("Decimal always produces valid decimal string")
323}
324
325#[must_use]
331#[allow(clippy::implicit_hasher)]
332pub fn calculate_residual_precise(transaction: &Transaction) -> FxHashMap<Currency, BigDecimal> {
333 residual_weight::<BigDecimal>(transaction)
334}
335
336#[must_use]
338#[allow(clippy::implicit_hasher)]
339pub fn is_balanced(transaction: &Transaction, tolerances: &FxHashMap<Currency, Decimal>) -> bool {
340 let residuals = calculate_residual(transaction);
341
342 for (currency, residual) in residuals {
343 let tolerance = tolerances.get(¤cy).copied().unwrap_or(Decimal::ZERO); if residual.abs() > tolerance {
346 return false;
347 }
348 }
349
350 true
351}
352
353pub fn normalize_prices(txn: &mut Transaction) {
362 use rustledger_core::{PriceAnnotation, PriceKind};
363
364 for posting in &mut txn.postings {
365 if let (Some(IncompleteAmount::Complete(units)), Some(price)) =
366 (&posting.units, &posting.price)
367 && price.kind == PriceKind::Total
368 {
369 let normalized = match price.amount.as_ref().and_then(IncompleteAmount::as_amount) {
370 Some(total_amount) if !units.number.is_zero() => {
371 let per_unit = total_amount.number / units.number.abs();
372 Some(PriceAnnotation::unit(Amount::new(
373 per_unit,
374 &total_amount.currency,
375 )))
376 }
377 Some(_) => None, None => {
379 if price.amount.is_none() {
383 Some(PriceAnnotation::unit_empty())
384 } else {
385 None
386 }
387 }
388 };
389 if let Some(normalized_price) = normalized {
390 posting.price = Some(normalized_price);
391 }
392 }
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use rust_decimal_macros::dec;
400 use rustledger_core::{CostSpec, IncompleteAmount, NaiveDate, Posting, PriceAnnotation};
401
402 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
403 rustledger_core::naive_date(year, month, day).unwrap()
404 }
405
406 #[test]
411 fn test_calculate_residual_balanced() {
412 let txn = Transaction::new(date(2024, 1, 15), "Test")
413 .with_synthesized_posting(Posting::new(
414 "Expenses:Food",
415 Amount::new(dec!(50.00), "USD"),
416 ))
417 .with_synthesized_posting(Posting::new(
418 "Assets:Cash",
419 Amount::new(dec!(-50.00), "USD"),
420 ));
421
422 let residual = calculate_residual(&txn);
423 assert_eq!(residual.get("USD"), Some(&dec!(0)));
424 }
425
426 #[test]
427 fn test_calculate_residual_unbalanced() {
428 let txn = Transaction::new(date(2024, 1, 15), "Test")
429 .with_synthesized_posting(Posting::new(
430 "Expenses:Food",
431 Amount::new(dec!(50.00), "USD"),
432 ))
433 .with_synthesized_posting(Posting::new(
434 "Assets:Cash",
435 Amount::new(dec!(-45.00), "USD"),
436 ));
437
438 let residual = calculate_residual(&txn);
439 assert_eq!(residual.get("USD"), Some(&dec!(5.00)));
440 }
441
442 #[test]
443 fn test_is_balanced() {
444 let txn = Transaction::new(date(2024, 1, 15), "Test")
445 .with_synthesized_posting(Posting::new(
446 "Expenses:Food",
447 Amount::new(dec!(50.00), "USD"),
448 ))
449 .with_synthesized_posting(Posting::new(
450 "Assets:Cash",
451 Amount::new(dec!(-50.00), "USD"),
452 ));
453
454 let tolerances = calculate_tolerance(&[
455 &Amount::new(dec!(50.00), "USD"),
456 &Amount::new(dec!(-50.00), "USD"),
457 ]);
458
459 assert!(is_balanced(&txn, &tolerances));
460 }
461
462 #[test]
463 fn test_is_balanced_within_tolerance() {
464 let txn = Transaction::new(date(2024, 1, 15), "Test")
465 .with_synthesized_posting(Posting::new(
466 "Expenses:Food",
467 Amount::new(dec!(50.004), "USD"),
468 ))
469 .with_synthesized_posting(Posting::new(
470 "Assets:Cash",
471 Amount::new(dec!(-50.00), "USD"),
472 ));
473
474 let tolerances = calculate_tolerance(&[
475 &Amount::new(dec!(50.004), "USD"),
476 &Amount::new(dec!(-50.00), "USD"),
477 ]);
478
479 assert!(is_balanced(&txn, &tolerances));
481 }
482
483 #[test]
484 fn test_is_balanced_detects_imbalance() {
485 let txn = Transaction::new(date(2024, 1, 15), "Test")
490 .with_synthesized_posting(Posting::new(
491 "Expenses:Food",
492 Amount::new(dec!(50.00), "USD"),
493 ))
494 .with_synthesized_posting(Posting::new(
495 "Assets:Cash",
496 Amount::new(dec!(-49.00), "USD"),
497 ));
498 let mut tolerances = FxHashMap::default();
500 tolerances.insert(Currency::from("USD"), Decimal::ZERO);
501 assert!(
502 !is_balanced(&txn, &tolerances),
503 "a 1.00 USD residual with zero tolerance must be detected as unbalanced"
504 );
505 }
506
507 #[test]
508 fn test_is_balanced_at_exact_tolerance_boundary() {
509 let txn = Transaction::new(date(2024, 1, 15), "Test")
514 .with_synthesized_posting(Posting::new(
515 "Expenses:Food",
516 Amount::new(dec!(50.01), "USD"),
517 ))
518 .with_synthesized_posting(Posting::new(
519 "Assets:Cash",
520 Amount::new(dec!(-50.00), "USD"),
521 ));
522 let mut tolerances = FxHashMap::default();
524 tolerances.insert(Currency::from("USD"), dec!(0.01));
525 assert!(
526 is_balanced(&txn, &tolerances),
527 "a residual exactly at the tolerance must be treated as balanced"
528 );
529 }
530
531 #[test]
532 fn test_calculate_tolerance() {
533 let amounts = [
534 Amount::new(dec!(100), "USD"), Amount::new(dec!(50.00), "USD"), Amount::new(dec!(25.000), "EUR"), ];
538
539 let refs: Vec<&Amount> = amounts.iter().collect();
540 let tolerances = calculate_tolerance(&refs);
541
542 assert_eq!(tolerances.get("USD"), Some(&dec!(0.5)));
544 assert_eq!(tolerances.get("EUR"), Some(&dec!(0.0005)));
545 }
546
547 #[test]
554 fn test_calculate_residual_with_per_unit_cost() {
555 let txn = Transaction::new(date(2024, 1, 15), "Buy stock")
556 .with_synthesized_posting(
557 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
558 CostSpec::empty()
559 .with_number(rustledger_core::CostNumber::PerUnit {
560 value: dec!(150.00),
561 })
562 .with_currency("USD"),
563 ),
564 )
565 .with_synthesized_posting(Posting::new(
566 "Assets:Cash",
567 Amount::new(dec!(-1500.00), "USD"),
568 ));
569
570 let residual = calculate_residual(&txn);
571 assert_eq!(residual.get("USD"), Some(&dec!(0)));
575 assert_eq!(residual.get("AAPL"), None);
577 }
578
579 #[test]
585 fn fast_and_precise_residual_agree_across_weight_arms() {
586 use std::str::FromStr;
587
588 let txn = Transaction::new(date(2024, 1, 15), "Every weight arm")
589 .with_synthesized_posting(
591 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
592 CostSpec::empty()
593 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150.00) })
594 .with_currency("USD"),
595 ),
596 )
597 .with_synthesized_posting(
599 Posting::new("Assets:Bond", Amount::new(dec!(-3), "BOND")).with_cost(
600 CostSpec::empty()
601 .with_number(rustledger_core::CostNumber::Total { value: dec!(450.00) })
602 .with_currency("USD"),
603 ),
604 )
605 .with_synthesized_posting(
607 Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD"))
608 .with_price(PriceAnnotation::unit(Amount::new(dec!(0.85), "EUR"))),
609 )
610 .with_synthesized_posting(
612 Posting::new("Assets:GBP", Amount::new(dec!(20.00), "GBP"))
613 .with_price(PriceAnnotation::total(Amount::new(dec!(26.00), "EUR"))),
614 )
615 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-12.34), "USD")));
617
618 let fast = calculate_residual(&txn);
619 let precise = calculate_residual_precise(&txn);
620
621 assert_eq!(
622 fast.len(),
623 precise.len(),
624 "fast {fast:?} and precise {precise:?} cover different currency sets"
625 );
626 for (currency, fval) in &fast {
627 let pval = precise.get(currency).expect("currency present in precise");
628 let pval_as_dec = Decimal::from_str(&pval.to_string()).unwrap();
631 assert_eq!(
632 *fval, pval_as_dec,
633 "fast and precise residual disagree for {currency}: {fval} vs {pval}"
634 );
635 }
636 }
637
638 #[test]
641 fn test_calculate_residual_with_total_cost() {
642 let txn = Transaction::new(date(2024, 1, 15), "Buy stock")
643 .with_synthesized_posting(
644 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
645 CostSpec::empty()
646 .with_number(rustledger_core::CostNumber::Total {
647 value: dec!(1500.00),
648 })
649 .with_currency("USD"),
650 ),
651 )
652 .with_synthesized_posting(Posting::new(
653 "Assets:Cash",
654 Amount::new(dec!(-1500.00), "USD"),
655 ));
656
657 let residual = calculate_residual(&txn);
658 assert_eq!(residual.get("USD"), Some(&dec!(0)));
661 }
662
663 #[test]
665 fn test_calculate_residual_with_total_cost_negative_units() {
666 let txn = Transaction::new(date(2024, 1, 15), "Sell stock")
667 .with_synthesized_posting(
668 Posting::new("Assets:Stock", Amount::new(dec!(-10), "AAPL")).with_cost(
669 CostSpec::empty()
670 .with_number(rustledger_core::CostNumber::Total {
671 value: dec!(1500.00),
672 })
673 .with_currency("USD"),
674 ),
675 )
676 .with_synthesized_posting(Posting::new(
677 "Assets:Cash",
678 Amount::new(dec!(1500.00), "USD"),
679 ));
680
681 let residual = calculate_residual(&txn);
682 assert_eq!(residual.get("USD"), Some(&dec!(0)));
685 }
686
687 #[test]
689 fn test_calculate_residual_cost_without_amount_skips() {
690 let txn = Transaction::new(date(2024, 1, 15), "Test")
694 .with_synthesized_posting(
695 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
696 .with_cost(CostSpec::empty()), )
698 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-10), "AAPL")));
699
700 let residual = calculate_residual(&txn);
701 assert_eq!(residual.get("AAPL"), Some(&dec!(-10)));
703 }
704
705 #[test]
717 fn test_calculate_residual_empty_cost_spec_with_price_skips_not_uses_price() {
718 let txn = Transaction::new(date(2024, 1, 15), "Sale, empty cost + price")
719 .with_synthesized_posting(
720 Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
721 .with_cost(CostSpec::empty())
722 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
723 dec!(150),
724 "USD",
725 ))),
726 )
727 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")));
728
729 let residual = calculate_residual(&txn);
730 assert_eq!(residual.get("USD"), Some(&dec!(1500)));
736 }
737
738 #[test]
741 fn test_calculate_residual_precise_empty_cost_spec_with_price_skips_not_uses_price() {
742 use bigdecimal::BigDecimal;
743 use std::str::FromStr;
744
745 let txn = Transaction::new(date(2024, 1, 15), "Sale, empty cost + price")
746 .with_synthesized_posting(
747 Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
748 .with_cost(CostSpec::empty())
749 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
750 dec!(150),
751 "USD",
752 ))),
753 )
754 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")));
755
756 let residual = calculate_residual_precise(&txn);
757 assert_eq!(
758 residual.get("USD"),
759 Some(&BigDecimal::from_str("1500").unwrap())
760 );
761 }
762
763 #[test]
770 fn test_calculate_residual_with_unit_price() {
771 let txn = Transaction::new(date(2024, 1, 15), "Currency exchange")
772 .with_synthesized_posting(
773 Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD"))
774 .with_price(PriceAnnotation::unit(Amount::new(dec!(0.85), "EUR"))),
775 )
776 .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
777
778 let residual = calculate_residual(&txn);
779 assert_eq!(residual.get("EUR"), Some(&dec!(0)));
783 assert_eq!(residual.get("USD"), None);
785 }
786
787 #[test]
789 fn test_calculate_residual_with_total_price() {
790 let txn = Transaction::new(date(2024, 1, 15), "Currency exchange")
791 .with_synthesized_posting(
792 Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD"))
793 .with_price(PriceAnnotation::total(Amount::new(dec!(85.00), "EUR"))),
794 )
795 .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
796
797 let residual = calculate_residual(&txn);
798 assert_eq!(residual.get("EUR"), Some(&dec!(0)));
801 }
802
803 #[test]
805 fn test_calculate_residual_with_unit_price_positive() {
806 let txn = Transaction::new(date(2024, 1, 15), "Buy EUR")
807 .with_synthesized_posting(
808 Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR"))
809 .with_price(PriceAnnotation::unit(Amount::new(dec!(1.18), "USD"))),
810 )
811 .with_synthesized_posting(Posting::new(
812 "Assets:USD",
813 Amount::new(dec!(-100.30), "USD"),
814 ));
815
816 let residual = calculate_residual(&txn);
817 assert_eq!(residual.get("USD"), Some(&dec!(0)));
820 }
821
822 #[test]
824 fn test_calculate_residual_unit_incomplete_with_amount() {
825 let txn = Transaction::new(date(2024, 1, 15), "Exchange")
826 .with_synthesized_posting(
827 Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD")).with_price(
828 PriceAnnotation::unit_incomplete(IncompleteAmount::Complete(Amount::new(
829 dec!(0.85),
830 "EUR",
831 ))),
832 ),
833 )
834 .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
835
836 let residual = calculate_residual(&txn);
837 assert_eq!(residual.get("EUR"), Some(&dec!(0)));
838 }
839
840 #[test]
842 fn test_calculate_residual_total_incomplete_with_amount() {
843 let txn = Transaction::new(date(2024, 1, 15), "Exchange")
844 .with_synthesized_posting(
845 Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD")).with_price(
846 PriceAnnotation::total_incomplete(IncompleteAmount::Complete(Amount::new(
847 dec!(85.00),
848 "EUR",
849 ))),
850 ),
851 )
852 .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
853
854 let residual = calculate_residual(&txn);
855 assert_eq!(residual.get("EUR"), Some(&dec!(0)));
856 }
857
858 #[test]
860 fn test_calculate_residual_unit_incomplete_no_amount_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")).with_price(
864 PriceAnnotation::unit_incomplete(IncompleteAmount::NumberOnly(dec!(0.85))),
865 ),
866 )
867 .with_synthesized_posting(Posting::new(
868 "Assets:USD",
869 Amount::new(dec!(-100.00), "USD"),
870 ));
871
872 let residual = calculate_residual(&txn);
873 assert_eq!(residual.get("USD"), Some(&dec!(0)));
875 }
876
877 #[test]
879 fn test_calculate_residual_total_incomplete_no_amount_fallback() {
880 let txn = Transaction::new(date(2024, 1, 15), "Test")
881 .with_synthesized_posting(
882 Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD")).with_price(
883 PriceAnnotation::total_incomplete(IncompleteAmount::NumberOnly(dec!(85.00))),
884 ),
885 )
886 .with_synthesized_posting(Posting::new(
887 "Assets:USD",
888 Amount::new(dec!(-100.00), "USD"),
889 ));
890
891 let residual = calculate_residual(&txn);
892 assert_eq!(residual.get("USD"), Some(&dec!(0)));
893 }
894
895 #[test]
897 fn test_calculate_residual_unit_empty_fallback() {
898 let txn = Transaction::new(date(2024, 1, 15), "Test")
899 .with_synthesized_posting(
900 Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD"))
901 .with_price(PriceAnnotation::unit_empty()),
902 )
903 .with_synthesized_posting(Posting::new(
904 "Assets:USD",
905 Amount::new(dec!(-100.00), "USD"),
906 ));
907
908 let residual = calculate_residual(&txn);
909 assert_eq!(residual.get("USD"), Some(&dec!(0)));
911 }
912
913 #[test]
915 fn test_calculate_residual_total_empty_fallback() {
916 let txn = Transaction::new(date(2024, 1, 15), "Test")
917 .with_synthesized_posting(
918 Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD"))
919 .with_price(PriceAnnotation::total_empty()),
920 )
921 .with_synthesized_posting(Posting::new(
922 "Assets:USD",
923 Amount::new(dec!(-100.00), "USD"),
924 ));
925
926 let residual = calculate_residual(&txn);
927 assert_eq!(residual.get("USD"), Some(&dec!(0)));
928 }
929
930 #[test]
936 fn test_calculate_residual_mixed_cost_and_simple() {
937 let txn = Transaction::new(date(2024, 1, 15), "Buy with fee")
938 .with_synthesized_posting(
939 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
940 CostSpec::empty()
941 .with_number(rustledger_core::CostNumber::PerUnit {
942 value: dec!(150.00),
943 })
944 .with_currency("USD"),
945 ),
946 )
947 .with_synthesized_posting(Posting::new(
948 "Expenses:Fees",
949 Amount::new(dec!(10.00), "USD"),
950 ))
951 .with_synthesized_posting(Posting::new(
952 "Assets:Cash",
953 Amount::new(dec!(-1510.00), "USD"),
954 ));
955
956 let residual = calculate_residual(&txn);
957 assert_eq!(residual.get("USD"), Some(&dec!(0)));
959 }
960
961 #[test]
963 fn test_calculate_residual_sell_with_gains() {
964 let txn = Transaction::new(date(2024, 6, 15), "Sell stock")
965 .with_synthesized_posting(
966 Posting::new("Assets:Stock", Amount::new(dec!(-10), "AAPL"))
967 .with_cost(
968 CostSpec::empty()
969 .with_number(rustledger_core::CostNumber::PerUnit {
970 value: dec!(150.00),
971 })
972 .with_currency("USD"),
973 )
974 .with_price(PriceAnnotation::unit(Amount::new(dec!(175.00), "USD"))),
975 )
976 .with_synthesized_posting(Posting::new(
977 "Assets:Cash",
978 Amount::new(dec!(1750.00), "USD"),
979 ))
980 .with_synthesized_posting(Posting::new(
981 "Income:CapitalGains",
982 Amount::new(dec!(-250.00), "USD"),
983 ));
984
985 let residual = calculate_residual(&txn);
986 assert_eq!(residual.get("USD"), Some(&dec!(0)));
991 }
992
993 #[test]
995 fn test_calculate_residual_multi_currency_with_cost() {
996 let txn = Transaction::new(date(2024, 1, 15), "Multi-currency")
997 .with_synthesized_posting(
998 Posting::new("Assets:Stock:US", Amount::new(dec!(10), "AAPL")).with_cost(
999 CostSpec::empty()
1000 .with_number(rustledger_core::CostNumber::PerUnit {
1001 value: dec!(150.00),
1002 })
1003 .with_currency("USD"),
1004 ),
1005 )
1006 .with_synthesized_posting(
1007 Posting::new("Assets:Stock:EU", Amount::new(dec!(5), "SAP")).with_cost(
1008 CostSpec::empty()
1009 .with_number(rustledger_core::CostNumber::PerUnit {
1010 value: dec!(100.00),
1011 })
1012 .with_currency("EUR"),
1013 ),
1014 )
1015 .with_synthesized_posting(Posting::new(
1016 "Assets:Cash:USD",
1017 Amount::new(dec!(-1500.00), "USD"),
1018 ))
1019 .with_synthesized_posting(Posting::new(
1020 "Assets:Cash:EUR",
1021 Amount::new(dec!(-500.00), "EUR"),
1022 ));
1023
1024 let residual = calculate_residual(&txn);
1025 assert_eq!(residual.get("USD"), Some(&dec!(0)));
1026 assert_eq!(residual.get("EUR"), Some(&dec!(0)));
1027 }
1028
1029 #[test]
1031 fn test_calculate_residual_skips_incomplete_units() {
1032 let txn = Transaction::new(date(2024, 1, 15), "Test")
1033 .with_synthesized_posting(Posting::new(
1034 "Expenses:Food",
1035 Amount::new(dec!(50.00), "USD"),
1036 ))
1037 .with_synthesized_posting(Posting::auto("Assets:Cash")); let residual = calculate_residual(&txn);
1040 assert_eq!(residual.get("USD"), Some(&dec!(50.00)));
1042 }
1043
1044 #[test]
1051 fn test_calculate_residual_infers_cost_currency_from_other_posting() {
1052 let txn = Transaction::new(date(2026, 1, 1), "Opening balance")
1058 .with_synthesized_posting(
1059 Posting::new(
1060 "Assets:Vanguard:IRA:Trad:VFIFX",
1061 Amount::new(dec!(10), "VFIFX"),
1062 )
1063 .with_cost(
1064 CostSpec::empty()
1065 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) }),
1066 ),
1067 )
1068 .with_synthesized_posting(Posting::new(
1069 "Equity:Opening-Balances",
1070 Amount::new(dec!(-1000), "USD"),
1071 ));
1072
1073 let residual = calculate_residual(&txn);
1074 assert_eq!(
1078 residual.get("USD"),
1079 Some(&dec!(0)),
1080 "Should balance when cost currency is inferred from other posting"
1081 );
1082 assert_eq!(residual.get("VFIFX"), None);
1084 }
1085
1086 #[test]
1088 fn test_calculate_residual_infers_cost_currency_total_cost() {
1089 let txn = Transaction::new(date(2026, 1, 1), "Test")
1091 .with_synthesized_posting(
1092 Posting::new("Assets:Stock", Amount::new(dec!(10), "VFIFX")).with_cost(
1093 CostSpec::empty()
1094 .with_number(rustledger_core::CostNumber::Total { value: dec!(1000) }),
1095 ),
1096 )
1097 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1000), "USD")));
1098
1099 let residual = calculate_residual(&txn);
1100 assert_eq!(residual.get("USD"), Some(&dec!(0)));
1101 }
1102
1103 #[test]
1105 fn test_calculate_residual_explicit_cost_currency_takes_precedence() {
1106 let txn = Transaction::new(date(2026, 1, 1), "Test")
1108 .with_synthesized_posting(
1109 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1110 CostSpec::empty()
1111 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) })
1112 .with_currency("EUR"), ),
1114 )
1115 .with_synthesized_posting(Posting::new(
1116 "Assets:Cash",
1117 Amount::new(dec!(-1000), "USD"), ));
1119
1120 let residual = calculate_residual(&txn);
1121 assert_eq!(residual.get("EUR"), Some(&dec!(1000)));
1123 assert_eq!(residual.get("USD"), Some(&dec!(-1000)));
1124 }
1125
1126 #[test]
1128 fn test_calculate_residual_price_annotation_takes_precedence() {
1129 let txn = Transaction::new(date(2026, 1, 1), "Test")
1131 .with_synthesized_posting(
1132 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1133 .with_cost(
1134 CostSpec::empty()
1135 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) }),
1136 )
1137 .with_price(PriceAnnotation::unit(Amount::new(dec!(105), "EUR"))),
1138 )
1139 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1000), "USD")));
1140
1141 let residual = calculate_residual(&txn);
1142 assert_eq!(residual.get("EUR"), Some(&dec!(1000)));
1144 assert_eq!(residual.get("USD"), Some(&dec!(-1000)));
1145 }
1146
1147 #[test]
1153 fn test_infer_cost_currency_from_cost_spec() {
1154 let txn = Transaction::new(date(2022, 4, 16), "Free tokens")
1156 .with_synthesized_posting(
1157 Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
1158 CostSpec::empty()
1159 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
1160 .with_currency("USD"),
1161 ),
1162 )
1163 .with_synthesized_posting(Posting::auto("Income:Bonus"));
1164
1165 let inferred = infer_cost_currency_from_postings(&txn);
1166 assert_eq!(inferred.as_deref(), Some("USD"));
1167 }
1168
1169 #[test]
1171 fn test_infer_cost_currency_simple_takes_precedence() {
1172 let txn = Transaction::new(date(2022, 4, 16), "Trade")
1174 .with_synthesized_posting(
1175 Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
1176 CostSpec::empty()
1177 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(10) })
1178 .with_currency("EUR"),
1179 ),
1180 )
1181 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1000), "USD")));
1182
1183 let inferred = infer_cost_currency_from_postings(&txn);
1184 assert_eq!(inferred.as_deref(), Some("USD"));
1186 }
1187
1188 #[test]
1190 fn test_infer_cost_currency_zero_cost() {
1191 let txn = Transaction::new(date(2022, 4, 16), "Airdrop")
1193 .with_synthesized_posting(
1194 Posting::new("Assets:Crypto", Amount::new(dec!(1000), "SHIB")).with_cost(
1195 CostSpec::empty()
1196 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
1197 .with_currency("JPY"),
1198 ),
1199 )
1200 .with_synthesized_posting(Posting::auto("Income:Airdrop"));
1201
1202 let inferred = infer_cost_currency_from_postings(&txn);
1203 assert_eq!(inferred.as_deref(), Some("JPY"));
1204 }
1205}