Skip to main content

rustledger_booking/
interpolate.rs

1//! Transaction interpolation.
2//!
3//! Fills in missing posting amounts to balance transactions.
4
5use rust_decimal::Decimal;
6use rust_decimal::prelude::Signed;
7use rustledger_core::{Amount, IncompleteAmount, InternedStr, Transaction};
8use std::collections::HashMap;
9use thiserror::Error;
10
11/// Errors that can occur during interpolation.
12#[derive(Debug, Clone, Error)]
13pub enum InterpolationError {
14    /// Multiple postings are missing amounts for the same currency.
15    #[error("multiple postings missing amounts for currency {currency}")]
16    MultipleMissing {
17        /// The currency with multiple missing amounts.
18        currency: InternedStr,
19        /// Number of postings missing this currency.
20        count: usize,
21    },
22
23    /// Cannot infer currency for a posting.
24    #[error("cannot infer currency for posting to account {account}")]
25    CannotInferCurrency {
26        /// The account of the posting.
27        account: InternedStr,
28    },
29
30    /// Transaction does not balance after interpolation.
31    #[error("transaction does not balance: residual {residual} {currency}")]
32    DoesNotBalance {
33        /// The unbalanced currency.
34        currency: InternedStr,
35        /// The residual amount.
36        residual: Decimal,
37    },
38}
39
40/// Result of interpolation.
41#[derive(Debug, Clone)]
42pub struct InterpolationResult {
43    /// The interpolated transaction.
44    pub transaction: Transaction,
45    /// Which posting indices were filled in.
46    pub filled_indices: Vec<usize>,
47    /// Residuals after interpolation (should all be near zero).
48    pub residuals: HashMap<InternedStr, Decimal>,
49}
50
51/// Round an interpolated amount to match existing scale, but never round
52/// a non-zero residual to zero (that would leave the transaction unbalanced).
53fn 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 rounding would make non-zero residual into zero, preserve precision
58        if rounded.is_zero() && !residual.is_zero() {
59            interpolated
60        } else {
61            rounded
62        }
63    } else {
64        interpolated
65    }
66}
67
68/// Interpolate missing amounts in a transaction.
69///
70/// This function:
71/// 1. Identifies postings with missing amounts
72/// 2. For each currency, calculates the residual
73/// 3. Fills in the missing amount to balance
74///
75/// # Rules
76///
77/// - At most one posting per currency can have a missing amount
78/// - If a posting has a cost spec with a currency, that currency is used
79/// - Otherwise, the posting gets the residual that makes the transaction balance
80///
81/// # TLA+ Specification
82///
83/// Implements invariants from `Interpolation.tla`:
84/// - `AtMostOneNull`: At most one posting per currency can have a missing amount
85///   (returns `MultipleMissing` error if violated)
86/// - `CompleteImpliesBalanced`: After interpolation, `sum(postings) = 0` for each currency
87/// - `HasNullAccurate`: `filled_indices` contains exactly the indices of postings
88///   that were originally missing amounts
89///
90/// See: `spec/tla/Interpolation.tla`
91///
92/// # Example
93///
94/// ```ignore
95/// let txn = Transaction::new(date, "Test")
96///     .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(50.00), "USD")))
97///     .with_posting(Posting::auto("Assets:Cash"));
98///
99/// let result = interpolate(&txn)?;
100/// // Assets:Cash now has -50.00 USD
101/// ```
102pub fn interpolate(transaction: &Transaction) -> Result<InterpolationResult, InterpolationError> {
103    // Clone the transaction for modification
104    let mut result = transaction.clone();
105    let mut filled_indices = Vec::new();
106
107    // Lazily compute inferred currency only when needed (most transactions don't need it)
108    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    // Calculate initial residuals from postings with amounts
116    // Pre-allocate for typical case (1-2 currencies per transaction)
117    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    // Track maximum scale (decimal places) per currency for rounding interpolated amounts.
123    // Python beancount rounds interpolated amounts to match the precision of other amounts
124    // in the same currency, which can create small residuals within tolerance.
125    let mut max_scale_by_currency: HashMap<InternedStr, u32> = HashMap::with_capacity(4);
126
127    // Track scales from cost specs separately. These are merged with max_scale_by_currency
128    // after the loop, but only for currencies that have explicit amounts. This ensures we
129    // preserve precision when cost has more decimal places than other postings (#333),
130    // without forcing rounding when there are no explicit amounts (#251).
131    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                // Track scale (decimal places) for rounding interpolated amounts
137                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                // Determine the "weight" of this posting for balance purposes.
144                // This must match the logic in calculate_residual().
145                //
146                // Rules:
147                // - If there's a cost spec, weight is in cost currency (not units)
148                // - If there's a price annotation (no cost), weight is in price currency
149                // - Otherwise, weight is the units themselves
150
151                // Check if cost spec has determinable values.
152                // If cost has number but no currency, try to infer currency from:
153                // 1. Price annotation
154                // 2. Other postings in the transaction
155                let cost_contribution = posting.cost.as_ref().and_then(|cost_spec| {
156                    // Helper to get currency from price annotation
157                    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                    // Try to get cost currency, falling back to price currency, then other postings
168                    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                        // Track the scale of number_per for rounding interpolated amounts.
179                        // This ensures we preserve the precision of the per-unit price.
180                        // See: https://github.com/rustledger/rustledger/issues/333
181                        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                        // For total cost, sign depends on units sign
186                        // Track the scale of number_total for rounding
187                        Some((
188                            cost_curr.clone(),
189                            *total * amount.number.signum(),
190                            Some(total.scale()),
191                        ))
192                    } else {
193                        None // Cost spec without determinable amount (e.g., empty `{}`)
194                    }
195                });
196
197                if let Some((currency, cost_amount, cost_scale)) = cost_contribution {
198                    // Cost-based posting: weight is in the cost currency.
199                    // Track cost scale separately - it will be merged later only for
200                    // currencies that have explicit amounts.
201                    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                    // Price annotation: converts units to price currency
210                    // Note: We do NOT track scale from per-unit prices (they're multipliers).
211                    // We DO track scale from total prices (they're explicit amounts).
212                    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                            // Total price is an explicit amount - track its scale
220                            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                                // Can't calculate, fall back to units
235                                *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                                // Total price is an explicit amount - track its scale
242                                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                                // Can't calculate, fall back to units
251                                *residuals.entry(amount.currency.clone()).or_default() +=
252                                    amount.number;
253                            }
254                        }
255                        // Empty price annotations - fall back to units
256                        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                    // Cost spec exists but is empty (e.g., `{}`), and no price annotation
263                    // Don't contribute to residual - cost will be filled by lot matching
264                } else {
265                    // Simple posting: weight is just the units
266                    *residuals.entry(amount.currency.clone()).or_default() += amount.number;
267                }
268            }
269            Some(IncompleteAmount::CurrencyOnly(currency)) => {
270                // Currency known, number to be interpolated
271                missing_by_currency
272                    .entry(currency.clone())
273                    .or_default()
274                    .push(i);
275            }
276            Some(IncompleteAmount::NumberOnly(number)) => {
277                // Number known, currency to be inferred
278                // Try to get currency from cost or price
279                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                    // We have currency from context, make it complete
298                    *residuals.entry(curr.clone()).or_default() += *number;
299                } else {
300                    // Can't determine currency yet
301                    unassigned_missing.push(i);
302                }
303            }
304            None => {
305                // Missing amount - try to determine currency from cost
306                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                // Can't determine currency yet
316                unassigned_missing.push(i);
317            }
318        }
319    }
320
321    // Merge cost scales into max_scale_by_currency, but only for currencies that
322    // already have explicit amounts. This preserves precision from cost specs (#333)
323    // without forcing rounding when there are no explicit amounts (#251).
324    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    // Check for multiple missing in same currency
331    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    // Fill in known-currency missing postings
341    for (currency, indices) in missing_by_currency {
342        let idx = indices[0];
343        let residual = residuals.get(&currency).copied().unwrap_or(Decimal::ZERO);
344
345        let interpolated =
346            round_interpolated(residual, max_scale_by_currency.get(&currency).copied());
347
348        result.postings[idx].units = Some(IncompleteAmount::Complete(Amount::new(
349            interpolated,
350            &currency,
351        )));
352        filled_indices.push(idx);
353
354        // Update residual to reflect actual interpolated amount (may have rounding difference)
355        *residuals.entry(currency).or_default() += interpolated;
356    }
357
358    // Handle unassigned missing postings
359    // Each one absorbs one or more currencies' residuals
360    if !unassigned_missing.is_empty() {
361        // Get currencies with non-zero residuals
362        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        // Special case: single missing posting with multiple currencies
369        // This is multi-currency interpolation - split into multiple postings
370        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            // Fill the first currency into the original posting
375            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            // Add new postings for remaining currencies
388            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            // Check for ambiguous elision: more unassigned missing postings than
402            // available residual currencies means multiple postings would all be
403            // assigned to the same currency, which is ambiguous and an error.
404            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            // Standard case: assign one currency per missing posting
414            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                    // Use the first currency
427                    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                    // No residuals but we can infer currency from cost basis
433                    // This handles balanced cost-basis transactions like:
434                    //   Assets:Crypto  100 USDC {1.0 USD}
435                    //   Assets:Cash   -100 USD
436                    //   Income:Trading  ; <- infer 0 USD from cost basis
437                    result.postings[*idx].units =
438                        Some(IncompleteAmount::Complete(Amount::zero(&currency)));
439                    filled_indices.push(*idx);
440                } else {
441                    // No residuals and cannot infer currency
442                    return Err(InterpolationError::CannotInferCurrency {
443                        account: transaction.postings[*idx].account.clone(),
444                    });
445                }
446            }
447        }
448    }
449
450    // Note: We intentionally do NOT prune postings that interpolate to zero.
451    // Although Python beancount removes such postings, pruning them before
452    // validation hides errors (e.g., E1001 for unopened accounts).
453    // See issue #877 / beancount/beancount#962.
454
455    // Return the residuals we've been tracking incrementally
456    // (no need to recalculate - we've updated residuals as we filled amounts)
457    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    /// Helper to get the complete amount from a posting.
475    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        // Multiple unassigned missing postings with a single residual currency
571        // is ambiguous and should return MultipleMissing error.
572        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        // Two elided postings but two residual currencies - each gets one
582        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        // Two unassigned missing, two non-zero residuals - this is unambiguous
589        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        // 2015-10-02 *
599        //   Assets:Stock   10 HOOL {100.00 USD}
600        //   Assets:Cash
601        //
602        // Expected: Assets:Cash should be interpolated to -1000.00 USD
603        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        // Check that the cash posting was filled
616        assert_eq!(result.filled_indices, vec![1]);
617
618        // Check the interpolated amount
619        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        // Verify the transaction balances
632        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        // There should be NO HOOL residual
642        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        // 2015-10-02 *
651        //   Assets:Stock   10 HOOL {{1000.00 USD}}
652        //   Assets:Cash
653        //
654        // Expected: Assets:Cash should be interpolated to -1000.00 USD
655        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        // From beancount starter.beancount:
676        // 2013-02-03 * "Bought some stock"
677        //   Assets:Stock         8 HOOL {701.20 USD}
678        //   Expenses:Commission  7.95 USD
679        //   Assets:Cash
680        //
681        // Expected: Cash = -(8 * 701.20 + 7.95) = -5617.55 USD
682        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        // 8 * 701.20 = 5609.60, plus 7.95 commission = 5617.55
702        assert_eq!(amount.number, dec!(-5617.55));
703    }
704
705    #[test]
706    fn test_interpolate_stock_sale_with_cost_and_price() {
707        // Selling stock at a different price than cost basis
708        // 2015-10-02 *
709        //   Assets:Stock   -10 HOOL {100.00 USD} @ 120.00 USD
710        //   Assets:Cash
711        //   Income:Gains
712        //
713        // The sale is at cost (for booking), but price is 120 USD
714        // Weight: -10 * 100 = -1000 USD (at cost)
715        // Cash should receive: 10 * 120 = 1200 USD (at price)
716        // Gains: -200 USD
717        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        // Gains = cost - proceeds = 1000 - 1200 = -200 (income is negative)
742        assert_eq!(amount.number, dec!(-200.00));
743    }
744
745    #[test]
746    fn test_interpolate_balanced_with_cost_no_interpolation_needed() {
747        // When all amounts are provided, no interpolation needed
748        // 2015-10-02 *
749        //   Assets:Stock   10 HOOL {100.00 USD}
750        //   Assets:Cash   -1000.00 USD
751        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        // No postings should be filled
767        assert!(result.filled_indices.is_empty());
768
769        // Transaction should balance
770        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        // Selling stock (negative units) with cost
781        // 2015-10-02 *
782        //   Assets:Stock   -5 HOOL {100.00 USD}
783        //   Assets:Cash
784        //
785        // Expected: Cash = 500.00 USD (proceeds from sale at cost)
786        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)); // Positive (receiving cash)
802    }
803
804    // =========================================================================
805    // Multi-currency interpolation tests
806    // =========================================================================
807
808    #[test]
809    fn test_interpolate_multi_currency_single_elided() {
810        // Test case from basic.beancount:
811        // 2008-04-02 * "Gilbert paid back for iPhone"
812        //   Assets:Cash                            440.00 CAD
813        //   Assets:AccountsReceivable             -431.92 USD
814        //   Assets:Cash
815        //
816        // Expected: The elided Assets:Cash becomes TWO postings:
817        //   Assets:Cash: -440.00 CAD
818        //   Assets:Cash: 431.92 USD
819        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        // Should now have 4 postings (original 3 + 1 added for second currency)
833        assert_eq!(
834            result.transaction.postings.len(),
835            4,
836            "should split elided posting into 2"
837        );
838
839        // Check that all residuals are zero
840        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        // Verify the amounts (order may vary based on HashMap iteration)
848        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        // Three currencies with one elided posting
868        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        // Should now have 6 postings (original 4 + 2 added)
877        assert_eq!(result.transaction.postings.len(), 6);
878
879        // All residuals should be zero
880        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    // =========================================================================
889    // Cost currency inference tests (issue #203)
890    // =========================================================================
891
892    /// Test interpolation with cost currency inferred from other postings.
893    /// This is the exact case from issue #203.
894    #[test]
895    fn test_interpolate_cost_currency_inferred_from_other_posting() {
896        // 2026-01-01 * "Opening balance"
897        //   Assets:Vanguard:IRA:Trad:VFIFX  10 VFIFX {100}
898        //   Equity:Opening-Balances
899        //
900        // The cost currency should be inferred, and the elided posting should
901        // be filled with -1000 USD.
902        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        // Transaction should balance
918        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 interpolation where the cash posting is elided.
930    #[test]
931    fn test_interpolate_cost_currency_inferred_elided_cash() {
932        // Like issue #203 but with elided cash posting:
933        // 2026-01-01 * "Opening balance"
934        //   Assets:Vanguard:IRA:Trad:VFIFX  10 VFIFX {100}
935        //   Equity:Opening-Balances  -1000 USD
936        //
937        // Both postings are complete, should just balance.
938        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        // No postings filled since both are complete
954        assert!(result.filled_indices.is_empty());
955
956        // Should balance
957        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    // =========================================================================
969    // Interpolation rounding tests (issue #268)
970    // =========================================================================
971
972    /// Test that interpolated amounts are rounded to match the precision of other amounts.
973    /// This matches Python beancount's behavior where interpolated amounts use the same
974    /// quantum (decimal places) as other amounts in the same currency.
975    ///
976    /// Issue: <https://github.com/rustledger/rustledger/issues/268>
977    #[test]
978    fn test_interpolate_rounds_to_quantum() {
979        // From issue #268:
980        // 2026-01-02 * "..."
981        //   Assets:Cash
982        //   Assets:Abc                    12.3340 ABC {140.02 USD, 2025-01-01}
983        //   Expenses:Abc                    -0.01 USD
984        //
985        // Cost: 12.3340 * 140.02 = 1727.006680 USD
986        // Python rounds Cash to -1727.00 (2 decimal places from -0.01 USD)
987        // Residual: 1727.006680 - 0.01 - 1727.00 = -0.003320 USD (within 0.005 tolerance)
988        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        // Check that Cash was filled
1005        assert_eq!(result.filled_indices, vec![0]);
1006
1007        // The interpolated amount should be rounded to 2 decimal places
1008        // (matching the -0.01 USD in Expenses:Abc)
1009        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        // The residual should be non-zero but small (within tolerance)
1019        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 that interpolation uses the maximum scale when multiple amounts have different scales.
1032    #[test]
1033    fn test_interpolate_uses_max_scale() {
1034        // When we have amounts with different scales, use the maximum.
1035        // 0.1 USD (scale 1) and 0.001 USD (scale 3) -> interpolate to scale 3
1036        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        // The amount is exactly -0.101, which fits in 3 decimal places
1047        assert_eq!(amount.number, dec!(-0.101));
1048        // Scale should be 3 (the maximum of 1 and 3)
1049        assert_eq!(amount.number.scale(), 3);
1050    }
1051
1052    /// Test that cost spec scale is used when other postings have lower scale.
1053    ///
1054    /// Issue: <https://github.com/rustledger/rustledger/issues/333>
1055    ///
1056    /// When a transaction has:
1057    /// - A cost spec with decimal places (e.g., {2800.01 CAD})
1058    /// - Other postings with fewer decimal places (e.g., 1 CAD)
1059    ///
1060    /// The interpolated amount should use the cost spec's scale, not the
1061    /// lower scale from other postings.
1062    #[test]
1063    fn test_interpolate_cost_scale_preserved() {
1064        // From issue #333:
1065        // 2026-01-19 * "Buy stock"
1066        //   Assets:Stock  1 CSU { 2800.01 CAD }
1067        //   Expenses:Commission  1 CAD
1068        //   Assets:Cash
1069        //
1070        // Cost: 1 * 2800.01 = 2800.01 CAD (scale 2)
1071        // Commission: 1 CAD (scale 0)
1072        // Without fix: Cash rounds to -2801.00 (scale 0), leaving 0.01 residual
1073        // With fix: Cash is -2801.01 (scale 2), transaction balances
1074        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        // Check that Cash was filled
1091        assert_eq!(result.filled_indices, vec![2]);
1092
1093        // The interpolated amount should be -2801.01 (scale 2 from cost spec)
1094        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        // Transaction should balance (no residual)
1104        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    // =========================================================================
1116    // Currency inference from cost basis tests
1117    // =========================================================================
1118
1119    /// Test that zero-amount postings are removed when transaction balances perfectly.
1120    /// Test that zero-amount postings from balanced cost basis are preserved.
1121    ///
1122    /// When a transaction with cost basis balances to zero (e.g., cost equals cash),
1123    /// the empty posting is filled with 0 USD and preserved. Previously these were
1124    /// pruned, but that hid validation errors (see issue #877).
1125    ///
1126    /// Example:
1127    /// ```beancount
1128    /// Assets:Crypto    100 USDC {1.0 USD, 2022-04-16}
1129    /// Assets:Cash     -100 USD
1130    /// Income:Trading   ; <- filled with 0 USD, preserved
1131    /// ```
1132    #[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        // The zero-amount posting should be filled and preserved
1148        assert_eq!(
1149            result.filled_indices,
1150            vec![2],
1151            "zero-amount posting should be in filled_indices"
1152        );
1153
1154        // Transaction should have all 3 postings
1155        assert_eq!(
1156            result.transaction.postings.len(),
1157            3,
1158            "zero-amount posting should be preserved in transaction"
1159        );
1160
1161        // The filled posting should have 0 USD
1162        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 that zero-amount postings from zero-cost basis are preserved.
1169    ///
1170    /// When a posting has a zero cost like `{0 USD}`, the empty posting
1171    /// is filled with 0 USD and preserved for validation.
1172    /// See issue #877.
1173    ///
1174    /// Example:
1175    /// ```beancount
1176    /// Assets:Crypto    100 TOKEN {0 USD}
1177    /// Income:Bonus     ; <- filled with 0 USD, preserved
1178    /// ```
1179    #[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        // The zero-amount posting should be preserved
1194        assert_eq!(
1195            result.filled_indices,
1196            vec![1],
1197            "zero-amount posting should be in filled_indices"
1198        );
1199
1200        // Transaction should have both postings
1201        assert_eq!(
1202            result.transaction.postings.len(),
1203            2,
1204            "zero-amount posting should be preserved in transaction"
1205        );
1206    }
1207
1208    /// Test that zero-amount postings from zero total cost are preserved.
1209    ///
1210    /// Example:
1211    /// ```beancount
1212    /// Assets:Crypto    100 TOKEN {{0 USD}}
1213    /// Income:Bonus     ; <- filled with 0 USD, preserved
1214    /// ```
1215    #[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        // The zero-amount posting should be preserved
1230        assert_eq!(
1231            result.filled_indices,
1232            vec![1],
1233            "zero-amount posting should be in filled_indices"
1234        );
1235
1236        // Transaction should have both postings
1237        assert_eq!(
1238            result.transaction.postings.len(),
1239            2,
1240            "zero-amount posting should be preserved in transaction"
1241        );
1242    }
1243
1244    /// Regression test for issue #877 (beancount/beancount#962).
1245    /// Zero-value interpolated postings must NOT be pruned, because pruning
1246    /// can hide validation errors (e.g., E1001 for unopened accounts).
1247    #[test]
1248    fn test_zero_value_posting_preserved_for_validation() {
1249        // An elided posting on an account that would interpolate to zero.
1250        // Even though the amount is zero, the posting must survive interpolation
1251        // so that downstream validation can detect the unopened account.
1252        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        // The Income:Unopened posting must still be present
1263        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        // Verify the preserved posting is the one we expect
1270        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}