1use rust_decimal::Decimal;
6use rust_decimal::prelude::Signed;
7use rustledger_core::{Amount, Currency, IncompleteAmount, Transaction};
8use std::collections::HashMap;
9use thiserror::Error;
10
11#[derive(Debug, Clone, Error)]
13pub enum InterpolationError {
14 #[error(
25 "multiple postings missing amounts or with unresolved cost specs for currency {currency} ({count} unknowns)"
26 )]
27 MultipleMissing {
28 currency: Currency,
30 count: usize,
34 },
35
36 #[error("cannot infer currency for posting to account {account}")]
38 CannotInferCurrency {
39 account: rustledger_core::Account,
41 },
42
43 #[error("transaction does not balance: residual {residual} {currency}")]
45 DoesNotBalance {
46 currency: Currency,
48 residual: Decimal,
50 },
51}
52
53#[derive(Debug, Clone)]
55pub struct InterpolationResult {
56 pub transaction: Transaction,
58 pub filled_indices: Vec<usize>,
60 pub residuals: HashMap<Currency, Decimal>,
62}
63
64fn round_interpolated(residual: Decimal, existing_scale: Option<u32>) -> Decimal {
67 let interpolated = -residual;
68 if let Some(scale) = existing_scale {
69 let rounded = interpolated.round_dp(scale);
70 if rounded.is_zero() && !residual.is_zero() {
72 interpolated
73 } else {
74 rounded
75 }
76 } else {
77 interpolated
78 }
79}
80
81pub fn interpolate(transaction: &Transaction) -> Result<InterpolationResult, InterpolationError> {
123 let mut result = transaction.clone();
125 let mut filled_indices = Vec::new();
126
127 let mut inferred_cost_currency: Option<Option<Currency>> = None;
129 let get_inferred_currency = |cache: &mut Option<Option<Currency>>| -> Option<Currency> {
130 cache
131 .get_or_insert_with(|| crate::infer_cost_currency_from_postings(transaction))
132 .clone()
133 };
134
135 let num_postings = transaction.postings.len();
138 let mut residuals: HashMap<Currency, Decimal> = HashMap::with_capacity(num_postings.min(4));
139 let mut missing_by_currency: HashMap<Currency, Vec<usize>> = HashMap::with_capacity(2);
140 let mut unassigned_missing: Vec<usize> = Vec::with_capacity(2);
141
142 let mut max_scale_by_currency: HashMap<Currency, u32> = HashMap::with_capacity(4);
170
171 let mut cost_unknowns_by_currency: HashMap<Currency, usize> = HashMap::with_capacity(2);
180
181 for (i, posting) in transaction.postings.iter().enumerate() {
182 match &posting.units {
183 Some(IncompleteAmount::Complete(amount)) => {
184 let scale = amount.number.scale();
189 if scale > 0 {
190 max_scale_by_currency
191 .entry(amount.currency.clone())
192 .and_modify(|s| *s = (*s).max(scale))
193 .or_insert(scale);
194 }
195
196 let cost_contribution = crate::cost_weight::<Decimal>(posting, amount, || {
202 get_inferred_currency(&mut inferred_cost_currency)
203 });
204
205 if let Some((currency, cost_amount)) = cost_contribution {
206 *residuals.entry(currency).or_default() += cost_amount;
212 } else if posting.cost.is_some() {
213 let cost_currency = crate::cost_currency_of(posting, || {
225 get_inferred_currency(&mut inferred_cost_currency)
226 });
227 if let Some(curr) = cost_currency {
228 *cost_unknowns_by_currency.entry(curr).or_default() += 1;
229 }
230 } else if let Some(price) = &posting.price {
231 if let Some(price_amt) =
238 price.amount.as_ref().and_then(IncompleteAmount::as_amount)
239 {
240 let (curr, signed) = match price.kind {
241 rustledger_core::PriceKind::Unit => (
242 price_amt.currency.clone(),
243 amount.number.abs() * price_amt.number * amount.number.signum(),
244 ),
245 rustledger_core::PriceKind::Total => {
246 let scale = price_amt.number.scale();
247 if scale > 0 {
248 max_scale_by_currency
249 .entry(price_amt.currency.clone())
250 .and_modify(|s| *s = (*s).max(scale))
251 .or_insert(scale);
252 }
253 (
254 price_amt.currency.clone(),
255 price_amt.number * amount.number.signum(),
256 )
257 }
258 };
259 *residuals.entry(curr).or_default() += signed;
260 } else {
261 *residuals.entry(amount.currency.clone()).or_default() += amount.number;
263 }
264 } else {
265 *residuals.entry(amount.currency.clone()).or_default() += amount.number;
267 }
268 }
269 Some(IncompleteAmount::CurrencyOnly(currency)) => {
270 missing_by_currency
272 .entry(currency.clone())
273 .or_default()
274 .push(i);
275 }
276 Some(IncompleteAmount::NumberOnly(number)) => {
277 let currency = posting
280 .cost
281 .as_ref()
282 .and_then(|c| c.currency.clone())
283 .or_else(|| {
284 posting
288 .price
289 .as_ref()
290 .and_then(|p| p.amount.as_ref())
291 .and_then(IncompleteAmount::as_amount)
292 .map(|a| a.currency.clone())
293 });
294
295 if let Some(curr) = currency {
296 *residuals.entry(curr.clone()).or_default() += *number;
298 } else {
299 unassigned_missing.push(i);
301 }
302 }
303 None => {
304 if let Some(cost_spec) = &posting.cost
306 && let Some(currency) = &cost_spec.currency
307 {
308 missing_by_currency
309 .entry(currency.clone())
310 .or_default()
311 .push(i);
312 continue;
313 }
314 unassigned_missing.push(i);
316 }
317 }
318 }
319
320 let mut currencies_with_unknowns: Vec<&Currency> = missing_by_currency
331 .keys()
332 .chain(cost_unknowns_by_currency.keys())
333 .collect();
334 currencies_with_unknowns.sort_by(|a, b| a.as_str().cmp(b.as_str()));
335 currencies_with_unknowns.dedup();
336 for currency in currencies_with_unknowns {
337 let missing_count = missing_by_currency
338 .get(currency)
339 .map_or(0, std::vec::Vec::len);
340 let cost_unknown_count = cost_unknowns_by_currency
341 .get(currency)
342 .copied()
343 .unwrap_or(0);
344 let total = missing_count + cost_unknown_count;
345 if total > 1 {
346 return Err(InterpolationError::MultipleMissing {
347 currency: currency.clone(),
348 count: total,
349 });
350 }
351 }
352
353 if !unassigned_missing.is_empty() {
370 let mut cost_unknown_keys: Vec<&Currency> = cost_unknowns_by_currency.keys().collect();
371 cost_unknown_keys.sort_by(|a, b| a.as_str().cmp(b.as_str()));
372 if let Some(curr) = cost_unknown_keys.first() {
373 let count = cost_unknowns_by_currency.get(*curr).copied().unwrap_or(0);
374 return Err(InterpolationError::MultipleMissing {
375 currency: (*curr).clone(),
376 count: count + unassigned_missing.len(),
377 });
378 }
379 }
380
381 for (currency, indices) in missing_by_currency {
383 let idx = indices[0];
384 let residual = residuals.get(¤cy).copied().unwrap_or(Decimal::ZERO);
385
386 let interpolated =
387 round_interpolated(residual, max_scale_by_currency.get(¤cy).copied());
388
389 result.postings[idx].units = Some(IncompleteAmount::Complete(Amount::new(
390 interpolated,
391 ¤cy,
392 )));
393 filled_indices.push(idx);
394
395 *residuals.entry(currency).or_default() += interpolated;
397 }
398
399 if !unassigned_missing.is_empty() {
402 let non_zero_residuals: Vec<(Currency, Decimal)> = residuals
404 .iter()
405 .filter(|&(_, v)| !v.is_zero())
406 .map(|(k, v)| (k.clone(), *v))
407 .collect();
408
409 if unassigned_missing.len() == 1 && non_zero_residuals.len() > 1 {
412 let idx = unassigned_missing[0];
413 let original_posting = &transaction.postings[idx];
414
415 let (first_currency, first_residual) = &non_zero_residuals[0];
417 let interpolated = round_interpolated(
418 *first_residual,
419 max_scale_by_currency.get(first_currency).copied(),
420 );
421 result.postings[idx].units = Some(IncompleteAmount::Complete(Amount::new(
422 interpolated,
423 first_currency,
424 )));
425 filled_indices.push(idx);
426 *residuals.entry(first_currency.clone()).or_default() += interpolated;
427
428 for (currency, residual) in non_zero_residuals.iter().skip(1) {
430 let mut new_posting = original_posting.clone();
431 let interpolated =
432 round_interpolated(*residual, max_scale_by_currency.get(currency).copied());
433 new_posting.units = Some(IncompleteAmount::Complete(Amount::new(
434 interpolated,
435 currency,
436 )));
437 result.postings.push(new_posting);
438 filled_indices.push(result.postings.len() - 1);
439 *residuals.entry(currency.clone()).or_default() += interpolated;
440 }
441 } else {
442 if unassigned_missing.len() > non_zero_residuals.len() && !non_zero_residuals.is_empty()
446 {
447 let (currency, _) = &non_zero_residuals[0];
448 return Err(InterpolationError::MultipleMissing {
449 currency: currency.clone(),
450 count: unassigned_missing.len(),
451 });
452 }
453
454 for (i, idx) in unassigned_missing.iter().enumerate() {
456 if i < non_zero_residuals.len() {
457 let (currency, residual) = &non_zero_residuals[i];
458 let interpolated =
459 round_interpolated(*residual, max_scale_by_currency.get(currency).copied());
460 result.postings[*idx].units = Some(IncompleteAmount::Complete(Amount::new(
461 interpolated,
462 currency,
463 )));
464 filled_indices.push(*idx);
465 *residuals.entry(currency.clone()).or_default() += interpolated;
466 } else if !non_zero_residuals.is_empty() {
467 let (currency, _) = &non_zero_residuals[0];
469 result.postings[*idx].units =
470 Some(IncompleteAmount::Complete(Amount::zero(currency)));
471 filled_indices.push(*idx);
472 } else if let Some(currency) = get_inferred_currency(&mut inferred_cost_currency) {
473 result.postings[*idx].units =
479 Some(IncompleteAmount::Complete(Amount::zero(¤cy)));
480 filled_indices.push(*idx);
481 } else {
482 return Err(InterpolationError::CannotInferCurrency {
484 account: transaction.postings[*idx].account.clone(),
485 });
486 }
487 }
488 }
489 }
490
491 let mut indices_to_remove: Vec<usize> = filled_indices
509 .iter()
510 .filter(|&&idx| {
511 result.postings.get(idx).is_some_and(|p| {
512 p.units
513 .as_ref()
514 .and_then(|u| u.as_amount())
515 .is_some_and(|a| a.number.is_zero())
516 })
517 })
518 .copied()
519 .collect();
520 indices_to_remove.sort_unstable_by(|a, b| b.cmp(a));
521
522 for idx in &indices_to_remove {
523 result.postings.remove(*idx);
524 }
525
526 let final_filled_indices: Vec<usize> = filled_indices
529 .into_iter()
530 .filter(|idx| !indices_to_remove.contains(idx))
531 .map(|idx| {
532 let adjustment = indices_to_remove.iter().filter(|&&r| r < idx).count();
533 idx - adjustment
534 })
535 .collect();
536
537 Ok(InterpolationResult {
540 transaction: result,
541 filled_indices: final_filled_indices,
542 residuals,
543 })
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549 use rust_decimal_macros::dec;
550 use rustledger_core::{NaiveDate, Posting};
551
552 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
553 rustledger_core::naive_date(year, month, day).unwrap()
554 }
555
556 fn get_amount(posting: &rustledger_core::Posting) -> Option<&Amount> {
558 posting.units.as_ref().and_then(|u| u.as_amount())
559 }
560
561 #[test]
562 fn test_interpolate_simple() {
563 let txn = Transaction::new(date(2024, 1, 15), "Test")
564 .with_synthesized_posting(Posting::new(
565 "Expenses:Food",
566 Amount::new(dec!(50.00), "USD"),
567 ))
568 .with_synthesized_posting(Posting::auto("Assets:Cash"));
569
570 let result = interpolate(&txn).unwrap();
571
572 assert_eq!(result.filled_indices, vec![1]);
573
574 let filled = &result.transaction.postings[1];
575 let amount = get_amount(filled).expect("should have amount");
576 assert_eq!(amount.number, dec!(-50.00));
577 assert_eq!(amount.currency, "USD");
578 }
579
580 #[test]
581 fn test_interpolate_multiple_postings() {
582 let txn = Transaction::new(date(2024, 1, 15), "Test")
583 .with_synthesized_posting(Posting::new(
584 "Expenses:Food",
585 Amount::new(dec!(30.00), "USD"),
586 ))
587 .with_synthesized_posting(Posting::new(
588 "Expenses:Drink",
589 Amount::new(dec!(20.00), "USD"),
590 ))
591 .with_synthesized_posting(Posting::auto("Assets:Cash"));
592
593 let result = interpolate(&txn).unwrap();
594
595 let filled = &result.transaction.postings[2];
596 let amount = get_amount(filled).expect("should have amount");
597 assert_eq!(amount.number, dec!(-50.00));
598 }
599
600 #[test]
601 fn test_interpolate_no_missing() {
602 let txn = Transaction::new(date(2024, 1, 15), "Test")
603 .with_synthesized_posting(Posting::new(
604 "Expenses:Food",
605 Amount::new(dec!(50.00), "USD"),
606 ))
607 .with_synthesized_posting(Posting::new(
608 "Assets:Cash",
609 Amount::new(dec!(-50.00), "USD"),
610 ));
611
612 let result = interpolate(&txn).unwrap();
613
614 assert!(result.filled_indices.is_empty());
615 }
616
617 #[test]
618 fn test_interpolate_multiple_currencies() {
619 let txn = Transaction::new(date(2024, 1, 15), "Test")
620 .with_synthesized_posting(Posting::new(
621 "Expenses:Food",
622 Amount::new(dec!(50.00), "USD"),
623 ))
624 .with_synthesized_posting(Posting::new(
625 "Expenses:Travel",
626 Amount::new(dec!(100.00), "EUR"),
627 ))
628 .with_synthesized_posting(Posting::new(
629 "Assets:Cash:USD",
630 Amount::new(dec!(-50.00), "USD"),
631 ))
632 .with_synthesized_posting(Posting::auto("Assets:Cash:EUR"));
633
634 let result = interpolate(&txn).unwrap();
635
636 let filled = &result.transaction.postings[3];
637 let amount = get_amount(filled).expect("should have amount");
638 assert_eq!(amount.number, dec!(-100.00));
639 assert_eq!(amount.currency, "EUR");
640 }
641
642 #[test]
643 fn test_interpolate_error_multiple_missing_same_currency() {
644 let txn = Transaction::new(date(2024, 1, 15), "Test")
645 .with_synthesized_posting(Posting::new(
646 "Expenses:Food",
647 Amount::new(dec!(50.00), "USD"),
648 ))
649 .with_synthesized_posting(Posting::auto("Assets:Cash"))
650 .with_synthesized_posting(Posting::auto("Assets:Bank"));
651
652 let result = interpolate(&txn);
655 assert!(
656 matches!(result, Err(InterpolationError::MultipleMissing { .. })),
657 "expected MultipleMissing error, got: {result:?}"
658 );
659 }
660
661 #[test]
662 fn test_interpolate_multiple_missing_different_currencies_ok() {
663 let txn = Transaction::new(date(2024, 1, 15), "Multi-currency")
665 .with_synthesized_posting(Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD")))
666 .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")))
667 .with_synthesized_posting(Posting::auto("Liabilities:CreditCard"))
668 .with_synthesized_posting(Posting::auto("Equity:Exchange"));
669
670 let result = interpolate(&txn);
672 assert!(
673 result.is_ok(),
674 "expected success for different-currency elision, got: {result:?}"
675 );
676 }
677
678 #[test]
679 fn test_interpolate_with_per_unit_cost() {
680 let txn = Transaction::new(date(2015, 10, 2), "Buy stock")
686 .with_synthesized_posting(
687 Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
688 rustledger_core::CostSpec::empty()
689 .with_number(rustledger_core::CostNumber::PerUnit {
690 value: dec!(100.00),
691 })
692 .with_currency("USD"),
693 ),
694 )
695 .with_synthesized_posting(Posting::auto("Assets:Cash"));
696
697 let result = interpolate(&txn).expect("interpolation should succeed");
698
699 assert_eq!(result.filled_indices, vec![1]);
701
702 let filled = &result.transaction.postings[1];
704 let amount = get_amount(filled).expect("should have amount");
705 assert_eq!(
706 amount.currency, "USD",
707 "should be USD (cost currency), not HOOL"
708 );
709 assert_eq!(
710 amount.number,
711 dec!(-1000.00),
712 "should be -1000 USD (10 * 100)"
713 );
714
715 let residual = result
717 .residuals
718 .get("USD")
719 .copied()
720 .unwrap_or(Decimal::ZERO);
721 assert!(
722 residual.abs() < dec!(0.01),
723 "USD residual should be ~0, got {residual}"
724 );
725 assert!(
727 !result.residuals.contains_key("HOOL"),
728 "should not have HOOL residual"
729 );
730 }
731
732 #[test]
739 fn test_interpolated_weights_agree_with_calculate_residual() {
740 let txn = Transaction::new(date(2015, 10, 2), "Mixed cost and price")
743 .with_synthesized_posting(
744 Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
745 rustledger_core::CostSpec::empty()
746 .with_number(rustledger_core::CostNumber::Total {
747 value: dec!(1500.00),
748 })
749 .with_currency("USD"),
750 ),
751 )
752 .with_synthesized_posting(
753 Posting::new("Assets:EUR", Amount::new(dec!(-200.00), "EUR")).with_price(
754 rustledger_core::PriceAnnotation::unit(Amount::new(dec!(1.10), "USD")),
755 ),
756 )
757 .with_synthesized_posting(Posting::auto("Assets:Cash"));
758
759 let result = interpolate(&txn).expect("interpolation should succeed");
760
761 for (currency, value) in crate::calculate_residual(&result.transaction) {
763 assert!(
764 value.abs() < dec!(0.0001),
765 "interpolated result not balanced per calculate_residual: {value} {currency}"
766 );
767 }
768 }
769
770 #[test]
771 fn test_interpolate_with_total_cost() {
772 let txn = Transaction::new(date(2015, 10, 2), "Buy stock")
778 .with_synthesized_posting(
779 Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
780 rustledger_core::CostSpec::empty()
781 .with_number(rustledger_core::CostNumber::Total {
782 value: dec!(1000.00),
783 })
784 .with_currency("USD"),
785 ),
786 )
787 .with_synthesized_posting(Posting::auto("Assets:Cash"));
788
789 let result = interpolate(&txn).expect("interpolation should succeed");
790
791 let filled = &result.transaction.postings[1];
792 let amount = get_amount(filled).expect("should have amount");
793 assert_eq!(amount.currency, "USD");
794 assert_eq!(amount.number, dec!(-1000.00));
795 }
796
797 #[test]
798 fn test_interpolate_stock_purchase_with_commission() {
799 let txn = Transaction::new(date(2013, 2, 3), "Bought some stock")
807 .with_synthesized_posting(
808 Posting::new("Assets:Stock", Amount::new(dec!(8), "HOOL")).with_cost(
809 rustledger_core::CostSpec::empty()
810 .with_number(rustledger_core::CostNumber::PerUnit {
811 value: dec!(701.20),
812 })
813 .with_currency("USD"),
814 ),
815 )
816 .with_synthesized_posting(Posting::new(
817 "Expenses:Commission",
818 Amount::new(dec!(7.95), "USD"),
819 ))
820 .with_synthesized_posting(Posting::auto("Assets:Cash"));
821
822 let result = interpolate(&txn).expect("interpolation should succeed");
823
824 let filled = &result.transaction.postings[2];
825 let amount = get_amount(filled).expect("should have amount");
826 assert_eq!(amount.currency, "USD");
827 assert_eq!(amount.number, dec!(-5617.55));
829 }
830
831 #[test]
832 fn test_interpolate_stock_sale_with_cost_and_price() {
833 let txn = Transaction::new(date(2015, 10, 2), "Sell stock")
844 .with_synthesized_posting(
845 Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
846 .with_cost(
847 rustledger_core::CostSpec::empty()
848 .with_number(rustledger_core::CostNumber::PerUnit {
849 value: dec!(100.00),
850 })
851 .with_currency("USD"),
852 )
853 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
854 dec!(120.00),
855 "USD",
856 ))),
857 )
858 .with_synthesized_posting(Posting::new(
859 "Assets:Cash",
860 Amount::new(dec!(1200.00), "USD"),
861 ))
862 .with_synthesized_posting(Posting::auto("Income:Gains"));
863
864 let result = interpolate(&txn).expect("interpolation should succeed");
865
866 let filled = &result.transaction.postings[2];
867 let amount = get_amount(filled).expect("should have amount");
868 assert_eq!(amount.currency, "USD");
869 assert_eq!(amount.number, dec!(-200.00));
871 }
872
873 #[test]
874 fn test_interpolate_balanced_with_cost_no_interpolation_needed() {
875 let txn = Transaction::new(date(2015, 10, 2), "Buy stock")
880 .with_synthesized_posting(
881 Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
882 rustledger_core::CostSpec::empty()
883 .with_number(rustledger_core::CostNumber::PerUnit {
884 value: dec!(100.00),
885 })
886 .with_currency("USD"),
887 ),
888 )
889 .with_synthesized_posting(Posting::new(
890 "Assets:Cash",
891 Amount::new(dec!(-1000.00), "USD"),
892 ));
893
894 let result = interpolate(&txn).expect("interpolation should succeed");
895
896 assert!(result.filled_indices.is_empty());
898
899 let residual = result
901 .residuals
902 .get("USD")
903 .copied()
904 .unwrap_or(Decimal::ZERO);
905 assert!(residual.abs() < dec!(0.01));
906 }
907
908 #[test]
909 fn test_interpolate_negative_cost_units_sale() {
910 let txn = Transaction::new(date(2015, 10, 2), "Sell stock")
917 .with_synthesized_posting(
918 Posting::new("Assets:Stock", Amount::new(dec!(-5), "HOOL")).with_cost(
919 rustledger_core::CostSpec::empty()
920 .with_number(rustledger_core::CostNumber::PerUnit {
921 value: dec!(100.00),
922 })
923 .with_currency("USD"),
924 ),
925 )
926 .with_synthesized_posting(Posting::auto("Assets:Cash"));
927
928 let result = interpolate(&txn).expect("interpolation should succeed");
929
930 let filled = &result.transaction.postings[1];
931 let amount = get_amount(filled).expect("should have amount");
932 assert_eq!(amount.currency, "USD");
933 assert_eq!(amount.number, dec!(500.00)); }
935
936 #[test]
941 fn test_interpolate_multi_currency_single_elided() {
942 let txn = Transaction::new(date(2008, 4, 2), "Gilbert paid back for iPhone")
952 .with_synthesized_posting(Posting::new(
953 "Assets:Cash",
954 Amount::new(dec!(440.00), "CAD"),
955 ))
956 .with_synthesized_posting(Posting::new(
957 "Assets:AccountsReceivable",
958 Amount::new(dec!(-431.92), "USD"),
959 ))
960 .with_synthesized_posting(Posting::auto("Assets:Cash"));
961
962 let result = interpolate(&txn).expect("interpolation should succeed");
963
964 assert_eq!(
966 result.transaction.postings.len(),
967 4,
968 "should split elided posting into 2"
969 );
970
971 for (currency, residual) in &result.residuals {
973 assert!(
974 residual.abs() < dec!(0.01),
975 "{currency} residual should be ~0, got {residual}"
976 );
977 }
978
979 let mut found_cad = false;
981 let mut found_usd = false;
982 for posting in &result.transaction.postings {
983 if let Some(amount) = get_amount(posting)
984 && posting.account.as_str() == "Assets:Cash"
985 {
986 if amount.currency == "CAD" && amount.number == dec!(-440.00) {
987 found_cad = true;
988 } else if amount.currency == "USD" && amount.number == dec!(431.92) {
989 found_usd = true;
990 }
991 }
992 }
993 assert!(found_cad, "should have -440.00 CAD posting");
994 assert!(found_usd, "should have 431.92 USD posting");
995 }
996
997 #[test]
998 fn test_interpolate_multi_currency_three_currencies() {
999 let txn = Transaction::new(date(2024, 1, 15), "Multi-currency test")
1001 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(100), "USD")))
1002 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(200), "EUR")))
1003 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(300), "GBP")))
1004 .with_synthesized_posting(Posting::auto("Equity:Opening"));
1005
1006 let result = interpolate(&txn).expect("interpolation should succeed");
1007
1008 assert_eq!(result.transaction.postings.len(), 6);
1010
1011 for (currency, residual) in &result.residuals {
1013 assert!(
1014 residual.abs() < dec!(0.01),
1015 "{currency} residual should be ~0, got {residual}"
1016 );
1017 }
1018 }
1019
1020 #[test]
1027 fn test_interpolate_cost_currency_inferred_from_other_posting() {
1028 let txn = Transaction::new(date(2026, 1, 1), "Opening balance")
1035 .with_synthesized_posting(
1036 Posting::new(
1037 "Assets:Vanguard:IRA:Trad:VFIFX",
1038 Amount::new(dec!(10), "VFIFX"),
1039 )
1040 .with_cost(
1041 rustledger_core::CostSpec::empty()
1042 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) }),
1043 ),
1044 )
1045 .with_synthesized_posting(Posting::new(
1046 "Equity:Opening-Balances",
1047 Amount::new(dec!(-1000), "USD"),
1048 ));
1049
1050 let result = interpolate(&txn).expect("interpolation should succeed");
1051
1052 let residual = result
1054 .residuals
1055 .get("USD")
1056 .copied()
1057 .unwrap_or(Decimal::ZERO);
1058 assert!(
1059 residual.abs() < dec!(0.01),
1060 "USD residual should be ~0, got {residual}"
1061 );
1062 }
1063
1064 #[test]
1066 fn test_interpolate_cost_currency_inferred_elided_cash() {
1067 let txn = Transaction::new(date(2026, 1, 1), "Opening balance")
1074 .with_synthesized_posting(
1075 Posting::new(
1076 "Assets:Vanguard:IRA:Trad:VFIFX",
1077 Amount::new(dec!(10), "VFIFX"),
1078 )
1079 .with_cost(
1080 rustledger_core::CostSpec::empty()
1081 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) }),
1082 ),
1083 )
1084 .with_synthesized_posting(Posting::new(
1085 "Equity:Opening-Balances",
1086 Amount::new(dec!(-1000), "USD"),
1087 ));
1088
1089 let result = interpolate(&txn).expect("interpolation should succeed");
1090
1091 assert!(result.filled_indices.is_empty());
1093
1094 let residual = result
1096 .residuals
1097 .get("USD")
1098 .copied()
1099 .unwrap_or(Decimal::ZERO);
1100 assert!(
1101 residual.abs() < dec!(0.01),
1102 "USD residual should be ~0, got {residual}"
1103 );
1104 }
1105
1106 #[test]
1116 fn test_interpolate_rounds_to_quantum() {
1117 let txn = Transaction::new(date(2026, 1, 2), "Test")
1127 .with_synthesized_posting(Posting::auto("Assets:Cash"))
1128 .with_synthesized_posting(
1129 Posting::new("Assets:Abc", Amount::new(dec!(12.3340), "ABC")).with_cost(
1130 rustledger_core::CostSpec::empty()
1131 .with_number(rustledger_core::CostNumber::PerUnit {
1132 value: dec!(140.02),
1133 })
1134 .with_currency("USD"),
1135 ),
1136 )
1137 .with_synthesized_posting(Posting::new(
1138 "Expenses:Abc",
1139 Amount::new(dec!(-0.01), "USD"),
1140 ));
1141
1142 let result = interpolate(&txn).expect("interpolation should succeed");
1143
1144 assert_eq!(result.filled_indices, vec![0]);
1146
1147 let filled = &result.transaction.postings[0];
1150 let amount = get_amount(filled).expect("should have amount");
1151 assert_eq!(amount.currency, "USD");
1152 assert_eq!(
1153 amount.number,
1154 dec!(-1727.00),
1155 "should be -1727.00 USD (rounded to 2 decimal places)"
1156 );
1157
1158 let residual = result
1160 .residuals
1161 .get("USD")
1162 .copied()
1163 .unwrap_or(Decimal::ZERO);
1164 assert_eq!(
1165 residual,
1166 dec!(-0.003320),
1167 "residual should be -0.003320 USD"
1168 );
1169 }
1170
1171 #[test]
1173 fn test_interpolate_uses_max_scale() {
1174 let txn = Transaction::new(date(2024, 1, 15), "Test")
1177 .with_synthesized_posting(Posting::new("Expenses:A", Amount::new(dec!(0.1), "USD")))
1178 .with_synthesized_posting(Posting::new("Expenses:B", Amount::new(dec!(0.001), "USD")))
1179 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1180
1181 let result = interpolate(&txn).expect("interpolation should succeed");
1182
1183 let filled = &result.transaction.postings[2];
1184 let amount = get_amount(filled).expect("should have amount");
1185
1186 assert_eq!(amount.number, dec!(-0.101));
1188 assert_eq!(amount.number.scale(), 3);
1190 }
1191
1192 #[test]
1203 fn test_interpolate_cost_scale_preserved() {
1204 let txn = Transaction::new(date(2026, 1, 19), "Buy stock")
1215 .with_synthesized_posting(
1216 Posting::new("Assets:Stock", Amount::new(dec!(1), "CSU")).with_cost(
1217 rustledger_core::CostSpec::empty()
1218 .with_number(rustledger_core::CostNumber::PerUnit {
1219 value: dec!(2800.01),
1220 })
1221 .with_currency("CAD"),
1222 ),
1223 )
1224 .with_synthesized_posting(Posting::new(
1225 "Expenses:Commission",
1226 Amount::new(dec!(1), "CAD"),
1227 ))
1228 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1229
1230 let result = interpolate(&txn).expect("interpolation should succeed");
1231
1232 assert_eq!(result.filled_indices, vec![2]);
1234
1235 let filled = &result.transaction.postings[2];
1237 let amount = get_amount(filled).expect("should have amount");
1238 assert_eq!(amount.currency, "CAD");
1239 assert_eq!(
1240 amount.number,
1241 dec!(-2801.01),
1242 "should be -2801.01 CAD (preserving cost spec precision)"
1243 );
1244
1245 let residual = result
1247 .residuals
1248 .get("CAD")
1249 .copied()
1250 .unwrap_or(Decimal::ZERO);
1251 assert!(
1252 residual.is_zero(),
1253 "CAD residual should be 0, got {residual}"
1254 );
1255 }
1256
1257 #[test]
1281 fn test_interpolate_balanced_cost_prunes_zero_posting() {
1282 let txn = Transaction::new(date(2022, 4, 16), "Trade")
1283 .with_synthesized_posting(
1284 Posting::new("Assets:Crypto", Amount::new(dec!(100), "USDC")).with_cost(
1285 rustledger_core::CostSpec::empty()
1286 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.0) })
1287 .with_currency("USD"),
1288 ),
1289 )
1290 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-100), "USD")))
1291 .with_synthesized_posting(Posting::auto("Income:Trading"));
1292
1293 let result = interpolate(&txn).expect("interpolation should succeed");
1294
1295 assert!(
1296 result.filled_indices.is_empty(),
1297 "zero-amount filled posting should have been pruned"
1298 );
1299 assert_eq!(
1300 result.transaction.postings.len(),
1301 2,
1302 "Income:Trading filled to 0 USD should be pruned"
1303 );
1304 assert!(
1305 !result
1306 .transaction
1307 .postings
1308 .iter()
1309 .any(|p| p.account.as_str() == "Income:Trading"),
1310 "Income:Trading should not be in postings after pruning"
1311 );
1312 }
1313
1314 #[test]
1322 fn test_interpolate_zero_cost_prunes_zero_posting() {
1323 let txn = Transaction::new(date(2022, 4, 16), "Free tokens")
1324 .with_synthesized_posting(
1325 Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
1326 rustledger_core::CostSpec::empty()
1327 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
1328 .with_currency("USD"),
1329 ),
1330 )
1331 .with_synthesized_posting(Posting::auto("Income:Bonus"));
1332
1333 let result = interpolate(&txn).expect("interpolation should succeed");
1334
1335 assert!(
1336 result.filled_indices.is_empty(),
1337 "zero-amount filled posting should have been pruned"
1338 );
1339 assert_eq!(result.transaction.postings.len(), 1);
1340 }
1341
1342 #[test]
1350 fn test_interpolate_zero_total_cost_prunes_zero_posting() {
1351 let txn = Transaction::new(date(2022, 4, 16), "Free tokens")
1352 .with_synthesized_posting(
1353 Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
1354 rustledger_core::CostSpec::empty()
1355 .with_number(rustledger_core::CostNumber::Total { value: dec!(0) })
1356 .with_currency("USD"),
1357 ),
1358 )
1359 .with_synthesized_posting(Posting::auto("Income:Bonus"));
1360
1361 let result = interpolate(&txn).expect("interpolation should succeed");
1362
1363 assert!(
1364 result.filled_indices.is_empty(),
1365 "zero-amount filled posting should have been pruned"
1366 );
1367 assert_eq!(result.transaction.postings.len(), 1);
1368 }
1369
1370 #[test]
1385 fn test_interpolate_empty_cost_spec_with_missing_amount_errors() {
1386 use rustledger_core::CostSpec;
1387
1388 let txn = Transaction::new(date(2022, 1, 12), "sell what was never bought")
1389 .with_synthesized_posting(
1390 Posting::new(
1391 "Assets:Htsec:Positions",
1392 Amount::new(dec!(-13000.00), "SH513050"),
1393 )
1394 .with_cost(CostSpec::empty()) .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1396 dec!(1.300),
1397 "CNY",
1398 ))),
1399 )
1400 .with_synthesized_posting(Posting::new(
1401 "Assets:Htsec:Cash",
1402 Amount::new(dec!(16900.00), "CNY"),
1403 ))
1404 .with_synthesized_posting(Posting::new(
1405 "Assets:Htsec:Cash",
1406 Amount::new(dec!(-0.85), "CNY"),
1407 ))
1408 .with_synthesized_posting(Posting::new(
1409 "Expenses:Htsec:Commission",
1410 Amount::new(dec!(0.85), "CNY"),
1411 ))
1412 .with_synthesized_posting(Posting::auto("Income:Htsec:PnL"));
1413
1414 let result = interpolate(&txn);
1415 assert!(
1416 matches!(result, Err(InterpolationError::MultipleMissing { .. })),
1417 "expected MultipleMissing error from empty cost spec + missing posting; got {result:?}"
1418 );
1419 if let Err(InterpolationError::MultipleMissing { currency, count }) = result {
1420 assert_eq!(currency.as_str(), "CNY");
1421 assert!(
1422 count >= 2,
1423 "expected count >= 2 unknowns in CNY group, got {count}"
1424 );
1425 }
1426 }
1427
1428 #[test]
1432 fn test_interpolate_empty_cost_spec_alone_ok() {
1433 use rustledger_core::CostSpec;
1434
1435 let txn = Transaction::new(date(2022, 1, 12), "Sell HOOL")
1436 .with_synthesized_posting(
1437 Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
1438 .with_cost(CostSpec::empty())
1439 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1440 dec!(150),
1441 "USD",
1442 ))),
1443 )
1444 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")));
1445
1446 let result = interpolate(&txn);
1447 assert!(
1448 result.is_ok(),
1449 "single empty cost spec with no missing postings should succeed; got {result:?}"
1450 );
1451 }
1452
1453 #[test]
1456 fn test_interpolate_two_empty_cost_specs_same_currency_errors() {
1457 use rustledger_core::CostSpec;
1458
1459 let txn = Transaction::new(date(2022, 1, 12), "Two unknown-cost sells")
1460 .with_synthesized_posting(
1461 Posting::new("Assets:StockA", Amount::new(dec!(-10), "AAPL"))
1462 .with_cost(CostSpec::empty())
1463 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1464 dec!(150),
1465 "USD",
1466 ))),
1467 )
1468 .with_synthesized_posting(
1469 Posting::new("Assets:StockB", Amount::new(dec!(-5), "GOOG"))
1470 .with_cost(CostSpec::empty())
1471 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1472 dec!(2000),
1473 "USD",
1474 ))),
1475 )
1476 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(11500), "USD")));
1477
1478 let result = interpolate(&txn);
1479 assert!(
1480 matches!(result, Err(InterpolationError::MultipleMissing { .. })),
1481 "two empty cost specs in same currency should error; got {result:?}"
1482 );
1483 }
1484
1485 #[test]
1490 fn test_interpolate_empty_cost_spec_with_missing_in_different_currency_ok() {
1491 use rustledger_core::CostSpec;
1492
1493 let txn = Transaction::new(date(2022, 1, 12), "Sale + currency-known absorber")
1494 .with_synthesized_posting(
1495 Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
1496 .with_cost(CostSpec::empty()) .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1498 dec!(150),
1499 "USD",
1500 ))),
1501 )
1502 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")))
1503 .with_synthesized_posting(Posting::new("Expenses:Fee", Amount::new(dec!(5), "EUR")))
1504 .with_synthesized_posting(Posting {
1505 units: Some(IncompleteAmount::CurrencyOnly("EUR".into())),
1507 ..Posting::auto("Income:Misc")
1508 });
1509
1510 let result = interpolate(&txn);
1511 assert!(
1512 result.is_ok(),
1513 "cost-unknown in USD + missing-amount in EUR should succeed (disjoint groups); \
1514 got {result:?}"
1515 );
1516 }
1517
1518 #[test]
1530 fn test_interpolate_residual_ignores_cost_spec_scale() {
1531 use rustledger_core::CostSpec;
1532
1533 let cost_spec = CostSpec {
1534 number: Some(rustledger_core::CostNumber::PerUnit {
1535 value: dec!(170.16734),
1536 }),
1537 currency: Some(Currency::from("USD")),
1538 date: None,
1539 label: None,
1540 merge: false,
1541 };
1542
1543 let txn = Transaction::new(date(2016, 2, 12), "Sell")
1544 .with_synthesized_posting(Posting::new(
1545 "Assets:Cash",
1546 Amount::new(dec!(336.73), "USD"),
1547 ))
1548 .with_synthesized_posting(
1549 Posting::new("Assets:Brokerage", Amount::new(dec!(-1.763), "STOCK"))
1550 .with_cost(cost_spec)
1551 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1552 dec!(191.00),
1553 "USD",
1554 ))),
1555 )
1556 .with_synthesized_posting(Posting::auto("Income:Capital-Gains"));
1557
1558 let result = interpolate(&txn).expect("interpolation should succeed");
1559 let filled = &result.transaction.postings[2];
1560 let amount = get_amount(filled).expect("Income should have amount");
1561
1562 assert_eq!(
1563 amount.currency.as_str(),
1564 "USD",
1565 "residual currency should be USD"
1566 );
1567 assert_eq!(
1568 amount.number.scale(),
1569 2,
1570 "residual scale must be 2 (USD precision from `336.73 USD`), \
1571 not 5 (from cost spec). Pre-fix this was 5. (#1107)"
1572 );
1573 assert_eq!(
1574 amount.number,
1575 dec!(-36.72),
1576 "residual value should match bean-query exactly (#1107). \
1577 Was -36.72498 before fix."
1578 );
1579 }
1580
1581 #[test]
1594 fn test_interpolate_residual_after_booking_total_cost_division() {
1595 use crate::book::BookingEngine;
1596 use rustledger_core::{Cost, CostSpec, IncompleteAmount, PriceAnnotation};
1597
1598 let buy = Transaction::new(date(2016, 1, 1), "Buy")
1601 .with_synthesized_posting(
1602 Posting::new("Assets:Brokerage", Amount::new(dec!(1.763), "STOCK")).with_cost(
1603 CostSpec {
1604 number: Some(rustledger_core::CostNumber::Total {
1605 value: dec!(300.00),
1606 }),
1607 currency: Some(Currency::from("USD")),
1608 date: None,
1609 label: None,
1610 merge: false,
1611 },
1612 ),
1613 )
1614 .with_synthesized_posting(Posting::new(
1615 "Assets:Cash",
1616 Amount::new(dec!(-300.00), "USD"),
1617 ));
1618
1619 let sell = Transaction::new(date(2016, 2, 12), "Sell")
1623 .with_synthesized_posting(Posting::new(
1624 "Assets:Cash",
1625 Amount::new(dec!(336.73), "USD"),
1626 ))
1627 .with_synthesized_posting(
1628 Posting::new("Assets:Brokerage", Amount::new(dec!(-1.763), "STOCK"))
1629 .with_cost(CostSpec::empty())
1630 .with_price(PriceAnnotation::unit(Amount::new(dec!(191.00), "USD"))),
1631 )
1632 .with_synthesized_posting(Posting::auto("Income:Capital-Gains"));
1633
1634 let mut engine = BookingEngine::new();
1635 engine.apply(&buy);
1636
1637 let result = engine
1642 .book_and_interpolate(&sell)
1643 .expect("booking+interpolation should succeed");
1644
1645 let income = &result.transaction.postings[2];
1646 let amount = get_amount(income).expect("Income should have an amount after interpolation");
1647
1648 assert_eq!(amount.currency.as_str(), "USD");
1649 assert!(
1650 amount.number.scale() <= 2,
1651 "residual scale must be ≤ 2 (USD's tracked precision), \
1652 not inherited from the lot's high-scale derived per_unit. \
1653 Got scale={} number={}",
1654 amount.number.scale(),
1655 amount.number
1656 );
1657
1658 let _ = Cost::new(dec!(1), "USD");
1661 let _: Option<IncompleteAmount> = None;
1662 }
1663
1664 #[test]
1670 fn test_interpolate_empty_cost_spec_with_unassigned_in_different_currency_errors() {
1671 use rustledger_core::CostSpec;
1672
1673 let txn = Transaction::new(date(2022, 1, 12), "Sale + unassigned absorber")
1674 .with_synthesized_posting(
1675 Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
1676 .with_cost(CostSpec::empty())
1677 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1678 dec!(150),
1679 "USD",
1680 ))),
1681 )
1682 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")))
1683 .with_synthesized_posting(Posting::new("Expenses:Fee", Amount::new(dec!(5), "EUR")))
1684 .with_synthesized_posting(Posting::auto("Income:Misc"));
1685
1686 let result = interpolate(&txn);
1687 assert!(
1688 matches!(result, Err(InterpolationError::MultipleMissing { .. })),
1689 "cost-unknown + unassigned-missing must error even when in different \
1690 currencies (bean-check parity); got {result:?}"
1691 );
1692 }
1693
1694 #[test]
1700 fn interpolate_unit_price_is_units_times_price() {
1701 let txn = Transaction::new(date(2024, 1, 1), "Buy")
1703 .with_synthesized_posting(
1704 Posting::new("Assets:Stock", Amount::new(dec!(10), "STK")).with_price(
1705 rustledger_core::PriceAnnotation::unit(Amount::new(dec!(3), "USD")),
1706 ),
1707 )
1708 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1709 let r = interpolate(&txn).expect("interpolation should succeed");
1710 let cash = get_amount(&r.transaction.postings[1]).expect("filled");
1711 assert_eq!(cash.currency, "USD");
1712 assert_eq!(cash.number, dec!(-30)); }
1714
1715 #[test]
1716 fn interpolate_total_price_is_total() {
1717 let txn = Transaction::new(date(2024, 1, 1), "Buy")
1719 .with_synthesized_posting(
1720 Posting::new("Assets:Stock", Amount::new(dec!(10), "STK")).with_price(
1721 rustledger_core::PriceAnnotation::total(Amount::new(dec!(30), "USD")),
1722 ),
1723 )
1724 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1725 let r = interpolate(&txn).expect("interpolation should succeed");
1726 let cash = get_amount(&r.transaction.postings[1]).expect("filled");
1727 assert_eq!(cash.number, dec!(-30)); assert_eq!(cash.currency, "USD"); }
1730
1731 #[test]
1732 fn interpolate_three_posting_residual_sum() {
1733 let txn = Transaction::new(date(2024, 1, 1), "Split")
1735 .with_synthesized_posting(Posting::new("Expenses:A", Amount::new(dec!(100), "USD")))
1736 .with_synthesized_posting(Posting::new("Expenses:B", Amount::new(dec!(25), "USD")))
1737 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1738 let r = interpolate(&txn).expect("interpolation should succeed");
1739 let cash = get_amount(&r.transaction.postings[2]).expect("filled");
1740 assert_eq!(cash.number, dec!(-125)); }
1742
1743 #[test]
1744 fn interpolate_single_elided_splits_two_currencies() {
1745 let txn = Transaction::new(date(2024, 1, 1), "FX")
1749 .with_synthesized_posting(Posting::new("Assets:USD", Amount::new(dec!(100), "USD")))
1750 .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(50), "EUR")))
1751 .with_synthesized_posting(Posting::auto("Equity:Balance"));
1752 let r = interpolate(&txn).expect("interpolation should succeed");
1753 let filled: Vec<Amount> = r
1754 .filled_indices
1755 .iter()
1756 .map(|&i| {
1757 get_amount(&r.transaction.postings[i])
1758 .expect("filled")
1759 .clone()
1760 })
1761 .collect();
1762 assert_eq!(filled.len(), 2, "one elided posting should split into two");
1763 assert!(
1764 filled
1765 .iter()
1766 .any(|a| a.currency == "USD" && a.number == dec!(-100))
1767 );
1768 assert!(
1769 filled
1770 .iter()
1771 .any(|a| a.currency == "EUR" && a.number == dec!(-50))
1772 );
1773 }
1774
1775 #[test]
1776 fn interpolate_post_fill_residual_returns_to_zero() {
1777 let txn = Transaction::new(date(2024, 1, 1), "Split")
1781 .with_synthesized_posting(Posting::new("Expenses:A", Amount::new(dec!(100), "USD")))
1782 .with_synthesized_posting(Posting::new("Expenses:B", Amount::new(dec!(25), "USD")))
1783 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1784 let r = interpolate(&txn).expect("interpolation should succeed");
1785 assert_eq!(
1786 r.residuals.get("USD").copied(),
1787 Some(dec!(0)),
1788 "residual must be exactly zero after the elided leg is filled"
1789 );
1790 }
1791
1792 #[test]
1793 fn interpolate_preserves_subcent_residual() {
1794 let txn = Transaction::new(date(2024, 1, 1), "subcent")
1801 .with_synthesized_posting(Posting::new("Assets:A", Amount::new(dec!(1.00), "USD")))
1802 .with_synthesized_posting(Posting::new("Assets:B", Amount::new(dec!(-1.00), "USD")))
1803 .with_synthesized_posting(
1804 Posting::new("Assets:Stock", Amount::new(dec!(1), "STK")).with_price(
1805 rustledger_core::PriceAnnotation::unit(Amount::new(dec!(0.001), "USD")),
1806 ),
1807 )
1808 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1809 let r = interpolate(&txn).expect("interpolation should succeed");
1810 let cash = r
1811 .filled_indices
1812 .iter()
1813 .map(|&i| get_amount(&r.transaction.postings[i]).expect("filled"))
1814 .find(|a| a.currency == "USD")
1815 .expect("a USD fill");
1816 assert_eq!(
1817 cash.number,
1818 dec!(-0.001),
1819 "sub-cent residual must be preserved, not rounded to zero"
1820 );
1821 }
1822
1823 #[test]
1824 fn interpolate_currency_only_fill_zeroes_residual() {
1825 let txn = Transaction::new(date(2024, 1, 1), "currency-only")
1829 .with_synthesized_posting(Posting::new("Expenses:X", Amount::new(dec!(100), "USD")))
1830 .with_synthesized_posting(Posting::with_incomplete(
1831 "Assets:Cash",
1832 IncompleteAmount::CurrencyOnly("USD".into()),
1833 ));
1834 let r = interpolate(&txn).expect("interpolation should succeed");
1835 let cash = get_amount(&r.transaction.postings[1]).expect("filled");
1836 assert_eq!(cash.number, dec!(-100));
1837 assert_eq!(r.residuals.get("USD").copied(), Some(dec!(0)));
1838 }
1839
1840 #[test]
1841 fn interpolate_number_only_infers_currency_and_balances() {
1842 let txn = Transaction::new(date(2024, 1, 1), "number-only")
1850 .with_synthesized_posting(Posting::new("Expenses:X", Amount::new(dec!(100), "USD")))
1851 .with_synthesized_posting(
1852 Posting::with_incomplete("Assets:Cash", IncompleteAmount::NumberOnly(dec!(-100)))
1853 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1854 dec!(1),
1855 "USD",
1856 ))),
1857 );
1858 let r = interpolate(&txn).expect("interpolation should succeed");
1859 assert_eq!(
1860 r.residuals.get("USD").copied(),
1861 Some(dec!(0)),
1862 "NumberOnly leg's number must net the residual to zero"
1863 );
1864 }
1865}