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, Currency, IncompleteAmount, 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 unknowns in the same currency group, where an unknown is
15    /// either a posting with a missing amount or a posting with an empty
16    /// cost spec (`{}`) whose cost-basis weight is deferred to booking-
17    /// time lot matching. Bean-check enforces "at most one unknown per
18    /// currency group" — see issue #1026 for the cost-spec extension.
19    ///
20    /// The variant name `MultipleMissing` is kept for API stability;
21    /// "missing amounts" in the error message is a slight overgeneral
22    /// (the count includes cost-unknowns, not just missing amounts), but
23    /// the field semantics are correct.
24    #[error(
25        "multiple postings missing amounts or with unresolved cost specs for currency {currency} ({count} unknowns)"
26    )]
27    MultipleMissing {
28        /// The currency group with too many unknowns.
29        currency: Currency,
30        /// Total count of unknowns: missing-amount postings plus
31        /// empty-cost-spec postings whose weight is deferred to
32        /// booking-time lot matching.
33        count: usize,
34    },
35
36    /// Cannot infer currency for a posting.
37    #[error("cannot infer currency for posting to account {account}")]
38    CannotInferCurrency {
39        /// The account of the posting.
40        account: rustledger_core::Account,
41    },
42
43    /// Transaction does not balance after interpolation.
44    #[error("transaction does not balance: residual {residual} {currency}")]
45    DoesNotBalance {
46        /// The unbalanced currency.
47        currency: Currency,
48        /// The residual amount.
49        residual: Decimal,
50    },
51}
52
53/// Result of interpolation.
54#[derive(Debug, Clone)]
55pub struct InterpolationResult {
56    /// The interpolated transaction.
57    pub transaction: Transaction,
58    /// Which posting indices were filled in.
59    pub filled_indices: Vec<usize>,
60    /// Residuals after interpolation (should all be near zero).
61    pub residuals: HashMap<Currency, Decimal>,
62}
63
64/// Round an interpolated amount to match existing scale, but never round
65/// a non-zero residual to zero (that would leave the transaction unbalanced).
66fn 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 rounding would make non-zero residual into zero, preserve precision
71        if rounded.is_zero() && !residual.is_zero() {
72            interpolated
73        } else {
74            rounded
75        }
76    } else {
77        interpolated
78    }
79}
80
81/// Interpolate missing amounts in a transaction.
82///
83/// This function:
84/// 1. Identifies postings with missing amounts
85/// 2. For each currency, calculates the residual
86/// 3. Fills in the missing amount to balance
87///
88/// # Rules
89///
90/// - At most one posting per currency can have a missing amount
91/// - If a posting has a cost spec with a currency, that currency is used
92/// - Otherwise, the posting gets the residual that makes the transaction balance
93///
94/// # TLA+ Specification
95///
96/// Implements invariants from `Interpolation.tla` (post-#1030 redesign for
97/// N postings + multi-currency + cost-unknowns):
98/// - `AtMostOneUnknownPerCurrency`: For each currency group, at most one
99///   posting may be "unknown" — either a missing amount (counts toward
100///   the units currency) or an empty cost spec like `{}` (counts toward
101///   the cost currency, since the cost-basis weight is unresolved until
102///   booking-pass lot matching). Returns `MultipleMissing` if violated.
103/// - `CompleteImpliesValidated`: Interpolation only completes the
104///   transaction when the validation rule holds.
105///
106/// The spec models the structural validation rule, not the residual
107/// arithmetic that produces filled amounts — see `Interpolation.tla`'s
108/// header for the scope rationale.
109///
110/// See: `spec/tla/Interpolation.tla`
111///
112/// # Example
113///
114/// ```ignore
115/// let txn = Transaction::new(date, "Test")
116///     .with_synthesized_posting(Posting::new("Expenses:Food", Amount::new(dec!(50.00), "USD")))
117///     .with_synthesized_posting(Posting::auto("Assets:Cash"));
118///
119/// let result = interpolate(&txn)?;
120/// // Assets:Cash now has -50.00 USD
121/// ```
122pub fn interpolate(transaction: &Transaction) -> Result<InterpolationResult, InterpolationError> {
123    // Clone the transaction for modification
124    let mut result = transaction.clone();
125    let mut filled_indices = Vec::new();
126
127    // Lazily compute inferred currency only when needed (most transactions don't need it)
128    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    // Calculate initial residuals from postings with amounts
136    // Pre-allocate for typical case (1-2 currencies per transaction)
137    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    // Track maximum scale (decimal places) per currency for rounding interpolated amounts.
143    //
144    // Matches Python beancount's `infer_tolerances` rule: only NON-INTEGER posting
145    // units contribute to the per-currency tolerance/precision. Integer amounts
146    // ("1 CAD" commission, "1 CSU" share count) do NOT contribute — they don't
147    // tell us anything about that currency's display precision.
148    //
149    // Cost spec scales are deliberately NOT included. With Python's default
150    // `infer_tolerance_from_cost = False`, cost annotations don't influence the
151    // residual quantization either. The natural Decimal arithmetic that flows
152    // through `cost_amount = units × per_unit` preserves whatever scale the
153    // operands carry, so a transaction with no non-integer posting units in a
154    // given currency simply doesn't get a quantization step (the residual is
155    // rendered at its natural scale).
156    //
157    // - #333 (`1 CSU {2800.01 CAD}` + `1 CAD` commission + missing CAD):
158    //   no non-integer CAD posting units in this transaction; residual
159    //   passes through unrounded at its natural scale, which is 2dp from
160    //   the explicit cost literal `{2800.01}` flowing through
161    //   `cost_amount = units × per_unit`.
162    // - #251 (`70.538 ABC {100 USD}` + missing posting): no non-integer
163    //   USD posting units; residual = `70.538 × 100 = 7053.800` (scale 3
164    //   from the rust_decimal multiplication), preserved naturally.
165    // - #1107 (`-1.763 STOCK {}` lot-matched against high-precision per_unit):
166    //   the cash side `336.73 USD` gives USD scale=2; the residual gets
167    //   quantized to 2dp instead of inheriting the lot's derived 26-digit
168    //   per_unit precision.
169    let mut max_scale_by_currency: HashMap<Currency, u32> = HashMap::with_capacity(4);
170
171    // Track per-currency count of postings whose weight contribution is unknown
172    // because the cost spec is empty (e.g., `{}`) and resolution is deferred to
173    // the booking pass (lot matching). Each such posting is one unknown for
174    // interpolation accounting and gets added to the per-currency unknowns
175    // total alongside missing-amount postings (issue #1026). Without this,
176    // rledger would silently use a fallback weight (price annotation, if
177    // present) and accept transactions with more unknowns than the
178    // interpolation rule allows.
179    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                // Track scale (decimal places) for rounding interpolated amounts.
185                // Skip integer (scale==0) amounts — matches Python's
186                // `infer_tolerances`, which ignores integer posting.units
187                // since they don't reflect intentional currency precision.
188                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                // Determine the "weight" of this posting for balance purposes.
197                // This must match the logic in calculate_residual().
198                //
199                // Rules:
200                // - If there's a cost spec, weight is in cost currency (not units)
201                // - If there's a price annotation (no cost), weight is in price currency
202                // - Otherwise, weight is the units themselves
203
204                // Check if cost spec has determinable values.
205                // If cost has number but no currency, try to infer currency from:
206                // 1. Price annotation
207                // 2. Other postings in the transaction
208                let cost_contribution = posting.cost.as_ref().and_then(|cost_spec| {
209                    // Try to get cost currency, falling back to price currency, then other postings
210                    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                            // Sign depends on units sign — the spec
224                            // names the total magnitude, not the
225                            // direction.
226                            Some((cost_curr.clone(), total * amount.number.signum()))
227                        }
228                        Some(rustledger_core::CostNumber::PerUnitFromTotal(b)) => {
229                            // Use the preserved total for precision
230                            // (same rationale as the residual block
231                            // above).
232                            Some((cost_curr.clone(), b.total * amount.number.signum()))
233                        }
234                        None => None, // empty `{}`
235                    }
236                });
237
238                if let Some((currency, cost_amount)) = cost_contribution {
239                    // Cost-based posting: weight is in the cost currency.
240                    // Cost spec scales are intentionally NOT tracked in
241                    // `max_scale_by_currency` — see its declaration for the
242                    // rationale (Python beancount with default
243                    // `infer_tolerance_from_cost = False`).
244                    *residuals.entry(currency).or_default() += cost_amount;
245                } else if posting.cost.is_some() {
246                    // Cost spec exists but has no determinable cost number (e.g.,
247                    // an empty `{}` spec where the lot's cost will be filled by
248                    // booking-time lot matching). The WEIGHT of this posting is
249                    // the cost basis × units, NOT the price × units — so we must
250                    // not fall through to the price branch below and use price
251                    // as a substitute (that's what happened pre-#1026 fix and
252                    // produced silent acceptance of unsolvable transactions).
253                    //
254                    // Track this as one unknown for the cost currency. The
255                    // post-loop check then enforces the "at most one unknown
256                    // per currency group" rule that bean-check enforces.
257                    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                    // Price annotation: converts units to price currency.
268                    // Scale tracking: per-unit prices are multipliers, so we
269                    // do NOT track their scale. Total prices are explicit
270                    // amounts, so we DO track theirs (non-integer scale
271                    // only — an integer `@@ 1 USD` shouldn't quantize an
272                    // elided same-currency residual to whole units).
273                    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                        // Incomplete/empty price annotation — fall back to units
298                        *residuals.entry(amount.currency.clone()).or_default() += amount.number;
299                    }
300                } else {
301                    // Simple posting: weight is just the units
302                    *residuals.entry(amount.currency.clone()).or_default() += amount.number;
303                }
304            }
305            Some(IncompleteAmount::CurrencyOnly(currency)) => {
306                // Currency known, number to be interpolated
307                missing_by_currency
308                    .entry(currency.clone())
309                    .or_default()
310                    .push(i);
311            }
312            Some(IncompleteAmount::NumberOnly(number)) => {
313                // Number known, currency to be inferred
314                // Try to get currency from cost or price
315                let currency = posting
316                    .cost
317                    .as_ref()
318                    .and_then(|c| c.currency.clone())
319                    .or_else(|| {
320                        // Pull currency from the price's complete amount,
321                        // regardless of kind. Incomplete/empty prices
322                        // contribute nothing here.
323                        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                    // We have currency from context, make it complete
333                    *residuals.entry(curr.clone()).or_default() += *number;
334                } else {
335                    // Can't determine currency yet
336                    unassigned_missing.push(i);
337                }
338            }
339            None => {
340                // Missing amount - try to determine currency from cost
341                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                // Can't determine currency yet
351                unassigned_missing.push(i);
352            }
353        }
354    }
355
356    // Check for multiple unknowns in the same currency group. An "unknown"
357    // is either a missing-amount posting or a posting with an empty cost
358    // spec (whose cost-basis weight contribution is unknown until booking
359    // resolves the lot match). Bean-check enforces "at most one unknown
360    // per currency group" — see issue #1026.
361    //
362    // Iterate currencies in sorted order so the error message is
363    // deterministic for the same input. HashMap iteration order is
364    // unspecified, so picking "the first failing currency" without
365    // sorting would produce non-reproducible test output.
366    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    // Same rule extended to "would-be" landing currencies for unassigned
390    // missing postings: an unassigned-missing posting absorbs residuals
391    // across all non-zero currencies at fill time, so it could land in
392    // any currency including one with a cost-unknown.
393    //
394    // Empirically verified against bean-check (issue #1026): bean-check
395    // rejects ANY combination of unassigned-missing + cost-unknown, even
396    // when the unassigned would semantically prefer a different currency.
397    // The reason is that an unassigned posting's currency assignment is
398    // determined post-hoc from non-zero residuals, and cost-unknowns
399    // contribute an unknown amount to their currency's residual — so the
400    // landing currency could always be the cost-unknown's currency. To
401    // require the user to make the absorber's currency explicit, reject.
402    //
403    // Pick the lexicographically-smallest cost-unknown currency for the
404    // error so the message is reproducible across runs.
405    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    // Fill in known-currency missing postings
418    for (currency, indices) in missing_by_currency {
419        let idx = indices[0];
420        let residual = residuals.get(&currency).copied().unwrap_or(Decimal::ZERO);
421
422        let interpolated =
423            round_interpolated(residual, max_scale_by_currency.get(&currency).copied());
424
425        result.postings[idx].units = Some(IncompleteAmount::Complete(Amount::new(
426            interpolated,
427            &currency,
428        )));
429        filled_indices.push(idx);
430
431        // Update residual to reflect actual interpolated amount (may have rounding difference)
432        *residuals.entry(currency).or_default() += interpolated;
433    }
434
435    // Handle unassigned missing postings
436    // Each one absorbs one or more currencies' residuals
437    if !unassigned_missing.is_empty() {
438        // Get currencies with non-zero residuals
439        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        // Special case: single missing posting with multiple currencies
446        // This is multi-currency interpolation - split into multiple postings
447        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            // Fill the first currency into the original posting
452            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            // Add new postings for remaining currencies
465            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            // Check for ambiguous elision: more unassigned missing postings than
479            // available residual currencies means multiple postings would all be
480            // assigned to the same currency, which is ambiguous and an error.
481            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            // Standard case: assign one currency per missing posting
491            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                    // Use the first currency
504                    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                    // No residuals but we can infer currency from cost basis
510                    // This handles balanced cost-basis transactions like:
511                    //   Assets:Crypto  100 USDC {1.0 USD}
512                    //   Assets:Cash   -100 USD
513                    //   Income:Trading  ; <- infer 0 USD from cost basis
514                    result.postings[*idx].units =
515                        Some(IncompleteAmount::Complete(Amount::zero(&currency)));
516                    filled_indices.push(*idx);
517                } else {
518                    // No residuals and cannot infer currency
519                    return Err(InterpolationError::CannotInferCurrency {
520                        account: transaction.postings[*idx].account.clone(),
521                    });
522                }
523            }
524        }
525    }
526
527    // Prune postings that were filled with zero amounts. Python
528    // beancount drops these from its rendered output too — they
529    // contribute nothing to the transaction balance and would just
530    // clutter BQL / JSON / format output.
531    //
532    // The historical concern (#877) was that pre-validation pruning
533    // hid `E1001 Account X was never opened` errors on elided
534    // postings to unopened accounts. The loader pipeline now runs an
535    // EARLY validation phase before booking (see
536    // `rustledger_validate::Phase::Early` and the "Python Compatibility
537    // Policy" section in CLAUDE.md), so account-presence checks fire
538    // BEFORE we reach this prune step. That's a deliberate divergence
539    // from Python — Python silently accepts these references; rledger
540    // catches them. Tested by `test_zero_interpolated_posting_keeps_e1001_*`
541    // in `rustledger-loader`.
542    //
543    // Iterate in reverse so indices stay valid as we remove.
544    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    // Drop the removed indices from filled_indices and shift the
563    // surviving ones down to reflect the new posting positions.
564    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    // Return the residuals we've been tracking incrementally
574    // (no need to recalculate - we've updated residuals as we filled amounts)
575    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    /// Helper to get the complete amount from a posting.
593    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        // Multiple unassigned missing postings with a single residual currency
689        // is ambiguous and should return MultipleMissing error.
690        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        // Two elided postings but two residual currencies - each gets one
700        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        // Two unassigned missing, two non-zero residuals - this is unambiguous
707        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        // 2015-10-02 *
717        //   Assets:Stock   10 HOOL {100.00 USD}
718        //   Assets:Cash
719        //
720        // Expected: Assets:Cash should be interpolated to -1000.00 USD
721        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        // Check that the cash posting was filled
736        assert_eq!(result.filled_indices, vec![1]);
737
738        // Check the interpolated amount
739        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        // Verify the transaction balances
752        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        // There should be NO HOOL residual
762        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        // 2015-10-02 *
771        //   Assets:Stock   10 HOOL {{1000.00 USD}}
772        //   Assets:Cash
773        //
774        // Expected: Assets:Cash should be interpolated to -1000.00 USD
775        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        // From beancount starter.beancount:
798        // 2013-02-03 * "Bought some stock"
799        //   Assets:Stock         8 HOOL {701.20 USD}
800        //   Expenses:Commission  7.95 USD
801        //   Assets:Cash
802        //
803        // Expected: Cash = -(8 * 701.20 + 7.95) = -5617.55 USD
804        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        // 8 * 701.20 = 5609.60, plus 7.95 commission = 5617.55
826        assert_eq!(amount.number, dec!(-5617.55));
827    }
828
829    #[test]
830    fn test_interpolate_stock_sale_with_cost_and_price() {
831        // Selling stock at a different price than cost basis
832        // 2015-10-02 *
833        //   Assets:Stock   -10 HOOL {100.00 USD} @ 120.00 USD
834        //   Assets:Cash
835        //   Income:Gains
836        //
837        // The sale is at cost (for booking), but price is 120 USD
838        // Weight: -10 * 100 = -1000 USD (at cost)
839        // Cash should receive: 10 * 120 = 1200 USD (at price)
840        // Gains: -200 USD
841        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        // Gains = cost - proceeds = 1000 - 1200 = -200 (income is negative)
868        assert_eq!(amount.number, dec!(-200.00));
869    }
870
871    #[test]
872    fn test_interpolate_balanced_with_cost_no_interpolation_needed() {
873        // When all amounts are provided, no interpolation needed
874        // 2015-10-02 *
875        //   Assets:Stock   10 HOOL {100.00 USD}
876        //   Assets:Cash   -1000.00 USD
877        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        // No postings should be filled
895        assert!(result.filled_indices.is_empty());
896
897        // Transaction should balance
898        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        // Selling stock (negative units) with cost
909        // 2015-10-02 *
910        //   Assets:Stock   -5 HOOL {100.00 USD}
911        //   Assets:Cash
912        //
913        // Expected: Cash = 500.00 USD (proceeds from sale at cost)
914        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)); // Positive (receiving cash)
932    }
933
934    // =========================================================================
935    // Multi-currency interpolation tests
936    // =========================================================================
937
938    #[test]
939    fn test_interpolate_multi_currency_single_elided() {
940        // Test case from basic.beancount:
941        // 2008-04-02 * "Gilbert paid back for iPhone"
942        //   Assets:Cash                            440.00 CAD
943        //   Assets:AccountsReceivable             -431.92 USD
944        //   Assets:Cash
945        //
946        // Expected: The elided Assets:Cash becomes TWO postings:
947        //   Assets:Cash: -440.00 CAD
948        //   Assets:Cash: 431.92 USD
949        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        // Should now have 4 postings (original 3 + 1 added for second currency)
963        assert_eq!(
964            result.transaction.postings.len(),
965            4,
966            "should split elided posting into 2"
967        );
968
969        // Check that all residuals are zero
970        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        // Verify the amounts (order may vary based on HashMap iteration)
978        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        // Three currencies with one elided posting
998        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        // Should now have 6 postings (original 4 + 2 added)
1007        assert_eq!(result.transaction.postings.len(), 6);
1008
1009        // All residuals should be zero
1010        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    // =========================================================================
1019    // Cost currency inference tests (issue #203)
1020    // =========================================================================
1021
1022    /// Test interpolation with cost currency inferred from other postings.
1023    /// This is the exact case from issue #203.
1024    #[test]
1025    fn test_interpolate_cost_currency_inferred_from_other_posting() {
1026        // 2026-01-01 * "Opening balance"
1027        //   Assets:Vanguard:IRA:Trad:VFIFX  10 VFIFX {100}
1028        //   Equity:Opening-Balances
1029        //
1030        // The cost currency should be inferred, and the elided posting should
1031        // be filled with -1000 USD.
1032        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        // Transaction should balance
1051        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 interpolation where the cash posting is elided.
1063    #[test]
1064    fn test_interpolate_cost_currency_inferred_elided_cash() {
1065        // Like issue #203 but with elided cash posting:
1066        // 2026-01-01 * "Opening balance"
1067        //   Assets:Vanguard:IRA:Trad:VFIFX  10 VFIFX {100}
1068        //   Equity:Opening-Balances  -1000 USD
1069        //
1070        // Both postings are complete, should just balance.
1071        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        // No postings filled since both are complete
1090        assert!(result.filled_indices.is_empty());
1091
1092        // Should balance
1093        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    // =========================================================================
1105    // Interpolation rounding tests (issue #268)
1106    // =========================================================================
1107
1108    /// Test that interpolated amounts are rounded to match the precision of other amounts.
1109    /// This matches Python beancount's behavior where interpolated amounts use the same
1110    /// quantum (decimal places) as other amounts in the same currency.
1111    ///
1112    /// Issue: <https://github.com/rustledger/rustledger/issues/268>
1113    #[test]
1114    fn test_interpolate_rounds_to_quantum() {
1115        // From issue #268:
1116        // 2026-01-02 * "..."
1117        //   Assets:Cash
1118        //   Assets:Abc                    12.3340 ABC {140.02 USD, 2025-01-01}
1119        //   Expenses:Abc                    -0.01 USD
1120        //
1121        // Cost: 12.3340 * 140.02 = 1727.006680 USD
1122        // Python rounds Cash to -1727.00 (2 decimal places from -0.01 USD)
1123        // Residual: 1727.006680 - 0.01 - 1727.00 = -0.003320 USD (within 0.005 tolerance)
1124        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        // Check that Cash was filled
1143        assert_eq!(result.filled_indices, vec![0]);
1144
1145        // The interpolated amount should be rounded to 2 decimal places
1146        // (matching the -0.01 USD in Expenses:Abc)
1147        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        // The residual should be non-zero but small (within tolerance)
1157        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 that interpolation uses the maximum scale when multiple amounts have different scales.
1170    #[test]
1171    fn test_interpolate_uses_max_scale() {
1172        // When we have amounts with different scales, use the maximum.
1173        // 0.1 USD (scale 1) and 0.001 USD (scale 3) -> interpolate to scale 3
1174        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        // The amount is exactly -0.101, which fits in 3 decimal places
1185        assert_eq!(amount.number, dec!(-0.101));
1186        // Scale should be 3 (the maximum of 1 and 3)
1187        assert_eq!(amount.number.scale(), 3);
1188    }
1189
1190    /// Test that cost spec scale is used when other postings have lower scale.
1191    ///
1192    /// Issue: <https://github.com/rustledger/rustledger/issues/333>
1193    ///
1194    /// When a transaction has:
1195    /// - A cost spec with decimal places (e.g., {2800.01 CAD})
1196    /// - Other postings with fewer decimal places (e.g., 1 CAD)
1197    ///
1198    /// The interpolated amount should use the cost spec's scale, not the
1199    /// lower scale from other postings.
1200    #[test]
1201    fn test_interpolate_cost_scale_preserved() {
1202        // From issue #333:
1203        // 2026-01-19 * "Buy stock"
1204        //   Assets:Stock  1 CSU { 2800.01 CAD }
1205        //   Expenses:Commission  1 CAD
1206        //   Assets:Cash
1207        //
1208        // Cost: 1 * 2800.01 = 2800.01 CAD (scale 2)
1209        // Commission: 1 CAD (scale 0)
1210        // Without fix: Cash rounds to -2801.00 (scale 0), leaving 0.01 residual
1211        // With fix: Cash is -2801.01 (scale 2), transaction balances
1212        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        // Check that Cash was filled
1231        assert_eq!(result.filled_indices, vec![2]);
1232
1233        // The interpolated amount should be -2801.01 (scale 2 from cost spec)
1234        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        // Transaction should balance (no residual)
1244        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    // =========================================================================
1256    // Currency inference from cost basis tests
1257    // =========================================================================
1258
1259    /// Test that zero-amount postings are removed when transaction balances perfectly.
1260    /// Zero-amount interpolated postings are pruned by booking.
1261    ///
1262    /// When a transaction with cost basis balances to zero (cost equals
1263    /// cash), the elided counterpart fills with 0 and gets dropped from
1264    /// the booked output — matches Python beancount's display behavior.
1265    /// The #877 invariant (catching E1001 on the elided posting's
1266    /// account) is preserved by running the loader's early-phase
1267    /// account validator BEFORE booking; see `rustledger-validate`'s
1268    /// `Phase::Early` and `test_zero_interpolated_posting_keeps_e1001_on_unopened_account`
1269    /// in `rustledger-loader/tests/loader_test.rs` for the
1270    /// end-to-end coverage.
1271    ///
1272    /// Example:
1273    /// ```beancount
1274    /// Assets:Crypto    100 USDC {1.0 USD, 2022-04-16}
1275    /// Assets:Cash     -100 USD
1276    /// Income:Trading   ; <- fills to 0 USD, pruned
1277    /// ```
1278    #[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    /// Zero-cost basis: empty posting fills to 0 and is pruned.
1313    ///
1314    /// Example:
1315    /// ```beancount
1316    /// Assets:Crypto    100 TOKEN {0 USD}
1317    /// Income:Bonus     ; <- fills to 0 USD, pruned
1318    /// ```
1319    #[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    /// Zero total cost: empty posting fills to 0 and is pruned.
1341    ///
1342    /// Example:
1343    /// ```beancount
1344    /// Assets:Crypto    100 TOKEN {{0 USD}}
1345    /// Income:Bonus     ; <- fills to 0 USD, pruned
1346    /// ```
1347    #[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    // ─── Issue #1026: empty cost spec + missing posting in same group ───
1369    //
1370    // bean-check rejects with "Too many missing numbers for currency
1371    // group 'CCY'" when a transaction has both:
1372    //   1. A posting with empty cost spec `{}` (cost-basis weight unknown
1373    //      until booking-pass lot matching).
1374    //   2. Another posting in the same currency group missing its amount.
1375    //
1376    // Pre-fix, rledger silently used the price annotation as the
1377    // posting's weight when cost was unknown, producing a balanced
1378    // residual and accepting the transaction.
1379
1380    /// Minimal repro from #1026's body: position with `{} @ price` plus
1381    /// missing-amount Income:PnL must error.
1382    #[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()) // empty `{}` — unknown cost
1393                .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    /// Empty cost spec by itself (no other missing posting) is OK — the
1427    /// booking pass will resolve the lot match. Pre- and post-fix should
1428    /// agree.
1429    #[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    /// Two empty cost specs in the same currency group: two cost-unknowns
1452    /// in one group, no missing-amount postings needed → still errors.
1453    #[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    /// Cost-unknown in one currency + missing-amount posting in a
1484    /// DIFFERENT currency: should succeed. The two unknowns belong to
1485    /// disjoint currency groups, so the rule is satisfied per-group.
1486    /// Verifies the rule check is per-currency, not global.
1487    #[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()) // cost-unknown in USD
1495                    .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                // Missing amount, currency known via CurrencyOnly: lands in EUR.
1504                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    /// Issue #1107: an interpolated residual must not inherit the high
1517    /// scale of a derived per-unit cost (which can be 26+ digits from
1518    /// `total / units` division). Python beancount quantizes the
1519    /// residual to currency precision derived from explicit posting
1520    /// units, not cost spec scales.
1521    ///
1522    /// Repro: a sell with explicit high-precision per-unit cost. Pre-fix,
1523    /// the cost scale (5) merged into `max_scale_by_currency[USD]`,
1524    /// rounding the residual to 5dp (`-36.72498`). Post-fix, only the
1525    /// `336.73 USD` cash side contributes to USD precision (scale=2), so
1526    /// the residual is `-36.72` (matches bean-query exactly).
1527    #[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    /// End-to-end #1107 repro through the booking pass — this is the
1580    /// path that actually surfaces in real ledgers, where the booking
1581    /// engine derives a 26+ digit per-unit cost from `{{total}} / units`
1582    /// (or lot-matches a `{}` sell against such a derived cost) and
1583    /// previously propagated that scale into the interpolated residual.
1584    ///
1585    /// Concretely models the healthequity fixture pattern: buy with
1586    /// `{{total}}` total cost, sell with `{}` lot-match. After booking,
1587    /// the sell's filled `CostSpec` carries the high-scale `per_unit` from
1588    /// the division — and interpolation must STILL round the missing
1589    /// Income residual to USD's 2dp (no posting-unit-scale cost-scale
1590    /// contamination).
1591    #[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        // Buy: 1.763 STOCK {{300.00 USD}} → booking derives
1597        // per_unit = 300.00 / 1.763 = ~170.16449... at 26-digit scale.
1598        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        // Sell: -1.763 STOCK {} @ 191.00 USD — empty cost spec; booking
1618        // lot-matches against the previous buy, filling the high-scale
1619        // derived per_unit. Income is missing, must be interpolated.
1620        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        // book_and_interpolate handles the empty `{}` lot match AND
1636        // runs interpolation on the booked transaction. The Income
1637        // residual must end up at USD's 2dp scale — pre-fix this
1638        // inherited the lot's derived 26-digit per_unit scale.
1639        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        // Use `_ = Cost::new` to keep the import live without an
1657        // unrelated unused-import warning if the test grows.
1658        let _ = Cost::new(dec!(1), "USD");
1659        let _: Option<IncompleteAmount> = None;
1660    }
1661
1662    /// UNASSIGNED missing posting (no currency context) instead of a
1663    /// currency-known one. bean-check rejects this because the
1664    /// unassigned could absorb residuals across all currencies including
1665    /// the cost-unknown's; the rejection is conservative-by-design.
1666    /// Pins the empirically-verified bean-check parity (#1026 review).
1667    #[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    // ---- #1309 cluster 2: residual / price arithmetic ----------------
1693    // Exact-value assertions on the residual math so the surviving
1694    // mutants (cost/price `*`, residual `+=`, the multi-currency split
1695    // guard and index math) are killed.
1696
1697    #[test]
1698    fn interpolate_unit_price_is_units_times_price() {
1699        // 10 STK @ 3 USD → the elided cash leg is -30 USD.
1700        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)); // kills `abs * price` and `* signum -> +`
1711    }
1712
1713    #[test]
1714    fn interpolate_total_price_is_total() {
1715        // 10 STK @@ 30 USD → elided cash -30 USD.
1716        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)); // kills total-price `* signum -> +`
1726        assert_eq!(cash.currency, "USD"); // right magnitude in the right currency
1727    }
1728
1729    #[test]
1730    fn interpolate_three_posting_residual_sum() {
1731        // 100 USD + 25 USD + elided → cash -125 USD.
1732        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)); // kills residual `+= -> -=`/`*=`
1739    }
1740
1741    #[test]
1742    fn interpolate_single_elided_splits_two_currencies() {
1743        // One auto posting absorbs two currency residuals → two filled
1744        // postings (-100 USD, -50 EUR). Exercises the multi-currency
1745        // split path's guard and `len() - 1` index push.
1746        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        // After filling the elided leg, the tracked residual must return
1776        // to zero (kills the post-fill `residual += interpolated` mutants:
1777        // `-=` → 2R, `*=` → R·interpolated).
1778        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        // Explicit USD legs net to zero; a 0.001 USD per-unit price
1793        // contribution leaves a sub-cent residual. The currency's tracked
1794        // scale is 2 (from the 1.00 USD legs), so naively rounding the
1795        // -0.001 fill to 0.00 would silently leave the txn unbalanced.
1796        // `round_interpolated` must keep full precision — kills the
1797        // `!residual.is_zero()` guard.
1798        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        // A CurrencyOnly elided leg (`Assets:Cash USD`, number missing)
1824        // is filled via the known-currency path; the post-fill residual
1825        // must return to zero (kills that path's `residual += interpolated`).
1826        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        // A NumberOnly leg (`-100`, currency missing) infers its currency
1841        // from its OWN price annotation (the arm only consults the
1842        // posting's own cost/price, never siblings — a bare NumberOnly
1843        // with no cost/price would route to the unassigned path instead).
1844        // The `@ 1 USD` price is a currency hint with a unit multiplier,
1845        // so the leg contributes `-100` to the residual via that arm's
1846        // `residual += *number` — which this test kills.
1847        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}