1use rust_decimal::Decimal;
6use rust_decimal::prelude::Signed;
7use rustledger_core::{Amount, IncompleteAmount, InternedStr, Transaction};
8use std::collections::HashMap;
9use thiserror::Error;
10
11#[derive(Debug, Clone, Error)]
13pub enum InterpolationError {
14 #[error("multiple postings missing amounts for currency {currency}")]
16 MultipleMissing {
17 currency: InternedStr,
19 count: usize,
21 },
22
23 #[error("cannot infer currency for posting to account {account}")]
25 CannotInferCurrency {
26 account: InternedStr,
28 },
29
30 #[error("transaction does not balance: residual {residual} {currency}")]
32 DoesNotBalance {
33 currency: InternedStr,
35 residual: Decimal,
37 },
38}
39
40#[derive(Debug, Clone)]
42pub struct InterpolationResult {
43 pub transaction: Transaction,
45 pub filled_indices: Vec<usize>,
47 pub residuals: HashMap<InternedStr, Decimal>,
49}
50
51fn round_interpolated(residual: Decimal, existing_scale: Option<u32>) -> Decimal {
54 let interpolated = -residual;
55 if let Some(scale) = existing_scale {
56 let rounded = interpolated.round_dp(scale);
57 if rounded.is_zero() && !residual.is_zero() {
59 interpolated
60 } else {
61 rounded
62 }
63 } else {
64 interpolated
65 }
66}
67
68pub fn interpolate(transaction: &Transaction) -> Result<InterpolationResult, InterpolationError> {
103 let mut result = transaction.clone();
105 let mut filled_indices = Vec::new();
106
107 let mut inferred_cost_currency: Option<Option<InternedStr>> = None;
109 let get_inferred_currency = |cache: &mut Option<Option<InternedStr>>| -> Option<InternedStr> {
110 cache
111 .get_or_insert_with(|| crate::infer_cost_currency_from_postings(transaction))
112 .clone()
113 };
114
115 let num_postings = transaction.postings.len();
118 let mut residuals: HashMap<InternedStr, Decimal> = HashMap::with_capacity(num_postings.min(4));
119 let mut missing_by_currency: HashMap<InternedStr, Vec<usize>> = HashMap::with_capacity(2);
120 let mut unassigned_missing: Vec<usize> = Vec::with_capacity(2);
121
122 let mut max_scale_by_currency: HashMap<InternedStr, u32> = HashMap::with_capacity(4);
126
127 let mut cost_scale_by_currency: HashMap<InternedStr, u32> = HashMap::with_capacity(2);
132
133 for (i, posting) in transaction.postings.iter().enumerate() {
134 match &posting.units {
135 Some(IncompleteAmount::Complete(amount)) => {
136 let scale = amount.number.scale();
138 max_scale_by_currency
139 .entry(amount.currency.clone())
140 .and_modify(|s| *s = (*s).max(scale))
141 .or_insert(scale);
142
143 let cost_contribution = posting.cost.as_ref().and_then(|cost_spec| {
156 let price_currency = posting.price.as_ref().and_then(|p| match p {
158 rustledger_core::PriceAnnotation::Unit(a)
159 | rustledger_core::PriceAnnotation::Total(a) => Some(a.currency.clone()),
160 rustledger_core::PriceAnnotation::UnitIncomplete(inc)
161 | rustledger_core::PriceAnnotation::TotalIncomplete(inc) => {
162 inc.as_amount().map(|a| a.currency.clone())
163 }
164 _ => None,
165 });
166
167 let inferred_currency = cost_spec
169 .currency
170 .clone()
171 .or(price_currency)
172 .or_else(|| get_inferred_currency(&mut inferred_cost_currency));
173
174 if let (Some(per_unit), Some(cost_curr)) =
175 (&cost_spec.number_per, &inferred_currency)
176 {
177 let cost_amount = amount.number * per_unit;
178 Some((cost_curr.clone(), cost_amount, Some(per_unit.scale())))
182 } else if let (Some(total), Some(cost_curr)) =
183 (&cost_spec.number_total, &inferred_currency)
184 {
185 Some((
188 cost_curr.clone(),
189 *total * amount.number.signum(),
190 Some(total.scale()),
191 ))
192 } else {
193 None }
195 });
196
197 if let Some((currency, cost_amount, cost_scale)) = cost_contribution {
198 if let Some(scale) = cost_scale {
202 cost_scale_by_currency
203 .entry(currency.clone())
204 .and_modify(|s| *s = (*s).max(scale))
205 .or_insert(scale);
206 }
207 *residuals.entry(currency).or_default() += cost_amount;
208 } else if let Some(price) = &posting.price {
209 match price {
213 rustledger_core::PriceAnnotation::Unit(price_amt) => {
214 let converted = amount.number.abs() * price_amt.number;
215 *residuals.entry(price_amt.currency.clone()).or_default() +=
216 converted * amount.number.signum();
217 }
218 rustledger_core::PriceAnnotation::Total(price_amt) => {
219 let scale = price_amt.number.scale();
221 max_scale_by_currency
222 .entry(price_amt.currency.clone())
223 .and_modify(|s| *s = (*s).max(scale))
224 .or_insert(scale);
225 *residuals.entry(price_amt.currency.clone()).or_default() +=
226 price_amt.number * amount.number.signum();
227 }
228 rustledger_core::PriceAnnotation::UnitIncomplete(inc) => {
229 if let Some(price_amt) = inc.as_amount() {
230 let converted = amount.number.abs() * price_amt.number;
231 *residuals.entry(price_amt.currency.clone()).or_default() +=
232 converted * amount.number.signum();
233 } else {
234 *residuals.entry(amount.currency.clone()).or_default() +=
236 amount.number;
237 }
238 }
239 rustledger_core::PriceAnnotation::TotalIncomplete(inc) => {
240 if let Some(price_amt) = inc.as_amount() {
241 let scale = price_amt.number.scale();
243 max_scale_by_currency
244 .entry(price_amt.currency.clone())
245 .and_modify(|s| *s = (*s).max(scale))
246 .or_insert(scale);
247 *residuals.entry(price_amt.currency.clone()).or_default() +=
248 price_amt.number * amount.number.signum();
249 } else {
250 *residuals.entry(amount.currency.clone()).or_default() +=
252 amount.number;
253 }
254 }
255 rustledger_core::PriceAnnotation::UnitEmpty
257 | rustledger_core::PriceAnnotation::TotalEmpty => {
258 *residuals.entry(amount.currency.clone()).or_default() += amount.number;
259 }
260 }
261 } else if posting.cost.is_some() {
262 } 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.price.as_ref().and_then(|p| match p {
285 rustledger_core::PriceAnnotation::Unit(a) => Some(a.currency.clone()),
286 rustledger_core::PriceAnnotation::Total(a) => Some(a.currency.clone()),
287 rustledger_core::PriceAnnotation::UnitIncomplete(inc)
288 | rustledger_core::PriceAnnotation::TotalIncomplete(inc) => {
289 inc.as_amount().map(|a| a.currency.clone())
290 }
291 rustledger_core::PriceAnnotation::UnitEmpty
292 | rustledger_core::PriceAnnotation::TotalEmpty => None,
293 })
294 });
295
296 if let Some(curr) = currency {
297 *residuals.entry(curr.clone()).or_default() += *number;
299 } else {
300 unassigned_missing.push(i);
302 }
303 }
304 None => {
305 if let Some(cost_spec) = &posting.cost
307 && let Some(currency) = &cost_spec.currency
308 {
309 missing_by_currency
310 .entry(currency.clone())
311 .or_default()
312 .push(i);
313 continue;
314 }
315 unassigned_missing.push(i);
317 }
318 }
319 }
320
321 for (currency, cost_scale) in cost_scale_by_currency {
325 max_scale_by_currency
326 .entry(currency)
327 .and_modify(|s| *s = (*s).max(cost_scale));
328 }
329
330 for (currency, indices) in &missing_by_currency {
332 if indices.len() > 1 {
333 return Err(InterpolationError::MultipleMissing {
334 currency: currency.clone(),
335 count: indices.len(),
336 });
337 }
338 }
339
340 for (currency, indices) in missing_by_currency {
342 let idx = indices[0];
343 let residual = residuals.get(¤cy).copied().unwrap_or(Decimal::ZERO);
344
345 let interpolated =
346 round_interpolated(residual, max_scale_by_currency.get(¤cy).copied());
347
348 result.postings[idx].units = Some(IncompleteAmount::Complete(Amount::new(
349 interpolated,
350 ¤cy,
351 )));
352 filled_indices.push(idx);
353
354 *residuals.entry(currency).or_default() += interpolated;
356 }
357
358 if !unassigned_missing.is_empty() {
361 let non_zero_residuals: Vec<(InternedStr, Decimal)> = residuals
363 .iter()
364 .filter(|&(_, v)| !v.is_zero())
365 .map(|(k, v)| (k.clone(), *v))
366 .collect();
367
368 if unassigned_missing.len() == 1 && non_zero_residuals.len() > 1 {
371 let idx = unassigned_missing[0];
372 let original_posting = &transaction.postings[idx];
373
374 let (first_currency, first_residual) = &non_zero_residuals[0];
376 let interpolated = round_interpolated(
377 *first_residual,
378 max_scale_by_currency.get(first_currency).copied(),
379 );
380 result.postings[idx].units = Some(IncompleteAmount::Complete(Amount::new(
381 interpolated,
382 first_currency,
383 )));
384 filled_indices.push(idx);
385 *residuals.entry(first_currency.clone()).or_default() += interpolated;
386
387 for (currency, residual) in non_zero_residuals.iter().skip(1) {
389 let mut new_posting = original_posting.clone();
390 let interpolated =
391 round_interpolated(*residual, max_scale_by_currency.get(currency).copied());
392 new_posting.units = Some(IncompleteAmount::Complete(Amount::new(
393 interpolated,
394 currency,
395 )));
396 result.postings.push(new_posting);
397 filled_indices.push(result.postings.len() - 1);
398 *residuals.entry(currency.clone()).or_default() += interpolated;
399 }
400 } else {
401 if unassigned_missing.len() > non_zero_residuals.len() && !non_zero_residuals.is_empty()
405 {
406 let (currency, _) = &non_zero_residuals[0];
407 return Err(InterpolationError::MultipleMissing {
408 currency: currency.clone(),
409 count: unassigned_missing.len(),
410 });
411 }
412
413 for (i, idx) in unassigned_missing.iter().enumerate() {
415 if i < non_zero_residuals.len() {
416 let (currency, residual) = &non_zero_residuals[i];
417 let interpolated =
418 round_interpolated(*residual, max_scale_by_currency.get(currency).copied());
419 result.postings[*idx].units = Some(IncompleteAmount::Complete(Amount::new(
420 interpolated,
421 currency,
422 )));
423 filled_indices.push(*idx);
424 *residuals.entry(currency.clone()).or_default() += interpolated;
425 } else if !non_zero_residuals.is_empty() {
426 let (currency, _) = &non_zero_residuals[0];
428 result.postings[*idx].units =
429 Some(IncompleteAmount::Complete(Amount::zero(currency)));
430 filled_indices.push(*idx);
431 } else if let Some(currency) = get_inferred_currency(&mut inferred_cost_currency) {
432 result.postings[*idx].units =
438 Some(IncompleteAmount::Complete(Amount::zero(¤cy)));
439 filled_indices.push(*idx);
440 } else {
441 return Err(InterpolationError::CannotInferCurrency {
443 account: transaction.postings[*idx].account.clone(),
444 });
445 }
446 }
447 }
448 }
449
450 Ok(InterpolationResult {
458 transaction: result,
459 filled_indices,
460 residuals,
461 })
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467 use rust_decimal_macros::dec;
468 use rustledger_core::{NaiveDate, Posting};
469
470 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
471 rustledger_core::naive_date(year, month, day).unwrap()
472 }
473
474 fn get_amount(posting: &rustledger_core::Posting) -> Option<&Amount> {
476 posting.units.as_ref().and_then(|u| u.as_amount())
477 }
478
479 #[test]
480 fn test_interpolate_simple() {
481 let txn = Transaction::new(date(2024, 1, 15), "Test")
482 .with_posting(Posting::new(
483 "Expenses:Food",
484 Amount::new(dec!(50.00), "USD"),
485 ))
486 .with_posting(Posting::auto("Assets:Cash"));
487
488 let result = interpolate(&txn).unwrap();
489
490 assert_eq!(result.filled_indices, vec![1]);
491
492 let filled = &result.transaction.postings[1];
493 let amount = get_amount(filled).expect("should have amount");
494 assert_eq!(amount.number, dec!(-50.00));
495 assert_eq!(amount.currency, "USD");
496 }
497
498 #[test]
499 fn test_interpolate_multiple_postings() {
500 let txn = Transaction::new(date(2024, 1, 15), "Test")
501 .with_posting(Posting::new(
502 "Expenses:Food",
503 Amount::new(dec!(30.00), "USD"),
504 ))
505 .with_posting(Posting::new(
506 "Expenses:Drink",
507 Amount::new(dec!(20.00), "USD"),
508 ))
509 .with_posting(Posting::auto("Assets:Cash"));
510
511 let result = interpolate(&txn).unwrap();
512
513 let filled = &result.transaction.postings[2];
514 let amount = get_amount(filled).expect("should have amount");
515 assert_eq!(amount.number, dec!(-50.00));
516 }
517
518 #[test]
519 fn test_interpolate_no_missing() {
520 let txn = Transaction::new(date(2024, 1, 15), "Test")
521 .with_posting(Posting::new(
522 "Expenses:Food",
523 Amount::new(dec!(50.00), "USD"),
524 ))
525 .with_posting(Posting::new(
526 "Assets:Cash",
527 Amount::new(dec!(-50.00), "USD"),
528 ));
529
530 let result = interpolate(&txn).unwrap();
531
532 assert!(result.filled_indices.is_empty());
533 }
534
535 #[test]
536 fn test_interpolate_multiple_currencies() {
537 let txn = Transaction::new(date(2024, 1, 15), "Test")
538 .with_posting(Posting::new(
539 "Expenses:Food",
540 Amount::new(dec!(50.00), "USD"),
541 ))
542 .with_posting(Posting::new(
543 "Expenses:Travel",
544 Amount::new(dec!(100.00), "EUR"),
545 ))
546 .with_posting(Posting::new(
547 "Assets:Cash:USD",
548 Amount::new(dec!(-50.00), "USD"),
549 ))
550 .with_posting(Posting::auto("Assets:Cash:EUR"));
551
552 let result = interpolate(&txn).unwrap();
553
554 let filled = &result.transaction.postings[3];
555 let amount = get_amount(filled).expect("should have amount");
556 assert_eq!(amount.number, dec!(-100.00));
557 assert_eq!(amount.currency, "EUR");
558 }
559
560 #[test]
561 fn test_interpolate_error_multiple_missing_same_currency() {
562 let txn = Transaction::new(date(2024, 1, 15), "Test")
563 .with_posting(Posting::new(
564 "Expenses:Food",
565 Amount::new(dec!(50.00), "USD"),
566 ))
567 .with_posting(Posting::auto("Assets:Cash"))
568 .with_posting(Posting::auto("Assets:Bank"));
569
570 let result = interpolate(&txn);
573 assert!(
574 matches!(result, Err(InterpolationError::MultipleMissing { .. })),
575 "expected MultipleMissing error, got: {result:?}"
576 );
577 }
578
579 #[test]
580 fn test_interpolate_multiple_missing_different_currencies_ok() {
581 let txn = Transaction::new(date(2024, 1, 15), "Multi-currency")
583 .with_posting(Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD")))
584 .with_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")))
585 .with_posting(Posting::auto("Liabilities:CreditCard"))
586 .with_posting(Posting::auto("Equity:Exchange"));
587
588 let result = interpolate(&txn);
590 assert!(
591 result.is_ok(),
592 "expected success for different-currency elision, got: {result:?}"
593 );
594 }
595
596 #[test]
597 fn test_interpolate_with_per_unit_cost() {
598 let txn = Transaction::new(date(2015, 10, 2), "Buy stock")
604 .with_posting(
605 Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
606 rustledger_core::CostSpec::empty()
607 .with_number_per(dec!(100.00))
608 .with_currency("USD"),
609 ),
610 )
611 .with_posting(Posting::auto("Assets:Cash"));
612
613 let result = interpolate(&txn).expect("interpolation should succeed");
614
615 assert_eq!(result.filled_indices, vec![1]);
617
618 let filled = &result.transaction.postings[1];
620 let amount = get_amount(filled).expect("should have amount");
621 assert_eq!(
622 amount.currency, "USD",
623 "should be USD (cost currency), not HOOL"
624 );
625 assert_eq!(
626 amount.number,
627 dec!(-1000.00),
628 "should be -1000 USD (10 * 100)"
629 );
630
631 let residual = result
633 .residuals
634 .get("USD")
635 .copied()
636 .unwrap_or(Decimal::ZERO);
637 assert!(
638 residual.abs() < dec!(0.01),
639 "USD residual should be ~0, got {residual}"
640 );
641 assert!(
643 !result.residuals.contains_key("HOOL"),
644 "should not have HOOL residual"
645 );
646 }
647
648 #[test]
649 fn test_interpolate_with_total_cost() {
650 let txn = Transaction::new(date(2015, 10, 2), "Buy stock")
656 .with_posting(
657 Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
658 rustledger_core::CostSpec::empty()
659 .with_number_total(dec!(1000.00))
660 .with_currency("USD"),
661 ),
662 )
663 .with_posting(Posting::auto("Assets:Cash"));
664
665 let result = interpolate(&txn).expect("interpolation should succeed");
666
667 let filled = &result.transaction.postings[1];
668 let amount = get_amount(filled).expect("should have amount");
669 assert_eq!(amount.currency, "USD");
670 assert_eq!(amount.number, dec!(-1000.00));
671 }
672
673 #[test]
674 fn test_interpolate_stock_purchase_with_commission() {
675 let txn = Transaction::new(date(2013, 2, 3), "Bought some stock")
683 .with_posting(
684 Posting::new("Assets:Stock", Amount::new(dec!(8), "HOOL")).with_cost(
685 rustledger_core::CostSpec::empty()
686 .with_number_per(dec!(701.20))
687 .with_currency("USD"),
688 ),
689 )
690 .with_posting(Posting::new(
691 "Expenses:Commission",
692 Amount::new(dec!(7.95), "USD"),
693 ))
694 .with_posting(Posting::auto("Assets:Cash"));
695
696 let result = interpolate(&txn).expect("interpolation should succeed");
697
698 let filled = &result.transaction.postings[2];
699 let amount = get_amount(filled).expect("should have amount");
700 assert_eq!(amount.currency, "USD");
701 assert_eq!(amount.number, dec!(-5617.55));
703 }
704
705 #[test]
706 fn test_interpolate_stock_sale_with_cost_and_price() {
707 let txn = Transaction::new(date(2015, 10, 2), "Sell stock")
718 .with_posting(
719 Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
720 .with_cost(
721 rustledger_core::CostSpec::empty()
722 .with_number_per(dec!(100.00))
723 .with_currency("USD"),
724 )
725 .with_price(rustledger_core::PriceAnnotation::Unit(Amount::new(
726 dec!(120.00),
727 "USD",
728 ))),
729 )
730 .with_posting(Posting::new(
731 "Assets:Cash",
732 Amount::new(dec!(1200.00), "USD"),
733 ))
734 .with_posting(Posting::auto("Income:Gains"));
735
736 let result = interpolate(&txn).expect("interpolation should succeed");
737
738 let filled = &result.transaction.postings[2];
739 let amount = get_amount(filled).expect("should have amount");
740 assert_eq!(amount.currency, "USD");
741 assert_eq!(amount.number, dec!(-200.00));
743 }
744
745 #[test]
746 fn test_interpolate_balanced_with_cost_no_interpolation_needed() {
747 let txn = Transaction::new(date(2015, 10, 2), "Buy stock")
752 .with_posting(
753 Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
754 rustledger_core::CostSpec::empty()
755 .with_number_per(dec!(100.00))
756 .with_currency("USD"),
757 ),
758 )
759 .with_posting(Posting::new(
760 "Assets:Cash",
761 Amount::new(dec!(-1000.00), "USD"),
762 ));
763
764 let result = interpolate(&txn).expect("interpolation should succeed");
765
766 assert!(result.filled_indices.is_empty());
768
769 let residual = result
771 .residuals
772 .get("USD")
773 .copied()
774 .unwrap_or(Decimal::ZERO);
775 assert!(residual.abs() < dec!(0.01));
776 }
777
778 #[test]
779 fn test_interpolate_negative_cost_units_sale() {
780 let txn = Transaction::new(date(2015, 10, 2), "Sell stock")
787 .with_posting(
788 Posting::new("Assets:Stock", Amount::new(dec!(-5), "HOOL")).with_cost(
789 rustledger_core::CostSpec::empty()
790 .with_number_per(dec!(100.00))
791 .with_currency("USD"),
792 ),
793 )
794 .with_posting(Posting::auto("Assets:Cash"));
795
796 let result = interpolate(&txn).expect("interpolation should succeed");
797
798 let filled = &result.transaction.postings[1];
799 let amount = get_amount(filled).expect("should have amount");
800 assert_eq!(amount.currency, "USD");
801 assert_eq!(amount.number, dec!(500.00)); }
803
804 #[test]
809 fn test_interpolate_multi_currency_single_elided() {
810 let txn = Transaction::new(date(2008, 4, 2), "Gilbert paid back for iPhone")
820 .with_posting(Posting::new(
821 "Assets:Cash",
822 Amount::new(dec!(440.00), "CAD"),
823 ))
824 .with_posting(Posting::new(
825 "Assets:AccountsReceivable",
826 Amount::new(dec!(-431.92), "USD"),
827 ))
828 .with_posting(Posting::auto("Assets:Cash"));
829
830 let result = interpolate(&txn).expect("interpolation should succeed");
831
832 assert_eq!(
834 result.transaction.postings.len(),
835 4,
836 "should split elided posting into 2"
837 );
838
839 for (currency, residual) in &result.residuals {
841 assert!(
842 residual.abs() < dec!(0.01),
843 "{currency} residual should be ~0, got {residual}"
844 );
845 }
846
847 let mut found_cad = false;
849 let mut found_usd = false;
850 for posting in &result.transaction.postings {
851 if let Some(amount) = get_amount(posting)
852 && posting.account.as_str() == "Assets:Cash"
853 {
854 if amount.currency == "CAD" && amount.number == dec!(-440.00) {
855 found_cad = true;
856 } else if amount.currency == "USD" && amount.number == dec!(431.92) {
857 found_usd = true;
858 }
859 }
860 }
861 assert!(found_cad, "should have -440.00 CAD posting");
862 assert!(found_usd, "should have 431.92 USD posting");
863 }
864
865 #[test]
866 fn test_interpolate_multi_currency_three_currencies() {
867 let txn = Transaction::new(date(2024, 1, 15), "Multi-currency test")
869 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(100), "USD")))
870 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(200), "EUR")))
871 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(300), "GBP")))
872 .with_posting(Posting::auto("Equity:Opening"));
873
874 let result = interpolate(&txn).expect("interpolation should succeed");
875
876 assert_eq!(result.transaction.postings.len(), 6);
878
879 for (currency, residual) in &result.residuals {
881 assert!(
882 residual.abs() < dec!(0.01),
883 "{currency} residual should be ~0, got {residual}"
884 );
885 }
886 }
887
888 #[test]
895 fn test_interpolate_cost_currency_inferred_from_other_posting() {
896 let txn = Transaction::new(date(2026, 1, 1), "Opening balance")
903 .with_posting(
904 Posting::new(
905 "Assets:Vanguard:IRA:Trad:VFIFX",
906 Amount::new(dec!(10), "VFIFX"),
907 )
908 .with_cost(rustledger_core::CostSpec::empty().with_number_per(dec!(100))),
909 )
910 .with_posting(Posting::new(
911 "Equity:Opening-Balances",
912 Amount::new(dec!(-1000), "USD"),
913 ));
914
915 let result = interpolate(&txn).expect("interpolation should succeed");
916
917 let residual = result
919 .residuals
920 .get("USD")
921 .copied()
922 .unwrap_or(Decimal::ZERO);
923 assert!(
924 residual.abs() < dec!(0.01),
925 "USD residual should be ~0, got {residual}"
926 );
927 }
928
929 #[test]
931 fn test_interpolate_cost_currency_inferred_elided_cash() {
932 let txn = Transaction::new(date(2026, 1, 1), "Opening balance")
939 .with_posting(
940 Posting::new(
941 "Assets:Vanguard:IRA:Trad:VFIFX",
942 Amount::new(dec!(10), "VFIFX"),
943 )
944 .with_cost(rustledger_core::CostSpec::empty().with_number_per(dec!(100))),
945 )
946 .with_posting(Posting::new(
947 "Equity:Opening-Balances",
948 Amount::new(dec!(-1000), "USD"),
949 ));
950
951 let result = interpolate(&txn).expect("interpolation should succeed");
952
953 assert!(result.filled_indices.is_empty());
955
956 let residual = result
958 .residuals
959 .get("USD")
960 .copied()
961 .unwrap_or(Decimal::ZERO);
962 assert!(
963 residual.abs() < dec!(0.01),
964 "USD residual should be ~0, got {residual}"
965 );
966 }
967
968 #[test]
978 fn test_interpolate_rounds_to_quantum() {
979 let txn = Transaction::new(date(2026, 1, 2), "Test")
989 .with_posting(Posting::auto("Assets:Cash"))
990 .with_posting(
991 Posting::new("Assets:Abc", Amount::new(dec!(12.3340), "ABC")).with_cost(
992 rustledger_core::CostSpec::empty()
993 .with_number_per(dec!(140.02))
994 .with_currency("USD"),
995 ),
996 )
997 .with_posting(Posting::new(
998 "Expenses:Abc",
999 Amount::new(dec!(-0.01), "USD"),
1000 ));
1001
1002 let result = interpolate(&txn).expect("interpolation should succeed");
1003
1004 assert_eq!(result.filled_indices, vec![0]);
1006
1007 let filled = &result.transaction.postings[0];
1010 let amount = get_amount(filled).expect("should have amount");
1011 assert_eq!(amount.currency, "USD");
1012 assert_eq!(
1013 amount.number,
1014 dec!(-1727.00),
1015 "should be -1727.00 USD (rounded to 2 decimal places)"
1016 );
1017
1018 let residual = result
1020 .residuals
1021 .get("USD")
1022 .copied()
1023 .unwrap_or(Decimal::ZERO);
1024 assert_eq!(
1025 residual,
1026 dec!(-0.003320),
1027 "residual should be -0.003320 USD"
1028 );
1029 }
1030
1031 #[test]
1033 fn test_interpolate_uses_max_scale() {
1034 let txn = Transaction::new(date(2024, 1, 15), "Test")
1037 .with_posting(Posting::new("Expenses:A", Amount::new(dec!(0.1), "USD")))
1038 .with_posting(Posting::new("Expenses:B", Amount::new(dec!(0.001), "USD")))
1039 .with_posting(Posting::auto("Assets:Cash"));
1040
1041 let result = interpolate(&txn).expect("interpolation should succeed");
1042
1043 let filled = &result.transaction.postings[2];
1044 let amount = get_amount(filled).expect("should have amount");
1045
1046 assert_eq!(amount.number, dec!(-0.101));
1048 assert_eq!(amount.number.scale(), 3);
1050 }
1051
1052 #[test]
1063 fn test_interpolate_cost_scale_preserved() {
1064 let txn = Transaction::new(date(2026, 1, 19), "Buy stock")
1075 .with_posting(
1076 Posting::new("Assets:Stock", Amount::new(dec!(1), "CSU")).with_cost(
1077 rustledger_core::CostSpec::empty()
1078 .with_number_per(dec!(2800.01))
1079 .with_currency("CAD"),
1080 ),
1081 )
1082 .with_posting(Posting::new(
1083 "Expenses:Commission",
1084 Amount::new(dec!(1), "CAD"),
1085 ))
1086 .with_posting(Posting::auto("Assets:Cash"));
1087
1088 let result = interpolate(&txn).expect("interpolation should succeed");
1089
1090 assert_eq!(result.filled_indices, vec![2]);
1092
1093 let filled = &result.transaction.postings[2];
1095 let amount = get_amount(filled).expect("should have amount");
1096 assert_eq!(amount.currency, "CAD");
1097 assert_eq!(
1098 amount.number,
1099 dec!(-2801.01),
1100 "should be -2801.01 CAD (preserving cost spec precision)"
1101 );
1102
1103 let residual = result
1105 .residuals
1106 .get("CAD")
1107 .copied()
1108 .unwrap_or(Decimal::ZERO);
1109 assert!(
1110 residual.is_zero(),
1111 "CAD residual should be 0, got {residual}"
1112 );
1113 }
1114
1115 #[test]
1133 fn test_interpolate_balanced_cost_preserves_zero_posting() {
1134 let txn = Transaction::new(date(2022, 4, 16), "Trade")
1135 .with_posting(
1136 Posting::new("Assets:Crypto", Amount::new(dec!(100), "USDC")).with_cost(
1137 rustledger_core::CostSpec::empty()
1138 .with_number_per(dec!(1.0))
1139 .with_currency("USD"),
1140 ),
1141 )
1142 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-100), "USD")))
1143 .with_posting(Posting::auto("Income:Trading"));
1144
1145 let result = interpolate(&txn).expect("interpolation should succeed");
1146
1147 assert_eq!(
1149 result.filled_indices,
1150 vec![2],
1151 "zero-amount posting should be in filled_indices"
1152 );
1153
1154 assert_eq!(
1156 result.transaction.postings.len(),
1157 3,
1158 "zero-amount posting should be preserved in transaction"
1159 );
1160
1161 let filled = &result.transaction.postings[2];
1163 let amount = filled.units.as_ref().unwrap().as_amount().unwrap();
1164 assert!(amount.number.is_zero());
1165 assert_eq!(amount.currency, "USD");
1166 }
1167
1168 #[test]
1180 fn test_interpolate_zero_cost_preserves_zero_posting() {
1181 let txn = Transaction::new(date(2022, 4, 16), "Free tokens")
1182 .with_posting(
1183 Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
1184 rustledger_core::CostSpec::empty()
1185 .with_number_per(dec!(0))
1186 .with_currency("USD"),
1187 ),
1188 )
1189 .with_posting(Posting::auto("Income:Bonus"));
1190
1191 let result = interpolate(&txn).expect("interpolation should succeed");
1192
1193 assert_eq!(
1195 result.filled_indices,
1196 vec![1],
1197 "zero-amount posting should be in filled_indices"
1198 );
1199
1200 assert_eq!(
1202 result.transaction.postings.len(),
1203 2,
1204 "zero-amount posting should be preserved in transaction"
1205 );
1206 }
1207
1208 #[test]
1216 fn test_interpolate_zero_total_cost_preserves_zero_posting() {
1217 let txn = Transaction::new(date(2022, 4, 16), "Free tokens")
1218 .with_posting(
1219 Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
1220 rustledger_core::CostSpec::empty()
1221 .with_number_total(dec!(0))
1222 .with_currency("USD"),
1223 ),
1224 )
1225 .with_posting(Posting::auto("Income:Bonus"));
1226
1227 let result = interpolate(&txn).expect("interpolation should succeed");
1228
1229 assert_eq!(
1231 result.filled_indices,
1232 vec![1],
1233 "zero-amount posting should be in filled_indices"
1234 );
1235
1236 assert_eq!(
1238 result.transaction.postings.len(),
1239 2,
1240 "zero-amount posting should be preserved in transaction"
1241 );
1242 }
1243
1244 #[test]
1248 fn test_zero_value_posting_preserved_for_validation() {
1249 let txn = Transaction::new(date(2022, 1, 1), "Test")
1253 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(100), "USD")))
1254 .with_posting(Posting::new(
1255 "Expenses:Food",
1256 Amount::new(dec!(-100), "USD"),
1257 ))
1258 .with_posting(Posting::auto("Income:Unopened"));
1259
1260 let result = interpolate(&txn).expect("interpolation should succeed");
1261
1262 assert_eq!(
1264 result.transaction.postings.len(),
1265 3,
1266 "zero-value elided posting must be preserved so validation can check the account"
1267 );
1268
1269 let preserved = &result.transaction.postings[2];
1271 assert_eq!(preserved.account, "Income:Unopened");
1272 let amount = preserved.units.as_ref().unwrap().as_amount().unwrap();
1273 assert!(amount.number.is_zero());
1274 }
1275}