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 = posting.cost.as_ref().and_then(|cost_spec| {
209 let inferred_currency = cost_spec
211 .currency
212 .clone()
213 .or_else(|| crate::price_currency_of(posting))
214 .or_else(|| get_inferred_currency(&mut inferred_cost_currency));
215
216 let cost_curr = inferred_currency.as_ref()?;
217 match cost_spec.number {
218 Some(rustledger_core::CostNumber::PerUnit { value: per_unit }) => {
219 let cost_amount = amount.number * per_unit;
220 Some((cost_curr.clone(), cost_amount))
221 }
222 Some(rustledger_core::CostNumber::Total { value: total }) => {
223 Some((cost_curr.clone(), total * amount.number.signum()))
227 }
228 Some(rustledger_core::CostNumber::PerUnitFromTotal(b)) => {
229 Some((cost_curr.clone(), b.total * amount.number.signum()))
233 }
234 None => None, }
236 });
237
238 if let Some((currency, cost_amount)) = cost_contribution {
239 *residuals.entry(currency).or_default() += cost_amount;
245 } else if posting.cost.is_some() {
246 let cost_currency = posting
258 .cost
259 .as_ref()
260 .and_then(|c| c.currency.clone())
261 .or_else(|| crate::price_currency_of(posting))
262 .or_else(|| get_inferred_currency(&mut inferred_cost_currency));
263 if let Some(curr) = cost_currency {
264 *cost_unknowns_by_currency.entry(curr).or_default() += 1;
265 }
266 } else if let Some(price) = &posting.price {
267 if let Some(price_amt) =
274 price.amount.as_ref().and_then(IncompleteAmount::as_amount)
275 {
276 let (curr, signed) = match price.kind {
277 rustledger_core::PriceKind::Unit => (
278 price_amt.currency.clone(),
279 amount.number.abs() * price_amt.number * amount.number.signum(),
280 ),
281 rustledger_core::PriceKind::Total => {
282 let scale = price_amt.number.scale();
283 if scale > 0 {
284 max_scale_by_currency
285 .entry(price_amt.currency.clone())
286 .and_modify(|s| *s = (*s).max(scale))
287 .or_insert(scale);
288 }
289 (
290 price_amt.currency.clone(),
291 price_amt.number * amount.number.signum(),
292 )
293 }
294 };
295 *residuals.entry(curr).or_default() += signed;
296 } else {
297 *residuals.entry(amount.currency.clone()).or_default() += amount.number;
299 }
300 } else {
301 *residuals.entry(amount.currency.clone()).or_default() += amount.number;
303 }
304 }
305 Some(IncompleteAmount::CurrencyOnly(currency)) => {
306 missing_by_currency
308 .entry(currency.clone())
309 .or_default()
310 .push(i);
311 }
312 Some(IncompleteAmount::NumberOnly(number)) => {
313 let currency = posting
316 .cost
317 .as_ref()
318 .and_then(|c| c.currency.clone())
319 .or_else(|| {
320 posting
324 .price
325 .as_ref()
326 .and_then(|p| p.amount.as_ref())
327 .and_then(IncompleteAmount::as_amount)
328 .map(|a| a.currency.clone())
329 });
330
331 if let Some(curr) = currency {
332 *residuals.entry(curr.clone()).or_default() += *number;
334 } else {
335 unassigned_missing.push(i);
337 }
338 }
339 None => {
340 if let Some(cost_spec) = &posting.cost
342 && let Some(currency) = &cost_spec.currency
343 {
344 missing_by_currency
345 .entry(currency.clone())
346 .or_default()
347 .push(i);
348 continue;
349 }
350 unassigned_missing.push(i);
352 }
353 }
354 }
355
356 let mut currencies_with_unknowns: Vec<&Currency> = missing_by_currency
367 .keys()
368 .chain(cost_unknowns_by_currency.keys())
369 .collect();
370 currencies_with_unknowns.sort_by(|a, b| a.as_str().cmp(b.as_str()));
371 currencies_with_unknowns.dedup();
372 for currency in currencies_with_unknowns {
373 let missing_count = missing_by_currency
374 .get(currency)
375 .map_or(0, std::vec::Vec::len);
376 let cost_unknown_count = cost_unknowns_by_currency
377 .get(currency)
378 .copied()
379 .unwrap_or(0);
380 let total = missing_count + cost_unknown_count;
381 if total > 1 {
382 return Err(InterpolationError::MultipleMissing {
383 currency: currency.clone(),
384 count: total,
385 });
386 }
387 }
388
389 if !unassigned_missing.is_empty() {
406 let mut cost_unknown_keys: Vec<&Currency> = cost_unknowns_by_currency.keys().collect();
407 cost_unknown_keys.sort_by(|a, b| a.as_str().cmp(b.as_str()));
408 if let Some(curr) = cost_unknown_keys.first() {
409 let count = cost_unknowns_by_currency.get(*curr).copied().unwrap_or(0);
410 return Err(InterpolationError::MultipleMissing {
411 currency: (*curr).clone(),
412 count: count + unassigned_missing.len(),
413 });
414 }
415 }
416
417 for (currency, indices) in missing_by_currency {
419 let idx = indices[0];
420 let residual = residuals.get(¤cy).copied().unwrap_or(Decimal::ZERO);
421
422 let interpolated =
423 round_interpolated(residual, max_scale_by_currency.get(¤cy).copied());
424
425 result.postings[idx].units = Some(IncompleteAmount::Complete(Amount::new(
426 interpolated,
427 ¤cy,
428 )));
429 filled_indices.push(idx);
430
431 *residuals.entry(currency).or_default() += interpolated;
433 }
434
435 if !unassigned_missing.is_empty() {
438 let non_zero_residuals: Vec<(Currency, Decimal)> = residuals
440 .iter()
441 .filter(|&(_, v)| !v.is_zero())
442 .map(|(k, v)| (k.clone(), *v))
443 .collect();
444
445 if unassigned_missing.len() == 1 && non_zero_residuals.len() > 1 {
448 let idx = unassigned_missing[0];
449 let original_posting = &transaction.postings[idx];
450
451 let (first_currency, first_residual) = &non_zero_residuals[0];
453 let interpolated = round_interpolated(
454 *first_residual,
455 max_scale_by_currency.get(first_currency).copied(),
456 );
457 result.postings[idx].units = Some(IncompleteAmount::Complete(Amount::new(
458 interpolated,
459 first_currency,
460 )));
461 filled_indices.push(idx);
462 *residuals.entry(first_currency.clone()).or_default() += interpolated;
463
464 for (currency, residual) in non_zero_residuals.iter().skip(1) {
466 let mut new_posting = original_posting.clone();
467 let interpolated =
468 round_interpolated(*residual, max_scale_by_currency.get(currency).copied());
469 new_posting.units = Some(IncompleteAmount::Complete(Amount::new(
470 interpolated,
471 currency,
472 )));
473 result.postings.push(new_posting);
474 filled_indices.push(result.postings.len() - 1);
475 *residuals.entry(currency.clone()).or_default() += interpolated;
476 }
477 } else {
478 if unassigned_missing.len() > non_zero_residuals.len() && !non_zero_residuals.is_empty()
482 {
483 let (currency, _) = &non_zero_residuals[0];
484 return Err(InterpolationError::MultipleMissing {
485 currency: currency.clone(),
486 count: unassigned_missing.len(),
487 });
488 }
489
490 for (i, idx) in unassigned_missing.iter().enumerate() {
492 if i < non_zero_residuals.len() {
493 let (currency, residual) = &non_zero_residuals[i];
494 let interpolated =
495 round_interpolated(*residual, max_scale_by_currency.get(currency).copied());
496 result.postings[*idx].units = Some(IncompleteAmount::Complete(Amount::new(
497 interpolated,
498 currency,
499 )));
500 filled_indices.push(*idx);
501 *residuals.entry(currency.clone()).or_default() += interpolated;
502 } else if !non_zero_residuals.is_empty() {
503 let (currency, _) = &non_zero_residuals[0];
505 result.postings[*idx].units =
506 Some(IncompleteAmount::Complete(Amount::zero(currency)));
507 filled_indices.push(*idx);
508 } else if let Some(currency) = get_inferred_currency(&mut inferred_cost_currency) {
509 result.postings[*idx].units =
515 Some(IncompleteAmount::Complete(Amount::zero(¤cy)));
516 filled_indices.push(*idx);
517 } else {
518 return Err(InterpolationError::CannotInferCurrency {
520 account: transaction.postings[*idx].account.clone(),
521 });
522 }
523 }
524 }
525 }
526
527 let mut indices_to_remove: Vec<usize> = filled_indices
545 .iter()
546 .filter(|&&idx| {
547 result.postings.get(idx).is_some_and(|p| {
548 p.units
549 .as_ref()
550 .and_then(|u| u.as_amount())
551 .is_some_and(|a| a.number.is_zero())
552 })
553 })
554 .copied()
555 .collect();
556 indices_to_remove.sort_unstable_by(|a, b| b.cmp(a));
557
558 for idx in &indices_to_remove {
559 result.postings.remove(*idx);
560 }
561
562 let final_filled_indices: Vec<usize> = filled_indices
565 .into_iter()
566 .filter(|idx| !indices_to_remove.contains(idx))
567 .map(|idx| {
568 let adjustment = indices_to_remove.iter().filter(|&&r| r < idx).count();
569 idx - adjustment
570 })
571 .collect();
572
573 Ok(InterpolationResult {
576 transaction: result,
577 filled_indices: final_filled_indices,
578 residuals,
579 })
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use rust_decimal_macros::dec;
586 use rustledger_core::{NaiveDate, Posting};
587
588 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
589 rustledger_core::naive_date(year, month, day).unwrap()
590 }
591
592 fn get_amount(posting: &rustledger_core::Posting) -> Option<&Amount> {
594 posting.units.as_ref().and_then(|u| u.as_amount())
595 }
596
597 #[test]
598 fn test_interpolate_simple() {
599 let txn = Transaction::new(date(2024, 1, 15), "Test")
600 .with_synthesized_posting(Posting::new(
601 "Expenses:Food",
602 Amount::new(dec!(50.00), "USD"),
603 ))
604 .with_synthesized_posting(Posting::auto("Assets:Cash"));
605
606 let result = interpolate(&txn).unwrap();
607
608 assert_eq!(result.filled_indices, vec![1]);
609
610 let filled = &result.transaction.postings[1];
611 let amount = get_amount(filled).expect("should have amount");
612 assert_eq!(amount.number, dec!(-50.00));
613 assert_eq!(amount.currency, "USD");
614 }
615
616 #[test]
617 fn test_interpolate_multiple_postings() {
618 let txn = Transaction::new(date(2024, 1, 15), "Test")
619 .with_synthesized_posting(Posting::new(
620 "Expenses:Food",
621 Amount::new(dec!(30.00), "USD"),
622 ))
623 .with_synthesized_posting(Posting::new(
624 "Expenses:Drink",
625 Amount::new(dec!(20.00), "USD"),
626 ))
627 .with_synthesized_posting(Posting::auto("Assets:Cash"));
628
629 let result = interpolate(&txn).unwrap();
630
631 let filled = &result.transaction.postings[2];
632 let amount = get_amount(filled).expect("should have amount");
633 assert_eq!(amount.number, dec!(-50.00));
634 }
635
636 #[test]
637 fn test_interpolate_no_missing() {
638 let txn = Transaction::new(date(2024, 1, 15), "Test")
639 .with_synthesized_posting(Posting::new(
640 "Expenses:Food",
641 Amount::new(dec!(50.00), "USD"),
642 ))
643 .with_synthesized_posting(Posting::new(
644 "Assets:Cash",
645 Amount::new(dec!(-50.00), "USD"),
646 ));
647
648 let result = interpolate(&txn).unwrap();
649
650 assert!(result.filled_indices.is_empty());
651 }
652
653 #[test]
654 fn test_interpolate_multiple_currencies() {
655 let txn = Transaction::new(date(2024, 1, 15), "Test")
656 .with_synthesized_posting(Posting::new(
657 "Expenses:Food",
658 Amount::new(dec!(50.00), "USD"),
659 ))
660 .with_synthesized_posting(Posting::new(
661 "Expenses:Travel",
662 Amount::new(dec!(100.00), "EUR"),
663 ))
664 .with_synthesized_posting(Posting::new(
665 "Assets:Cash:USD",
666 Amount::new(dec!(-50.00), "USD"),
667 ))
668 .with_synthesized_posting(Posting::auto("Assets:Cash:EUR"));
669
670 let result = interpolate(&txn).unwrap();
671
672 let filled = &result.transaction.postings[3];
673 let amount = get_amount(filled).expect("should have amount");
674 assert_eq!(amount.number, dec!(-100.00));
675 assert_eq!(amount.currency, "EUR");
676 }
677
678 #[test]
679 fn test_interpolate_error_multiple_missing_same_currency() {
680 let txn = Transaction::new(date(2024, 1, 15), "Test")
681 .with_synthesized_posting(Posting::new(
682 "Expenses:Food",
683 Amount::new(dec!(50.00), "USD"),
684 ))
685 .with_synthesized_posting(Posting::auto("Assets:Cash"))
686 .with_synthesized_posting(Posting::auto("Assets:Bank"));
687
688 let result = interpolate(&txn);
691 assert!(
692 matches!(result, Err(InterpolationError::MultipleMissing { .. })),
693 "expected MultipleMissing error, got: {result:?}"
694 );
695 }
696
697 #[test]
698 fn test_interpolate_multiple_missing_different_currencies_ok() {
699 let txn = Transaction::new(date(2024, 1, 15), "Multi-currency")
701 .with_synthesized_posting(Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD")))
702 .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")))
703 .with_synthesized_posting(Posting::auto("Liabilities:CreditCard"))
704 .with_synthesized_posting(Posting::auto("Equity:Exchange"));
705
706 let result = interpolate(&txn);
708 assert!(
709 result.is_ok(),
710 "expected success for different-currency elision, got: {result:?}"
711 );
712 }
713
714 #[test]
715 fn test_interpolate_with_per_unit_cost() {
716 let txn = Transaction::new(date(2015, 10, 2), "Buy stock")
722 .with_synthesized_posting(
723 Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
724 rustledger_core::CostSpec::empty()
725 .with_number(rustledger_core::CostNumber::PerUnit {
726 value: dec!(100.00),
727 })
728 .with_currency("USD"),
729 ),
730 )
731 .with_synthesized_posting(Posting::auto("Assets:Cash"));
732
733 let result = interpolate(&txn).expect("interpolation should succeed");
734
735 assert_eq!(result.filled_indices, vec![1]);
737
738 let filled = &result.transaction.postings[1];
740 let amount = get_amount(filled).expect("should have amount");
741 assert_eq!(
742 amount.currency, "USD",
743 "should be USD (cost currency), not HOOL"
744 );
745 assert_eq!(
746 amount.number,
747 dec!(-1000.00),
748 "should be -1000 USD (10 * 100)"
749 );
750
751 let residual = result
753 .residuals
754 .get("USD")
755 .copied()
756 .unwrap_or(Decimal::ZERO);
757 assert!(
758 residual.abs() < dec!(0.01),
759 "USD residual should be ~0, got {residual}"
760 );
761 assert!(
763 !result.residuals.contains_key("HOOL"),
764 "should not have HOOL residual"
765 );
766 }
767
768 #[test]
769 fn test_interpolate_with_total_cost() {
770 let txn = Transaction::new(date(2015, 10, 2), "Buy stock")
776 .with_synthesized_posting(
777 Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
778 rustledger_core::CostSpec::empty()
779 .with_number(rustledger_core::CostNumber::Total {
780 value: dec!(1000.00),
781 })
782 .with_currency("USD"),
783 ),
784 )
785 .with_synthesized_posting(Posting::auto("Assets:Cash"));
786
787 let result = interpolate(&txn).expect("interpolation should succeed");
788
789 let filled = &result.transaction.postings[1];
790 let amount = get_amount(filled).expect("should have amount");
791 assert_eq!(amount.currency, "USD");
792 assert_eq!(amount.number, dec!(-1000.00));
793 }
794
795 #[test]
796 fn test_interpolate_stock_purchase_with_commission() {
797 let txn = Transaction::new(date(2013, 2, 3), "Bought some stock")
805 .with_synthesized_posting(
806 Posting::new("Assets:Stock", Amount::new(dec!(8), "HOOL")).with_cost(
807 rustledger_core::CostSpec::empty()
808 .with_number(rustledger_core::CostNumber::PerUnit {
809 value: dec!(701.20),
810 })
811 .with_currency("USD"),
812 ),
813 )
814 .with_synthesized_posting(Posting::new(
815 "Expenses:Commission",
816 Amount::new(dec!(7.95), "USD"),
817 ))
818 .with_synthesized_posting(Posting::auto("Assets:Cash"));
819
820 let result = interpolate(&txn).expect("interpolation should succeed");
821
822 let filled = &result.transaction.postings[2];
823 let amount = get_amount(filled).expect("should have amount");
824 assert_eq!(amount.currency, "USD");
825 assert_eq!(amount.number, dec!(-5617.55));
827 }
828
829 #[test]
830 fn test_interpolate_stock_sale_with_cost_and_price() {
831 let txn = Transaction::new(date(2015, 10, 2), "Sell stock")
842 .with_synthesized_posting(
843 Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
844 .with_cost(
845 rustledger_core::CostSpec::empty()
846 .with_number(rustledger_core::CostNumber::PerUnit {
847 value: dec!(100.00),
848 })
849 .with_currency("USD"),
850 )
851 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
852 dec!(120.00),
853 "USD",
854 ))),
855 )
856 .with_synthesized_posting(Posting::new(
857 "Assets:Cash",
858 Amount::new(dec!(1200.00), "USD"),
859 ))
860 .with_synthesized_posting(Posting::auto("Income:Gains"));
861
862 let result = interpolate(&txn).expect("interpolation should succeed");
863
864 let filled = &result.transaction.postings[2];
865 let amount = get_amount(filled).expect("should have amount");
866 assert_eq!(amount.currency, "USD");
867 assert_eq!(amount.number, dec!(-200.00));
869 }
870
871 #[test]
872 fn test_interpolate_balanced_with_cost_no_interpolation_needed() {
873 let txn = Transaction::new(date(2015, 10, 2), "Buy stock")
878 .with_synthesized_posting(
879 Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
880 rustledger_core::CostSpec::empty()
881 .with_number(rustledger_core::CostNumber::PerUnit {
882 value: dec!(100.00),
883 })
884 .with_currency("USD"),
885 ),
886 )
887 .with_synthesized_posting(Posting::new(
888 "Assets:Cash",
889 Amount::new(dec!(-1000.00), "USD"),
890 ));
891
892 let result = interpolate(&txn).expect("interpolation should succeed");
893
894 assert!(result.filled_indices.is_empty());
896
897 let residual = result
899 .residuals
900 .get("USD")
901 .copied()
902 .unwrap_or(Decimal::ZERO);
903 assert!(residual.abs() < dec!(0.01));
904 }
905
906 #[test]
907 fn test_interpolate_negative_cost_units_sale() {
908 let txn = Transaction::new(date(2015, 10, 2), "Sell stock")
915 .with_synthesized_posting(
916 Posting::new("Assets:Stock", Amount::new(dec!(-5), "HOOL")).with_cost(
917 rustledger_core::CostSpec::empty()
918 .with_number(rustledger_core::CostNumber::PerUnit {
919 value: dec!(100.00),
920 })
921 .with_currency("USD"),
922 ),
923 )
924 .with_synthesized_posting(Posting::auto("Assets:Cash"));
925
926 let result = interpolate(&txn).expect("interpolation should succeed");
927
928 let filled = &result.transaction.postings[1];
929 let amount = get_amount(filled).expect("should have amount");
930 assert_eq!(amount.currency, "USD");
931 assert_eq!(amount.number, dec!(500.00)); }
933
934 #[test]
939 fn test_interpolate_multi_currency_single_elided() {
940 let txn = Transaction::new(date(2008, 4, 2), "Gilbert paid back for iPhone")
950 .with_synthesized_posting(Posting::new(
951 "Assets:Cash",
952 Amount::new(dec!(440.00), "CAD"),
953 ))
954 .with_synthesized_posting(Posting::new(
955 "Assets:AccountsReceivable",
956 Amount::new(dec!(-431.92), "USD"),
957 ))
958 .with_synthesized_posting(Posting::auto("Assets:Cash"));
959
960 let result = interpolate(&txn).expect("interpolation should succeed");
961
962 assert_eq!(
964 result.transaction.postings.len(),
965 4,
966 "should split elided posting into 2"
967 );
968
969 for (currency, residual) in &result.residuals {
971 assert!(
972 residual.abs() < dec!(0.01),
973 "{currency} residual should be ~0, got {residual}"
974 );
975 }
976
977 let mut found_cad = false;
979 let mut found_usd = false;
980 for posting in &result.transaction.postings {
981 if let Some(amount) = get_amount(posting)
982 && posting.account.as_str() == "Assets:Cash"
983 {
984 if amount.currency == "CAD" && amount.number == dec!(-440.00) {
985 found_cad = true;
986 } else if amount.currency == "USD" && amount.number == dec!(431.92) {
987 found_usd = true;
988 }
989 }
990 }
991 assert!(found_cad, "should have -440.00 CAD posting");
992 assert!(found_usd, "should have 431.92 USD posting");
993 }
994
995 #[test]
996 fn test_interpolate_multi_currency_three_currencies() {
997 let txn = Transaction::new(date(2024, 1, 15), "Multi-currency test")
999 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(100), "USD")))
1000 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(200), "EUR")))
1001 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(300), "GBP")))
1002 .with_synthesized_posting(Posting::auto("Equity:Opening"));
1003
1004 let result = interpolate(&txn).expect("interpolation should succeed");
1005
1006 assert_eq!(result.transaction.postings.len(), 6);
1008
1009 for (currency, residual) in &result.residuals {
1011 assert!(
1012 residual.abs() < dec!(0.01),
1013 "{currency} residual should be ~0, got {residual}"
1014 );
1015 }
1016 }
1017
1018 #[test]
1025 fn test_interpolate_cost_currency_inferred_from_other_posting() {
1026 let txn = Transaction::new(date(2026, 1, 1), "Opening balance")
1033 .with_synthesized_posting(
1034 Posting::new(
1035 "Assets:Vanguard:IRA:Trad:VFIFX",
1036 Amount::new(dec!(10), "VFIFX"),
1037 )
1038 .with_cost(
1039 rustledger_core::CostSpec::empty()
1040 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) }),
1041 ),
1042 )
1043 .with_synthesized_posting(Posting::new(
1044 "Equity:Opening-Balances",
1045 Amount::new(dec!(-1000), "USD"),
1046 ));
1047
1048 let result = interpolate(&txn).expect("interpolation should succeed");
1049
1050 let residual = result
1052 .residuals
1053 .get("USD")
1054 .copied()
1055 .unwrap_or(Decimal::ZERO);
1056 assert!(
1057 residual.abs() < dec!(0.01),
1058 "USD residual should be ~0, got {residual}"
1059 );
1060 }
1061
1062 #[test]
1064 fn test_interpolate_cost_currency_inferred_elided_cash() {
1065 let txn = Transaction::new(date(2026, 1, 1), "Opening balance")
1072 .with_synthesized_posting(
1073 Posting::new(
1074 "Assets:Vanguard:IRA:Trad:VFIFX",
1075 Amount::new(dec!(10), "VFIFX"),
1076 )
1077 .with_cost(
1078 rustledger_core::CostSpec::empty()
1079 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) }),
1080 ),
1081 )
1082 .with_synthesized_posting(Posting::new(
1083 "Equity:Opening-Balances",
1084 Amount::new(dec!(-1000), "USD"),
1085 ));
1086
1087 let result = interpolate(&txn).expect("interpolation should succeed");
1088
1089 assert!(result.filled_indices.is_empty());
1091
1092 let residual = result
1094 .residuals
1095 .get("USD")
1096 .copied()
1097 .unwrap_or(Decimal::ZERO);
1098 assert!(
1099 residual.abs() < dec!(0.01),
1100 "USD residual should be ~0, got {residual}"
1101 );
1102 }
1103
1104 #[test]
1114 fn test_interpolate_rounds_to_quantum() {
1115 let txn = Transaction::new(date(2026, 1, 2), "Test")
1125 .with_synthesized_posting(Posting::auto("Assets:Cash"))
1126 .with_synthesized_posting(
1127 Posting::new("Assets:Abc", Amount::new(dec!(12.3340), "ABC")).with_cost(
1128 rustledger_core::CostSpec::empty()
1129 .with_number(rustledger_core::CostNumber::PerUnit {
1130 value: dec!(140.02),
1131 })
1132 .with_currency("USD"),
1133 ),
1134 )
1135 .with_synthesized_posting(Posting::new(
1136 "Expenses:Abc",
1137 Amount::new(dec!(-0.01), "USD"),
1138 ));
1139
1140 let result = interpolate(&txn).expect("interpolation should succeed");
1141
1142 assert_eq!(result.filled_indices, vec![0]);
1144
1145 let filled = &result.transaction.postings[0];
1148 let amount = get_amount(filled).expect("should have amount");
1149 assert_eq!(amount.currency, "USD");
1150 assert_eq!(
1151 amount.number,
1152 dec!(-1727.00),
1153 "should be -1727.00 USD (rounded to 2 decimal places)"
1154 );
1155
1156 let residual = result
1158 .residuals
1159 .get("USD")
1160 .copied()
1161 .unwrap_or(Decimal::ZERO);
1162 assert_eq!(
1163 residual,
1164 dec!(-0.003320),
1165 "residual should be -0.003320 USD"
1166 );
1167 }
1168
1169 #[test]
1171 fn test_interpolate_uses_max_scale() {
1172 let txn = Transaction::new(date(2024, 1, 15), "Test")
1175 .with_synthesized_posting(Posting::new("Expenses:A", Amount::new(dec!(0.1), "USD")))
1176 .with_synthesized_posting(Posting::new("Expenses:B", Amount::new(dec!(0.001), "USD")))
1177 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1178
1179 let result = interpolate(&txn).expect("interpolation should succeed");
1180
1181 let filled = &result.transaction.postings[2];
1182 let amount = get_amount(filled).expect("should have amount");
1183
1184 assert_eq!(amount.number, dec!(-0.101));
1186 assert_eq!(amount.number.scale(), 3);
1188 }
1189
1190 #[test]
1201 fn test_interpolate_cost_scale_preserved() {
1202 let txn = Transaction::new(date(2026, 1, 19), "Buy stock")
1213 .with_synthesized_posting(
1214 Posting::new("Assets:Stock", Amount::new(dec!(1), "CSU")).with_cost(
1215 rustledger_core::CostSpec::empty()
1216 .with_number(rustledger_core::CostNumber::PerUnit {
1217 value: dec!(2800.01),
1218 })
1219 .with_currency("CAD"),
1220 ),
1221 )
1222 .with_synthesized_posting(Posting::new(
1223 "Expenses:Commission",
1224 Amount::new(dec!(1), "CAD"),
1225 ))
1226 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1227
1228 let result = interpolate(&txn).expect("interpolation should succeed");
1229
1230 assert_eq!(result.filled_indices, vec![2]);
1232
1233 let filled = &result.transaction.postings[2];
1235 let amount = get_amount(filled).expect("should have amount");
1236 assert_eq!(amount.currency, "CAD");
1237 assert_eq!(
1238 amount.number,
1239 dec!(-2801.01),
1240 "should be -2801.01 CAD (preserving cost spec precision)"
1241 );
1242
1243 let residual = result
1245 .residuals
1246 .get("CAD")
1247 .copied()
1248 .unwrap_or(Decimal::ZERO);
1249 assert!(
1250 residual.is_zero(),
1251 "CAD residual should be 0, got {residual}"
1252 );
1253 }
1254
1255 #[test]
1279 fn test_interpolate_balanced_cost_prunes_zero_posting() {
1280 let txn = Transaction::new(date(2022, 4, 16), "Trade")
1281 .with_synthesized_posting(
1282 Posting::new("Assets:Crypto", Amount::new(dec!(100), "USDC")).with_cost(
1283 rustledger_core::CostSpec::empty()
1284 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.0) })
1285 .with_currency("USD"),
1286 ),
1287 )
1288 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-100), "USD")))
1289 .with_synthesized_posting(Posting::auto("Income:Trading"));
1290
1291 let result = interpolate(&txn).expect("interpolation should succeed");
1292
1293 assert!(
1294 result.filled_indices.is_empty(),
1295 "zero-amount filled posting should have been pruned"
1296 );
1297 assert_eq!(
1298 result.transaction.postings.len(),
1299 2,
1300 "Income:Trading filled to 0 USD should be pruned"
1301 );
1302 assert!(
1303 !result
1304 .transaction
1305 .postings
1306 .iter()
1307 .any(|p| p.account.as_str() == "Income:Trading"),
1308 "Income:Trading should not be in postings after pruning"
1309 );
1310 }
1311
1312 #[test]
1320 fn test_interpolate_zero_cost_prunes_zero_posting() {
1321 let txn = Transaction::new(date(2022, 4, 16), "Free tokens")
1322 .with_synthesized_posting(
1323 Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
1324 rustledger_core::CostSpec::empty()
1325 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
1326 .with_currency("USD"),
1327 ),
1328 )
1329 .with_synthesized_posting(Posting::auto("Income:Bonus"));
1330
1331 let result = interpolate(&txn).expect("interpolation should succeed");
1332
1333 assert!(
1334 result.filled_indices.is_empty(),
1335 "zero-amount filled posting should have been pruned"
1336 );
1337 assert_eq!(result.transaction.postings.len(), 1);
1338 }
1339
1340 #[test]
1348 fn test_interpolate_zero_total_cost_prunes_zero_posting() {
1349 let txn = Transaction::new(date(2022, 4, 16), "Free tokens")
1350 .with_synthesized_posting(
1351 Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
1352 rustledger_core::CostSpec::empty()
1353 .with_number(rustledger_core::CostNumber::Total { value: dec!(0) })
1354 .with_currency("USD"),
1355 ),
1356 )
1357 .with_synthesized_posting(Posting::auto("Income:Bonus"));
1358
1359 let result = interpolate(&txn).expect("interpolation should succeed");
1360
1361 assert!(
1362 result.filled_indices.is_empty(),
1363 "zero-amount filled posting should have been pruned"
1364 );
1365 assert_eq!(result.transaction.postings.len(), 1);
1366 }
1367
1368 #[test]
1383 fn test_interpolate_empty_cost_spec_with_missing_amount_errors() {
1384 use rustledger_core::CostSpec;
1385
1386 let txn = Transaction::new(date(2022, 1, 12), "sell what was never bought")
1387 .with_synthesized_posting(
1388 Posting::new(
1389 "Assets:Htsec:Positions",
1390 Amount::new(dec!(-13000.00), "SH513050"),
1391 )
1392 .with_cost(CostSpec::empty()) .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1394 dec!(1.300),
1395 "CNY",
1396 ))),
1397 )
1398 .with_synthesized_posting(Posting::new(
1399 "Assets:Htsec:Cash",
1400 Amount::new(dec!(16900.00), "CNY"),
1401 ))
1402 .with_synthesized_posting(Posting::new(
1403 "Assets:Htsec:Cash",
1404 Amount::new(dec!(-0.85), "CNY"),
1405 ))
1406 .with_synthesized_posting(Posting::new(
1407 "Expenses:Htsec:Commission",
1408 Amount::new(dec!(0.85), "CNY"),
1409 ))
1410 .with_synthesized_posting(Posting::auto("Income:Htsec:PnL"));
1411
1412 let result = interpolate(&txn);
1413 assert!(
1414 matches!(result, Err(InterpolationError::MultipleMissing { .. })),
1415 "expected MultipleMissing error from empty cost spec + missing posting; got {result:?}"
1416 );
1417 if let Err(InterpolationError::MultipleMissing { currency, count }) = result {
1418 assert_eq!(currency.as_str(), "CNY");
1419 assert!(
1420 count >= 2,
1421 "expected count >= 2 unknowns in CNY group, got {count}"
1422 );
1423 }
1424 }
1425
1426 #[test]
1430 fn test_interpolate_empty_cost_spec_alone_ok() {
1431 use rustledger_core::CostSpec;
1432
1433 let txn = Transaction::new(date(2022, 1, 12), "Sell HOOL")
1434 .with_synthesized_posting(
1435 Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
1436 .with_cost(CostSpec::empty())
1437 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1438 dec!(150),
1439 "USD",
1440 ))),
1441 )
1442 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")));
1443
1444 let result = interpolate(&txn);
1445 assert!(
1446 result.is_ok(),
1447 "single empty cost spec with no missing postings should succeed; got {result:?}"
1448 );
1449 }
1450
1451 #[test]
1454 fn test_interpolate_two_empty_cost_specs_same_currency_errors() {
1455 use rustledger_core::CostSpec;
1456
1457 let txn = Transaction::new(date(2022, 1, 12), "Two unknown-cost sells")
1458 .with_synthesized_posting(
1459 Posting::new("Assets:StockA", Amount::new(dec!(-10), "AAPL"))
1460 .with_cost(CostSpec::empty())
1461 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1462 dec!(150),
1463 "USD",
1464 ))),
1465 )
1466 .with_synthesized_posting(
1467 Posting::new("Assets:StockB", Amount::new(dec!(-5), "GOOG"))
1468 .with_cost(CostSpec::empty())
1469 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1470 dec!(2000),
1471 "USD",
1472 ))),
1473 )
1474 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(11500), "USD")));
1475
1476 let result = interpolate(&txn);
1477 assert!(
1478 matches!(result, Err(InterpolationError::MultipleMissing { .. })),
1479 "two empty cost specs in same currency should error; got {result:?}"
1480 );
1481 }
1482
1483 #[test]
1488 fn test_interpolate_empty_cost_spec_with_missing_in_different_currency_ok() {
1489 use rustledger_core::CostSpec;
1490
1491 let txn = Transaction::new(date(2022, 1, 12), "Sale + currency-known absorber")
1492 .with_synthesized_posting(
1493 Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
1494 .with_cost(CostSpec::empty()) .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1496 dec!(150),
1497 "USD",
1498 ))),
1499 )
1500 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")))
1501 .with_synthesized_posting(Posting::new("Expenses:Fee", Amount::new(dec!(5), "EUR")))
1502 .with_synthesized_posting(Posting {
1503 units: Some(IncompleteAmount::CurrencyOnly("EUR".into())),
1505 ..Posting::auto("Income:Misc")
1506 });
1507
1508 let result = interpolate(&txn);
1509 assert!(
1510 result.is_ok(),
1511 "cost-unknown in USD + missing-amount in EUR should succeed (disjoint groups); \
1512 got {result:?}"
1513 );
1514 }
1515
1516 #[test]
1528 fn test_interpolate_residual_ignores_cost_spec_scale() {
1529 use rustledger_core::CostSpec;
1530
1531 let cost_spec = CostSpec {
1532 number: Some(rustledger_core::CostNumber::PerUnit {
1533 value: dec!(170.16734),
1534 }),
1535 currency: Some(Currency::from("USD")),
1536 date: None,
1537 label: None,
1538 merge: false,
1539 };
1540
1541 let txn = Transaction::new(date(2016, 2, 12), "Sell")
1542 .with_synthesized_posting(Posting::new(
1543 "Assets:Cash",
1544 Amount::new(dec!(336.73), "USD"),
1545 ))
1546 .with_synthesized_posting(
1547 Posting::new("Assets:Brokerage", Amount::new(dec!(-1.763), "STOCK"))
1548 .with_cost(cost_spec)
1549 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1550 dec!(191.00),
1551 "USD",
1552 ))),
1553 )
1554 .with_synthesized_posting(Posting::auto("Income:Capital-Gains"));
1555
1556 let result = interpolate(&txn).expect("interpolation should succeed");
1557 let filled = &result.transaction.postings[2];
1558 let amount = get_amount(filled).expect("Income should have amount");
1559
1560 assert_eq!(
1561 amount.currency.as_str(),
1562 "USD",
1563 "residual currency should be USD"
1564 );
1565 assert_eq!(
1566 amount.number.scale(),
1567 2,
1568 "residual scale must be 2 (USD precision from `336.73 USD`), \
1569 not 5 (from cost spec). Pre-fix this was 5. (#1107)"
1570 );
1571 assert_eq!(
1572 amount.number,
1573 dec!(-36.72),
1574 "residual value should match bean-query exactly (#1107). \
1575 Was -36.72498 before fix."
1576 );
1577 }
1578
1579 #[test]
1592 fn test_interpolate_residual_after_booking_total_cost_division() {
1593 use crate::book::BookingEngine;
1594 use rustledger_core::{Cost, CostSpec, IncompleteAmount, PriceAnnotation};
1595
1596 let buy = Transaction::new(date(2016, 1, 1), "Buy")
1599 .with_synthesized_posting(
1600 Posting::new("Assets:Brokerage", Amount::new(dec!(1.763), "STOCK")).with_cost(
1601 CostSpec {
1602 number: Some(rustledger_core::CostNumber::Total {
1603 value: dec!(300.00),
1604 }),
1605 currency: Some(Currency::from("USD")),
1606 date: None,
1607 label: None,
1608 merge: false,
1609 },
1610 ),
1611 )
1612 .with_synthesized_posting(Posting::new(
1613 "Assets:Cash",
1614 Amount::new(dec!(-300.00), "USD"),
1615 ));
1616
1617 let sell = Transaction::new(date(2016, 2, 12), "Sell")
1621 .with_synthesized_posting(Posting::new(
1622 "Assets:Cash",
1623 Amount::new(dec!(336.73), "USD"),
1624 ))
1625 .with_synthesized_posting(
1626 Posting::new("Assets:Brokerage", Amount::new(dec!(-1.763), "STOCK"))
1627 .with_cost(CostSpec::empty())
1628 .with_price(PriceAnnotation::unit(Amount::new(dec!(191.00), "USD"))),
1629 )
1630 .with_synthesized_posting(Posting::auto("Income:Capital-Gains"));
1631
1632 let mut engine = BookingEngine::new();
1633 engine.apply(&buy);
1634
1635 let result = engine
1640 .book_and_interpolate(&sell)
1641 .expect("booking+interpolation should succeed");
1642
1643 let income = &result.transaction.postings[2];
1644 let amount = get_amount(income).expect("Income should have an amount after interpolation");
1645
1646 assert_eq!(amount.currency.as_str(), "USD");
1647 assert!(
1648 amount.number.scale() <= 2,
1649 "residual scale must be ≤ 2 (USD's tracked precision), \
1650 not inherited from the lot's high-scale derived per_unit. \
1651 Got scale={} number={}",
1652 amount.number.scale(),
1653 amount.number
1654 );
1655
1656 let _ = Cost::new(dec!(1), "USD");
1659 let _: Option<IncompleteAmount> = None;
1660 }
1661
1662 #[test]
1668 fn test_interpolate_empty_cost_spec_with_unassigned_in_different_currency_errors() {
1669 use rustledger_core::CostSpec;
1670
1671 let txn = Transaction::new(date(2022, 1, 12), "Sale + unassigned absorber")
1672 .with_synthesized_posting(
1673 Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
1674 .with_cost(CostSpec::empty())
1675 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1676 dec!(150),
1677 "USD",
1678 ))),
1679 )
1680 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")))
1681 .with_synthesized_posting(Posting::new("Expenses:Fee", Amount::new(dec!(5), "EUR")))
1682 .with_synthesized_posting(Posting::auto("Income:Misc"));
1683
1684 let result = interpolate(&txn);
1685 assert!(
1686 matches!(result, Err(InterpolationError::MultipleMissing { .. })),
1687 "cost-unknown + unassigned-missing must error even when in different \
1688 currencies (bean-check parity); got {result:?}"
1689 );
1690 }
1691
1692 #[test]
1698 fn interpolate_unit_price_is_units_times_price() {
1699 let txn = Transaction::new(date(2024, 1, 1), "Buy")
1701 .with_synthesized_posting(
1702 Posting::new("Assets:Stock", Amount::new(dec!(10), "STK")).with_price(
1703 rustledger_core::PriceAnnotation::unit(Amount::new(dec!(3), "USD")),
1704 ),
1705 )
1706 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1707 let r = interpolate(&txn).expect("interpolation should succeed");
1708 let cash = get_amount(&r.transaction.postings[1]).expect("filled");
1709 assert_eq!(cash.currency, "USD");
1710 assert_eq!(cash.number, dec!(-30)); }
1712
1713 #[test]
1714 fn interpolate_total_price_is_total() {
1715 let txn = Transaction::new(date(2024, 1, 1), "Buy")
1717 .with_synthesized_posting(
1718 Posting::new("Assets:Stock", Amount::new(dec!(10), "STK")).with_price(
1719 rustledger_core::PriceAnnotation::total(Amount::new(dec!(30), "USD")),
1720 ),
1721 )
1722 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1723 let r = interpolate(&txn).expect("interpolation should succeed");
1724 let cash = get_amount(&r.transaction.postings[1]).expect("filled");
1725 assert_eq!(cash.number, dec!(-30)); assert_eq!(cash.currency, "USD"); }
1728
1729 #[test]
1730 fn interpolate_three_posting_residual_sum() {
1731 let txn = Transaction::new(date(2024, 1, 1), "Split")
1733 .with_synthesized_posting(Posting::new("Expenses:A", Amount::new(dec!(100), "USD")))
1734 .with_synthesized_posting(Posting::new("Expenses:B", Amount::new(dec!(25), "USD")))
1735 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1736 let r = interpolate(&txn).expect("interpolation should succeed");
1737 let cash = get_amount(&r.transaction.postings[2]).expect("filled");
1738 assert_eq!(cash.number, dec!(-125)); }
1740
1741 #[test]
1742 fn interpolate_single_elided_splits_two_currencies() {
1743 let txn = Transaction::new(date(2024, 1, 1), "FX")
1747 .with_synthesized_posting(Posting::new("Assets:USD", Amount::new(dec!(100), "USD")))
1748 .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(50), "EUR")))
1749 .with_synthesized_posting(Posting::auto("Equity:Balance"));
1750 let r = interpolate(&txn).expect("interpolation should succeed");
1751 let filled: Vec<Amount> = r
1752 .filled_indices
1753 .iter()
1754 .map(|&i| {
1755 get_amount(&r.transaction.postings[i])
1756 .expect("filled")
1757 .clone()
1758 })
1759 .collect();
1760 assert_eq!(filled.len(), 2, "one elided posting should split into two");
1761 assert!(
1762 filled
1763 .iter()
1764 .any(|a| a.currency == "USD" && a.number == dec!(-100))
1765 );
1766 assert!(
1767 filled
1768 .iter()
1769 .any(|a| a.currency == "EUR" && a.number == dec!(-50))
1770 );
1771 }
1772
1773 #[test]
1774 fn interpolate_post_fill_residual_returns_to_zero() {
1775 let txn = Transaction::new(date(2024, 1, 1), "Split")
1779 .with_synthesized_posting(Posting::new("Expenses:A", Amount::new(dec!(100), "USD")))
1780 .with_synthesized_posting(Posting::new("Expenses:B", Amount::new(dec!(25), "USD")))
1781 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1782 let r = interpolate(&txn).expect("interpolation should succeed");
1783 assert_eq!(
1784 r.residuals.get("USD").copied(),
1785 Some(dec!(0)),
1786 "residual must be exactly zero after the elided leg is filled"
1787 );
1788 }
1789
1790 #[test]
1791 fn interpolate_preserves_subcent_residual() {
1792 let txn = Transaction::new(date(2024, 1, 1), "subcent")
1799 .with_synthesized_posting(Posting::new("Assets:A", Amount::new(dec!(1.00), "USD")))
1800 .with_synthesized_posting(Posting::new("Assets:B", Amount::new(dec!(-1.00), "USD")))
1801 .with_synthesized_posting(
1802 Posting::new("Assets:Stock", Amount::new(dec!(1), "STK")).with_price(
1803 rustledger_core::PriceAnnotation::unit(Amount::new(dec!(0.001), "USD")),
1804 ),
1805 )
1806 .with_synthesized_posting(Posting::auto("Assets:Cash"));
1807 let r = interpolate(&txn).expect("interpolation should succeed");
1808 let cash = r
1809 .filled_indices
1810 .iter()
1811 .map(|&i| get_amount(&r.transaction.postings[i]).expect("filled"))
1812 .find(|a| a.currency == "USD")
1813 .expect("a USD fill");
1814 assert_eq!(
1815 cash.number,
1816 dec!(-0.001),
1817 "sub-cent residual must be preserved, not rounded to zero"
1818 );
1819 }
1820
1821 #[test]
1822 fn interpolate_currency_only_fill_zeroes_residual() {
1823 let txn = Transaction::new(date(2024, 1, 1), "currency-only")
1827 .with_synthesized_posting(Posting::new("Expenses:X", Amount::new(dec!(100), "USD")))
1828 .with_synthesized_posting(Posting::with_incomplete(
1829 "Assets:Cash",
1830 IncompleteAmount::CurrencyOnly("USD".into()),
1831 ));
1832 let r = interpolate(&txn).expect("interpolation should succeed");
1833 let cash = get_amount(&r.transaction.postings[1]).expect("filled");
1834 assert_eq!(cash.number, dec!(-100));
1835 assert_eq!(r.residuals.get("USD").copied(), Some(dec!(0)));
1836 }
1837
1838 #[test]
1839 fn interpolate_number_only_infers_currency_and_balances() {
1840 let txn = Transaction::new(date(2024, 1, 1), "number-only")
1848 .with_synthesized_posting(Posting::new("Expenses:X", Amount::new(dec!(100), "USD")))
1849 .with_synthesized_posting(
1850 Posting::with_incomplete("Assets:Cash", IncompleteAmount::NumberOnly(dec!(-100)))
1851 .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1852 dec!(1),
1853 "USD",
1854 ))),
1855 );
1856 let r = interpolate(&txn).expect("interpolation should succeed");
1857 assert_eq!(
1858 r.residuals.get("USD").copied(),
1859 Some(dec!(0)),
1860 "NumberOnly leg's number must net the residual to zero"
1861 );
1862 }
1863}