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                // The cost weight goes through the shared `cost_weight` engine —
198                // the SAME `CostNumber` ladder `calculate_residual` uses — so
199                // interpolation and the balance residual can no longer disagree
200                // (the rule: cost beats price; else price; else units).
201                let cost_contribution = crate::cost_weight::<Decimal>(posting, amount, || {
202                    get_inferred_currency(&mut inferred_cost_currency)
203                });
204
205                if let Some((currency, cost_amount)) = cost_contribution {
206                    // Cost-based posting: weight is in the cost currency.
207                    // Cost spec scales are intentionally NOT tracked in
208                    // `max_scale_by_currency` — see its declaration for the
209                    // rationale (Python beancount with default
210                    // `infer_tolerance_from_cost = False`).
211                    *residuals.entry(currency).or_default() += cost_amount;
212                } else if posting.cost.is_some() {
213                    // Cost spec exists but has no determinable cost number (e.g.,
214                    // an empty `{}` spec where the lot's cost will be filled by
215                    // booking-time lot matching). The WEIGHT of this posting is
216                    // the cost basis × units, NOT the price × units — so we must
217                    // not fall through to the price branch below and use price
218                    // as a substitute (that's what happened pre-#1026 fix and
219                    // produced silent acceptance of unsolvable transactions).
220                    //
221                    // Track this as one unknown for the cost currency. The
222                    // post-loop check then enforces the "at most one unknown
223                    // per currency group" rule that bean-check enforces.
224                    let cost_currency = crate::cost_currency_of(posting, || {
225                        get_inferred_currency(&mut inferred_cost_currency)
226                    });
227                    if let Some(curr) = cost_currency {
228                        *cost_unknowns_by_currency.entry(curr).or_default() += 1;
229                    }
230                } else if let Some(price) = &posting.price {
231                    // Price annotation: converts units to price currency.
232                    // Scale tracking: per-unit prices are multipliers, so we
233                    // do NOT track their scale. Total prices are explicit
234                    // amounts, so we DO track theirs (non-integer scale
235                    // only — an integer `@@ 1 USD` shouldn't quantize an
236                    // elided same-currency residual to whole units).
237                    if let Some(price_amt) =
238                        price.amount.as_ref().and_then(IncompleteAmount::as_amount)
239                    {
240                        let (curr, signed) = match price.kind {
241                            rustledger_core::PriceKind::Unit => (
242                                price_amt.currency.clone(),
243                                amount.number.abs() * price_amt.number * amount.number.signum(),
244                            ),
245                            rustledger_core::PriceKind::Total => {
246                                let scale = price_amt.number.scale();
247                                if scale > 0 {
248                                    max_scale_by_currency
249                                        .entry(price_amt.currency.clone())
250                                        .and_modify(|s| *s = (*s).max(scale))
251                                        .or_insert(scale);
252                                }
253                                (
254                                    price_amt.currency.clone(),
255                                    price_amt.number * amount.number.signum(),
256                                )
257                            }
258                        };
259                        *residuals.entry(curr).or_default() += signed;
260                    } else {
261                        // Incomplete/empty price annotation — fall back to units
262                        *residuals.entry(amount.currency.clone()).or_default() += amount.number;
263                    }
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                        // Pull currency from the price's complete amount,
285                        // regardless of kind. Incomplete/empty prices
286                        // contribute nothing here.
287                        posting
288                            .price
289                            .as_ref()
290                            .and_then(|p| p.amount.as_ref())
291                            .and_then(IncompleteAmount::as_amount)
292                            .map(|a| a.currency.clone())
293                    });
294
295                if let Some(curr) = currency {
296                    // We have currency from context, make it complete
297                    *residuals.entry(curr.clone()).or_default() += *number;
298                } else {
299                    // Can't determine currency yet
300                    unassigned_missing.push(i);
301                }
302            }
303            None => {
304                // Missing amount - try to determine currency from cost
305                if let Some(cost_spec) = &posting.cost
306                    && let Some(currency) = &cost_spec.currency
307                {
308                    missing_by_currency
309                        .entry(currency.clone())
310                        .or_default()
311                        .push(i);
312                    continue;
313                }
314                // Can't determine currency yet
315                unassigned_missing.push(i);
316            }
317        }
318    }
319
320    // Check for multiple unknowns in the same currency group. An "unknown"
321    // is either a missing-amount posting or a posting with an empty cost
322    // spec (whose cost-basis weight contribution is unknown until booking
323    // resolves the lot match). Bean-check enforces "at most one unknown
324    // per currency group" — see issue #1026.
325    //
326    // Iterate currencies in sorted order so the error message is
327    // deterministic for the same input. HashMap iteration order is
328    // unspecified, so picking "the first failing currency" without
329    // sorting would produce non-reproducible test output.
330    let mut currencies_with_unknowns: Vec<&Currency> = missing_by_currency
331        .keys()
332        .chain(cost_unknowns_by_currency.keys())
333        .collect();
334    currencies_with_unknowns.sort_by(|a, b| a.as_str().cmp(b.as_str()));
335    currencies_with_unknowns.dedup();
336    for currency in currencies_with_unknowns {
337        let missing_count = missing_by_currency
338            .get(currency)
339            .map_or(0, std::vec::Vec::len);
340        let cost_unknown_count = cost_unknowns_by_currency
341            .get(currency)
342            .copied()
343            .unwrap_or(0);
344        let total = missing_count + cost_unknown_count;
345        if total > 1 {
346            return Err(InterpolationError::MultipleMissing {
347                currency: currency.clone(),
348                count: total,
349            });
350        }
351    }
352
353    // Same rule extended to "would-be" landing currencies for unassigned
354    // missing postings: an unassigned-missing posting absorbs residuals
355    // across all non-zero currencies at fill time, so it could land in
356    // any currency including one with a cost-unknown.
357    //
358    // Empirically verified against bean-check (issue #1026): bean-check
359    // rejects ANY combination of unassigned-missing + cost-unknown, even
360    // when the unassigned would semantically prefer a different currency.
361    // The reason is that an unassigned posting's currency assignment is
362    // determined post-hoc from non-zero residuals, and cost-unknowns
363    // contribute an unknown amount to their currency's residual — so the
364    // landing currency could always be the cost-unknown's currency. To
365    // require the user to make the absorber's currency explicit, reject.
366    //
367    // Pick the lexicographically-smallest cost-unknown currency for the
368    // error so the message is reproducible across runs.
369    if !unassigned_missing.is_empty() {
370        let mut cost_unknown_keys: Vec<&Currency> = cost_unknowns_by_currency.keys().collect();
371        cost_unknown_keys.sort_by(|a, b| a.as_str().cmp(b.as_str()));
372        if let Some(curr) = cost_unknown_keys.first() {
373            let count = cost_unknowns_by_currency.get(*curr).copied().unwrap_or(0);
374            return Err(InterpolationError::MultipleMissing {
375                currency: (*curr).clone(),
376                count: count + unassigned_missing.len(),
377            });
378        }
379    }
380
381    // Fill in known-currency missing postings
382    for (currency, indices) in missing_by_currency {
383        let idx = indices[0];
384        let residual = residuals.get(&currency).copied().unwrap_or(Decimal::ZERO);
385
386        let interpolated =
387            round_interpolated(residual, max_scale_by_currency.get(&currency).copied());
388
389        result.postings[idx].units = Some(IncompleteAmount::Complete(Amount::new(
390            interpolated,
391            &currency,
392        )));
393        filled_indices.push(idx);
394
395        // Update residual to reflect actual interpolated amount (may have rounding difference)
396        *residuals.entry(currency).or_default() += interpolated;
397    }
398
399    // Handle unassigned missing postings
400    // Each one absorbs one or more currencies' residuals
401    if !unassigned_missing.is_empty() {
402        // Get currencies with non-zero residuals
403        let non_zero_residuals: Vec<(Currency, Decimal)> = residuals
404            .iter()
405            .filter(|&(_, v)| !v.is_zero())
406            .map(|(k, v)| (k.clone(), *v))
407            .collect();
408
409        // Special case: single missing posting with multiple currencies
410        // This is multi-currency interpolation - split into multiple postings
411        if unassigned_missing.len() == 1 && non_zero_residuals.len() > 1 {
412            let idx = unassigned_missing[0];
413            let original_posting = &transaction.postings[idx];
414
415            // Fill the first currency into the original posting
416            let (first_currency, first_residual) = &non_zero_residuals[0];
417            let interpolated = round_interpolated(
418                *first_residual,
419                max_scale_by_currency.get(first_currency).copied(),
420            );
421            result.postings[idx].units = Some(IncompleteAmount::Complete(Amount::new(
422                interpolated,
423                first_currency,
424            )));
425            filled_indices.push(idx);
426            *residuals.entry(first_currency.clone()).or_default() += interpolated;
427
428            // Add new postings for remaining currencies
429            for (currency, residual) in non_zero_residuals.iter().skip(1) {
430                let mut new_posting = original_posting.clone();
431                let interpolated =
432                    round_interpolated(*residual, max_scale_by_currency.get(currency).copied());
433                new_posting.units = Some(IncompleteAmount::Complete(Amount::new(
434                    interpolated,
435                    currency,
436                )));
437                result.postings.push(new_posting);
438                filled_indices.push(result.postings.len() - 1);
439                *residuals.entry(currency.clone()).or_default() += interpolated;
440            }
441        } else {
442            // Check for ambiguous elision: more unassigned missing postings than
443            // available residual currencies means multiple postings would all be
444            // assigned to the same currency, which is ambiguous and an error.
445            if unassigned_missing.len() > non_zero_residuals.len() && !non_zero_residuals.is_empty()
446            {
447                let (currency, _) = &non_zero_residuals[0];
448                return Err(InterpolationError::MultipleMissing {
449                    currency: currency.clone(),
450                    count: unassigned_missing.len(),
451                });
452            }
453
454            // Standard case: assign one currency per missing posting
455            for (i, idx) in unassigned_missing.iter().enumerate() {
456                if i < non_zero_residuals.len() {
457                    let (currency, residual) = &non_zero_residuals[i];
458                    let interpolated =
459                        round_interpolated(*residual, max_scale_by_currency.get(currency).copied());
460                    result.postings[*idx].units = Some(IncompleteAmount::Complete(Amount::new(
461                        interpolated,
462                        currency,
463                    )));
464                    filled_indices.push(*idx);
465                    *residuals.entry(currency.clone()).or_default() += interpolated;
466                } else if !non_zero_residuals.is_empty() {
467                    // Use the first currency
468                    let (currency, _) = &non_zero_residuals[0];
469                    result.postings[*idx].units =
470                        Some(IncompleteAmount::Complete(Amount::zero(currency)));
471                    filled_indices.push(*idx);
472                } else if let Some(currency) = get_inferred_currency(&mut inferred_cost_currency) {
473                    // No residuals but we can infer currency from cost basis
474                    // This handles balanced cost-basis transactions like:
475                    //   Assets:Crypto  100 USDC {1.0 USD}
476                    //   Assets:Cash   -100 USD
477                    //   Income:Trading  ; <- infer 0 USD from cost basis
478                    result.postings[*idx].units =
479                        Some(IncompleteAmount::Complete(Amount::zero(&currency)));
480                    filled_indices.push(*idx);
481                } else {
482                    // No residuals and cannot infer currency
483                    return Err(InterpolationError::CannotInferCurrency {
484                        account: transaction.postings[*idx].account.clone(),
485                    });
486                }
487            }
488        }
489    }
490
491    // Prune postings that were filled with zero amounts. Python
492    // beancount drops these from its rendered output too — they
493    // contribute nothing to the transaction balance and would just
494    // clutter BQL / JSON / format output.
495    //
496    // The historical concern (#877) was that pre-validation pruning
497    // hid `E1001 Account X was never opened` errors on elided
498    // postings to unopened accounts. The loader pipeline now runs an
499    // EARLY validation phase before booking (see
500    // `rustledger_validate::Phase::Early` and the "Python Compatibility
501    // Policy" section in CLAUDE.md), so account-presence checks fire
502    // BEFORE we reach this prune step. That's a deliberate divergence
503    // from Python — Python silently accepts these references; rledger
504    // catches them. Tested by `test_zero_interpolated_posting_keeps_e1001_*`
505    // in `rustledger-loader`.
506    //
507    // Iterate in reverse so indices stay valid as we remove.
508    let mut indices_to_remove: Vec<usize> = filled_indices
509        .iter()
510        .filter(|&&idx| {
511            result.postings.get(idx).is_some_and(|p| {
512                p.units
513                    .as_ref()
514                    .and_then(|u| u.as_amount())
515                    .is_some_and(|a| a.number.is_zero())
516            })
517        })
518        .copied()
519        .collect();
520    indices_to_remove.sort_unstable_by(|a, b| b.cmp(a));
521
522    for idx in &indices_to_remove {
523        result.postings.remove(*idx);
524    }
525
526    // Drop the removed indices from filled_indices and shift the
527    // surviving ones down to reflect the new posting positions.
528    let final_filled_indices: Vec<usize> = filled_indices
529        .into_iter()
530        .filter(|idx| !indices_to_remove.contains(idx))
531        .map(|idx| {
532            let adjustment = indices_to_remove.iter().filter(|&&r| r < idx).count();
533            idx - adjustment
534        })
535        .collect();
536
537    // Return the residuals we've been tracking incrementally
538    // (no need to recalculate - we've updated residuals as we filled amounts)
539    Ok(InterpolationResult {
540        transaction: result,
541        filled_indices: final_filled_indices,
542        residuals,
543    })
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549    use rust_decimal_macros::dec;
550    use rustledger_core::{NaiveDate, Posting};
551
552    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
553        rustledger_core::naive_date(year, month, day).unwrap()
554    }
555
556    /// Helper to get the complete amount from a posting.
557    fn get_amount(posting: &rustledger_core::Posting) -> Option<&Amount> {
558        posting.units.as_ref().and_then(|u| u.as_amount())
559    }
560
561    #[test]
562    fn test_interpolate_simple() {
563        let txn = Transaction::new(date(2024, 1, 15), "Test")
564            .with_synthesized_posting(Posting::new(
565                "Expenses:Food",
566                Amount::new(dec!(50.00), "USD"),
567            ))
568            .with_synthesized_posting(Posting::auto("Assets:Cash"));
569
570        let result = interpolate(&txn).unwrap();
571
572        assert_eq!(result.filled_indices, vec![1]);
573
574        let filled = &result.transaction.postings[1];
575        let amount = get_amount(filled).expect("should have amount");
576        assert_eq!(amount.number, dec!(-50.00));
577        assert_eq!(amount.currency, "USD");
578    }
579
580    #[test]
581    fn test_interpolate_multiple_postings() {
582        let txn = Transaction::new(date(2024, 1, 15), "Test")
583            .with_synthesized_posting(Posting::new(
584                "Expenses:Food",
585                Amount::new(dec!(30.00), "USD"),
586            ))
587            .with_synthesized_posting(Posting::new(
588                "Expenses:Drink",
589                Amount::new(dec!(20.00), "USD"),
590            ))
591            .with_synthesized_posting(Posting::auto("Assets:Cash"));
592
593        let result = interpolate(&txn).unwrap();
594
595        let filled = &result.transaction.postings[2];
596        let amount = get_amount(filled).expect("should have amount");
597        assert_eq!(amount.number, dec!(-50.00));
598    }
599
600    #[test]
601    fn test_interpolate_no_missing() {
602        let txn = Transaction::new(date(2024, 1, 15), "Test")
603            .with_synthesized_posting(Posting::new(
604                "Expenses:Food",
605                Amount::new(dec!(50.00), "USD"),
606            ))
607            .with_synthesized_posting(Posting::new(
608                "Assets:Cash",
609                Amount::new(dec!(-50.00), "USD"),
610            ));
611
612        let result = interpolate(&txn).unwrap();
613
614        assert!(result.filled_indices.is_empty());
615    }
616
617    #[test]
618    fn test_interpolate_multiple_currencies() {
619        let txn = Transaction::new(date(2024, 1, 15), "Test")
620            .with_synthesized_posting(Posting::new(
621                "Expenses:Food",
622                Amount::new(dec!(50.00), "USD"),
623            ))
624            .with_synthesized_posting(Posting::new(
625                "Expenses:Travel",
626                Amount::new(dec!(100.00), "EUR"),
627            ))
628            .with_synthesized_posting(Posting::new(
629                "Assets:Cash:USD",
630                Amount::new(dec!(-50.00), "USD"),
631            ))
632            .with_synthesized_posting(Posting::auto("Assets:Cash:EUR"));
633
634        let result = interpolate(&txn).unwrap();
635
636        let filled = &result.transaction.postings[3];
637        let amount = get_amount(filled).expect("should have amount");
638        assert_eq!(amount.number, dec!(-100.00));
639        assert_eq!(amount.currency, "EUR");
640    }
641
642    #[test]
643    fn test_interpolate_error_multiple_missing_same_currency() {
644        let txn = Transaction::new(date(2024, 1, 15), "Test")
645            .with_synthesized_posting(Posting::new(
646                "Expenses:Food",
647                Amount::new(dec!(50.00), "USD"),
648            ))
649            .with_synthesized_posting(Posting::auto("Assets:Cash"))
650            .with_synthesized_posting(Posting::auto("Assets:Bank"));
651
652        // Multiple unassigned missing postings with a single residual currency
653        // is ambiguous and should return MultipleMissing error.
654        let result = interpolate(&txn);
655        assert!(
656            matches!(result, Err(InterpolationError::MultipleMissing { .. })),
657            "expected MultipleMissing error, got: {result:?}"
658        );
659    }
660
661    #[test]
662    fn test_interpolate_multiple_missing_different_currencies_ok() {
663        // Two elided postings but two residual currencies - each gets one
664        let txn = Transaction::new(date(2024, 1, 15), "Multi-currency")
665            .with_synthesized_posting(Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD")))
666            .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")))
667            .with_synthesized_posting(Posting::auto("Liabilities:CreditCard"))
668            .with_synthesized_posting(Posting::auto("Equity:Exchange"));
669
670        // Two unassigned missing, two non-zero residuals - this is unambiguous
671        let result = interpolate(&txn);
672        assert!(
673            result.is_ok(),
674            "expected success for different-currency elision, got: {result:?}"
675        );
676    }
677
678    #[test]
679    fn test_interpolate_with_per_unit_cost() {
680        // 2015-10-02 *
681        //   Assets:Stock   10 HOOL {100.00 USD}
682        //   Assets:Cash
683        //
684        // Expected: Assets:Cash should be interpolated to -1000.00 USD
685        let txn = Transaction::new(date(2015, 10, 2), "Buy stock")
686            .with_synthesized_posting(
687                Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
688                    rustledger_core::CostSpec::empty()
689                        .with_number(rustledger_core::CostNumber::PerUnit {
690                            value: dec!(100.00),
691                        })
692                        .with_currency("USD"),
693                ),
694            )
695            .with_synthesized_posting(Posting::auto("Assets:Cash"));
696
697        let result = interpolate(&txn).expect("interpolation should succeed");
698
699        // Check that the cash posting was filled
700        assert_eq!(result.filled_indices, vec![1]);
701
702        // Check the interpolated amount
703        let filled = &result.transaction.postings[1];
704        let amount = get_amount(filled).expect("should have amount");
705        assert_eq!(
706            amount.currency, "USD",
707            "should be USD (cost currency), not HOOL"
708        );
709        assert_eq!(
710            amount.number,
711            dec!(-1000.00),
712            "should be -1000 USD (10 * 100)"
713        );
714
715        // Verify the transaction balances
716        let residual = result
717            .residuals
718            .get("USD")
719            .copied()
720            .unwrap_or(Decimal::ZERO);
721        assert!(
722            residual.abs() < dec!(0.01),
723            "USD residual should be ~0, got {residual}"
724        );
725        // There should be NO HOOL residual
726        assert!(
727            !result.residuals.contains_key("HOOL"),
728            "should not have HOOL residual"
729        );
730    }
731
732    /// Agreement fitness function: interpolation and balance-checking now share
733    /// the `cost_weight` engine, so after interpolation the *independent* public
734    /// [`crate::calculate_residual`] must see the result as balanced — across
735    /// cost AND price weights. If interpolation computed a posting using a
736    /// different weight than the residual does, this would surface a non-zero
737    /// residual.
738    #[test]
739    fn test_interpolated_weights_agree_with_calculate_residual() {
740        // Total-cost stock + unit-priced FX leg, both weighing in USD; the auto
741        // cash posting must absorb the USD residual exactly.
742        let txn = Transaction::new(date(2015, 10, 2), "Mixed cost and price")
743            .with_synthesized_posting(
744                Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
745                    rustledger_core::CostSpec::empty()
746                        .with_number(rustledger_core::CostNumber::Total {
747                            value: dec!(1500.00),
748                        })
749                        .with_currency("USD"),
750                ),
751            )
752            .with_synthesized_posting(
753                Posting::new("Assets:EUR", Amount::new(dec!(-200.00), "EUR")).with_price(
754                    rustledger_core::PriceAnnotation::unit(Amount::new(dec!(1.10), "USD")),
755                ),
756            )
757            .with_synthesized_posting(Posting::auto("Assets:Cash"));
758
759        let result = interpolate(&txn).expect("interpolation should succeed");
760
761        // The independent residual engine (sharing cost_weight) sees balance.
762        for (currency, value) in crate::calculate_residual(&result.transaction) {
763            assert!(
764                value.abs() < dec!(0.0001),
765                "interpolated result not balanced per calculate_residual: {value} {currency}"
766            );
767        }
768    }
769
770    #[test]
771    fn test_interpolate_with_total_cost() {
772        // 2015-10-02 *
773        //   Assets:Stock   10 HOOL {{1000.00 USD}}
774        //   Assets:Cash
775        //
776        // Expected: Assets:Cash should be interpolated to -1000.00 USD
777        let txn = Transaction::new(date(2015, 10, 2), "Buy stock")
778            .with_synthesized_posting(
779                Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
780                    rustledger_core::CostSpec::empty()
781                        .with_number(rustledger_core::CostNumber::Total {
782                            value: dec!(1000.00),
783                        })
784                        .with_currency("USD"),
785                ),
786            )
787            .with_synthesized_posting(Posting::auto("Assets:Cash"));
788
789        let result = interpolate(&txn).expect("interpolation should succeed");
790
791        let filled = &result.transaction.postings[1];
792        let amount = get_amount(filled).expect("should have amount");
793        assert_eq!(amount.currency, "USD");
794        assert_eq!(amount.number, dec!(-1000.00));
795    }
796
797    #[test]
798    fn test_interpolate_stock_purchase_with_commission() {
799        // From beancount starter.beancount:
800        // 2013-02-03 * "Bought some stock"
801        //   Assets:Stock         8 HOOL {701.20 USD}
802        //   Expenses:Commission  7.95 USD
803        //   Assets:Cash
804        //
805        // Expected: Cash = -(8 * 701.20 + 7.95) = -5617.55 USD
806        let txn = Transaction::new(date(2013, 2, 3), "Bought some stock")
807            .with_synthesized_posting(
808                Posting::new("Assets:Stock", Amount::new(dec!(8), "HOOL")).with_cost(
809                    rustledger_core::CostSpec::empty()
810                        .with_number(rustledger_core::CostNumber::PerUnit {
811                            value: dec!(701.20),
812                        })
813                        .with_currency("USD"),
814                ),
815            )
816            .with_synthesized_posting(Posting::new(
817                "Expenses:Commission",
818                Amount::new(dec!(7.95), "USD"),
819            ))
820            .with_synthesized_posting(Posting::auto("Assets:Cash"));
821
822        let result = interpolate(&txn).expect("interpolation should succeed");
823
824        let filled = &result.transaction.postings[2];
825        let amount = get_amount(filled).expect("should have amount");
826        assert_eq!(amount.currency, "USD");
827        // 8 * 701.20 = 5609.60, plus 7.95 commission = 5617.55
828        assert_eq!(amount.number, dec!(-5617.55));
829    }
830
831    #[test]
832    fn test_interpolate_stock_sale_with_cost_and_price() {
833        // Selling stock at a different price than cost basis
834        // 2015-10-02 *
835        //   Assets:Stock   -10 HOOL {100.00 USD} @ 120.00 USD
836        //   Assets:Cash
837        //   Income:Gains
838        //
839        // The sale is at cost (for booking), but price is 120 USD
840        // Weight: -10 * 100 = -1000 USD (at cost)
841        // Cash should receive: 10 * 120 = 1200 USD (at price)
842        // Gains: -200 USD
843        let txn = Transaction::new(date(2015, 10, 2), "Sell stock")
844            .with_synthesized_posting(
845                Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
846                    .with_cost(
847                        rustledger_core::CostSpec::empty()
848                            .with_number(rustledger_core::CostNumber::PerUnit {
849                                value: dec!(100.00),
850                            })
851                            .with_currency("USD"),
852                    )
853                    .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
854                        dec!(120.00),
855                        "USD",
856                    ))),
857            )
858            .with_synthesized_posting(Posting::new(
859                "Assets:Cash",
860                Amount::new(dec!(1200.00), "USD"),
861            ))
862            .with_synthesized_posting(Posting::auto("Income:Gains"));
863
864        let result = interpolate(&txn).expect("interpolation should succeed");
865
866        let filled = &result.transaction.postings[2];
867        let amount = get_amount(filled).expect("should have amount");
868        assert_eq!(amount.currency, "USD");
869        // Gains = cost - proceeds = 1000 - 1200 = -200 (income is negative)
870        assert_eq!(amount.number, dec!(-200.00));
871    }
872
873    #[test]
874    fn test_interpolate_balanced_with_cost_no_interpolation_needed() {
875        // When all amounts are provided, no interpolation needed
876        // 2015-10-02 *
877        //   Assets:Stock   10 HOOL {100.00 USD}
878        //   Assets:Cash   -1000.00 USD
879        let txn = Transaction::new(date(2015, 10, 2), "Buy stock")
880            .with_synthesized_posting(
881                Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
882                    rustledger_core::CostSpec::empty()
883                        .with_number(rustledger_core::CostNumber::PerUnit {
884                            value: dec!(100.00),
885                        })
886                        .with_currency("USD"),
887                ),
888            )
889            .with_synthesized_posting(Posting::new(
890                "Assets:Cash",
891                Amount::new(dec!(-1000.00), "USD"),
892            ));
893
894        let result = interpolate(&txn).expect("interpolation should succeed");
895
896        // No postings should be filled
897        assert!(result.filled_indices.is_empty());
898
899        // Transaction should balance
900        let residual = result
901            .residuals
902            .get("USD")
903            .copied()
904            .unwrap_or(Decimal::ZERO);
905        assert!(residual.abs() < dec!(0.01));
906    }
907
908    #[test]
909    fn test_interpolate_negative_cost_units_sale() {
910        // Selling stock (negative units) with cost
911        // 2015-10-02 *
912        //   Assets:Stock   -5 HOOL {100.00 USD}
913        //   Assets:Cash
914        //
915        // Expected: Cash = 500.00 USD (proceeds from sale at cost)
916        let txn = Transaction::new(date(2015, 10, 2), "Sell stock")
917            .with_synthesized_posting(
918                Posting::new("Assets:Stock", Amount::new(dec!(-5), "HOOL")).with_cost(
919                    rustledger_core::CostSpec::empty()
920                        .with_number(rustledger_core::CostNumber::PerUnit {
921                            value: dec!(100.00),
922                        })
923                        .with_currency("USD"),
924                ),
925            )
926            .with_synthesized_posting(Posting::auto("Assets:Cash"));
927
928        let result = interpolate(&txn).expect("interpolation should succeed");
929
930        let filled = &result.transaction.postings[1];
931        let amount = get_amount(filled).expect("should have amount");
932        assert_eq!(amount.currency, "USD");
933        assert_eq!(amount.number, dec!(500.00)); // Positive (receiving cash)
934    }
935
936    // =========================================================================
937    // Multi-currency interpolation tests
938    // =========================================================================
939
940    #[test]
941    fn test_interpolate_multi_currency_single_elided() {
942        // Test case from basic.beancount:
943        // 2008-04-02 * "Gilbert paid back for iPhone"
944        //   Assets:Cash                            440.00 CAD
945        //   Assets:AccountsReceivable             -431.92 USD
946        //   Assets:Cash
947        //
948        // Expected: The elided Assets:Cash becomes TWO postings:
949        //   Assets:Cash: -440.00 CAD
950        //   Assets:Cash: 431.92 USD
951        let txn = Transaction::new(date(2008, 4, 2), "Gilbert paid back for iPhone")
952            .with_synthesized_posting(Posting::new(
953                "Assets:Cash",
954                Amount::new(dec!(440.00), "CAD"),
955            ))
956            .with_synthesized_posting(Posting::new(
957                "Assets:AccountsReceivable",
958                Amount::new(dec!(-431.92), "USD"),
959            ))
960            .with_synthesized_posting(Posting::auto("Assets:Cash"));
961
962        let result = interpolate(&txn).expect("interpolation should succeed");
963
964        // Should now have 4 postings (original 3 + 1 added for second currency)
965        assert_eq!(
966            result.transaction.postings.len(),
967            4,
968            "should split elided posting into 2"
969        );
970
971        // Check that all residuals are zero
972        for (currency, residual) in &result.residuals {
973            assert!(
974                residual.abs() < dec!(0.01),
975                "{currency} residual should be ~0, got {residual}"
976            );
977        }
978
979        // Verify the amounts (order may vary based on HashMap iteration)
980        let mut found_cad = false;
981        let mut found_usd = false;
982        for posting in &result.transaction.postings {
983            if let Some(amount) = get_amount(posting)
984                && posting.account.as_str() == "Assets:Cash"
985            {
986                if amount.currency == "CAD" && amount.number == dec!(-440.00) {
987                    found_cad = true;
988                } else if amount.currency == "USD" && amount.number == dec!(431.92) {
989                    found_usd = true;
990                }
991            }
992        }
993        assert!(found_cad, "should have -440.00 CAD posting");
994        assert!(found_usd, "should have 431.92 USD posting");
995    }
996
997    #[test]
998    fn test_interpolate_multi_currency_three_currencies() {
999        // Three currencies with one elided posting
1000        let txn = Transaction::new(date(2024, 1, 15), "Multi-currency test")
1001            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(100), "USD")))
1002            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(200), "EUR")))
1003            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(300), "GBP")))
1004            .with_synthesized_posting(Posting::auto("Equity:Opening"));
1005
1006        let result = interpolate(&txn).expect("interpolation should succeed");
1007
1008        // Should now have 6 postings (original 4 + 2 added)
1009        assert_eq!(result.transaction.postings.len(), 6);
1010
1011        // All residuals should be zero
1012        for (currency, residual) in &result.residuals {
1013            assert!(
1014                residual.abs() < dec!(0.01),
1015                "{currency} residual should be ~0, got {residual}"
1016            );
1017        }
1018    }
1019
1020    // =========================================================================
1021    // Cost currency inference tests (issue #203)
1022    // =========================================================================
1023
1024    /// Test interpolation with cost currency inferred from other postings.
1025    /// This is the exact case from issue #203.
1026    #[test]
1027    fn test_interpolate_cost_currency_inferred_from_other_posting() {
1028        // 2026-01-01 * "Opening balance"
1029        //   Assets:Vanguard:IRA:Trad:VFIFX  10 VFIFX {100}
1030        //   Equity:Opening-Balances
1031        //
1032        // The cost currency should be inferred, and the elided posting should
1033        // be filled with -1000 USD.
1034        let txn = Transaction::new(date(2026, 1, 1), "Opening balance")
1035            .with_synthesized_posting(
1036                Posting::new(
1037                    "Assets:Vanguard:IRA:Trad:VFIFX",
1038                    Amount::new(dec!(10), "VFIFX"),
1039                )
1040                .with_cost(
1041                    rustledger_core::CostSpec::empty()
1042                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) }),
1043                ),
1044            )
1045            .with_synthesized_posting(Posting::new(
1046                "Equity:Opening-Balances",
1047                Amount::new(dec!(-1000), "USD"),
1048            ));
1049
1050        let result = interpolate(&txn).expect("interpolation should succeed");
1051
1052        // Transaction should balance
1053        let residual = result
1054            .residuals
1055            .get("USD")
1056            .copied()
1057            .unwrap_or(Decimal::ZERO);
1058        assert!(
1059            residual.abs() < dec!(0.01),
1060            "USD residual should be ~0, got {residual}"
1061        );
1062    }
1063
1064    /// Test interpolation where the cash posting is elided.
1065    #[test]
1066    fn test_interpolate_cost_currency_inferred_elided_cash() {
1067        // Like issue #203 but with elided cash posting:
1068        // 2026-01-01 * "Opening balance"
1069        //   Assets:Vanguard:IRA:Trad:VFIFX  10 VFIFX {100}
1070        //   Equity:Opening-Balances  -1000 USD
1071        //
1072        // Both postings are complete, should just balance.
1073        let txn = Transaction::new(date(2026, 1, 1), "Opening balance")
1074            .with_synthesized_posting(
1075                Posting::new(
1076                    "Assets:Vanguard:IRA:Trad:VFIFX",
1077                    Amount::new(dec!(10), "VFIFX"),
1078                )
1079                .with_cost(
1080                    rustledger_core::CostSpec::empty()
1081                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) }),
1082                ),
1083            )
1084            .with_synthesized_posting(Posting::new(
1085                "Equity:Opening-Balances",
1086                Amount::new(dec!(-1000), "USD"),
1087            ));
1088
1089        let result = interpolate(&txn).expect("interpolation should succeed");
1090
1091        // No postings filled since both are complete
1092        assert!(result.filled_indices.is_empty());
1093
1094        // Should balance
1095        let residual = result
1096            .residuals
1097            .get("USD")
1098            .copied()
1099            .unwrap_or(Decimal::ZERO);
1100        assert!(
1101            residual.abs() < dec!(0.01),
1102            "USD residual should be ~0, got {residual}"
1103        );
1104    }
1105
1106    // =========================================================================
1107    // Interpolation rounding tests (issue #268)
1108    // =========================================================================
1109
1110    /// Test that interpolated amounts are rounded to match the precision of other amounts.
1111    /// This matches Python beancount's behavior where interpolated amounts use the same
1112    /// quantum (decimal places) as other amounts in the same currency.
1113    ///
1114    /// Issue: <https://github.com/rustledger/rustledger/issues/268>
1115    #[test]
1116    fn test_interpolate_rounds_to_quantum() {
1117        // From issue #268:
1118        // 2026-01-02 * "..."
1119        //   Assets:Cash
1120        //   Assets:Abc                    12.3340 ABC {140.02 USD, 2025-01-01}
1121        //   Expenses:Abc                    -0.01 USD
1122        //
1123        // Cost: 12.3340 * 140.02 = 1727.006680 USD
1124        // Python rounds Cash to -1727.00 (2 decimal places from -0.01 USD)
1125        // Residual: 1727.006680 - 0.01 - 1727.00 = -0.003320 USD (within 0.005 tolerance)
1126        let txn = Transaction::new(date(2026, 1, 2), "Test")
1127            .with_synthesized_posting(Posting::auto("Assets:Cash"))
1128            .with_synthesized_posting(
1129                Posting::new("Assets:Abc", Amount::new(dec!(12.3340), "ABC")).with_cost(
1130                    rustledger_core::CostSpec::empty()
1131                        .with_number(rustledger_core::CostNumber::PerUnit {
1132                            value: dec!(140.02),
1133                        })
1134                        .with_currency("USD"),
1135                ),
1136            )
1137            .with_synthesized_posting(Posting::new(
1138                "Expenses:Abc",
1139                Amount::new(dec!(-0.01), "USD"),
1140            ));
1141
1142        let result = interpolate(&txn).expect("interpolation should succeed");
1143
1144        // Check that Cash was filled
1145        assert_eq!(result.filled_indices, vec![0]);
1146
1147        // The interpolated amount should be rounded to 2 decimal places
1148        // (matching the -0.01 USD in Expenses:Abc)
1149        let filled = &result.transaction.postings[0];
1150        let amount = get_amount(filled).expect("should have amount");
1151        assert_eq!(amount.currency, "USD");
1152        assert_eq!(
1153            amount.number,
1154            dec!(-1727.00),
1155            "should be -1727.00 USD (rounded to 2 decimal places)"
1156        );
1157
1158        // The residual should be non-zero but small (within tolerance)
1159        let residual = result
1160            .residuals
1161            .get("USD")
1162            .copied()
1163            .unwrap_or(Decimal::ZERO);
1164        assert_eq!(
1165            residual,
1166            dec!(-0.003320),
1167            "residual should be -0.003320 USD"
1168        );
1169    }
1170
1171    /// Test that interpolation uses the maximum scale when multiple amounts have different scales.
1172    #[test]
1173    fn test_interpolate_uses_max_scale() {
1174        // When we have amounts with different scales, use the maximum.
1175        // 0.1 USD (scale 1) and 0.001 USD (scale 3) -> interpolate to scale 3
1176        let txn = Transaction::new(date(2024, 1, 15), "Test")
1177            .with_synthesized_posting(Posting::new("Expenses:A", Amount::new(dec!(0.1), "USD")))
1178            .with_synthesized_posting(Posting::new("Expenses:B", Amount::new(dec!(0.001), "USD")))
1179            .with_synthesized_posting(Posting::auto("Assets:Cash"));
1180
1181        let result = interpolate(&txn).expect("interpolation should succeed");
1182
1183        let filled = &result.transaction.postings[2];
1184        let amount = get_amount(filled).expect("should have amount");
1185
1186        // The amount is exactly -0.101, which fits in 3 decimal places
1187        assert_eq!(amount.number, dec!(-0.101));
1188        // Scale should be 3 (the maximum of 1 and 3)
1189        assert_eq!(amount.number.scale(), 3);
1190    }
1191
1192    /// Test that cost spec scale is used when other postings have lower scale.
1193    ///
1194    /// Issue: <https://github.com/rustledger/rustledger/issues/333>
1195    ///
1196    /// When a transaction has:
1197    /// - A cost spec with decimal places (e.g., {2800.01 CAD})
1198    /// - Other postings with fewer decimal places (e.g., 1 CAD)
1199    ///
1200    /// The interpolated amount should use the cost spec's scale, not the
1201    /// lower scale from other postings.
1202    #[test]
1203    fn test_interpolate_cost_scale_preserved() {
1204        // From issue #333:
1205        // 2026-01-19 * "Buy stock"
1206        //   Assets:Stock  1 CSU { 2800.01 CAD }
1207        //   Expenses:Commission  1 CAD
1208        //   Assets:Cash
1209        //
1210        // Cost: 1 * 2800.01 = 2800.01 CAD (scale 2)
1211        // Commission: 1 CAD (scale 0)
1212        // Without fix: Cash rounds to -2801.00 (scale 0), leaving 0.01 residual
1213        // With fix: Cash is -2801.01 (scale 2), transaction balances
1214        let txn = Transaction::new(date(2026, 1, 19), "Buy stock")
1215            .with_synthesized_posting(
1216                Posting::new("Assets:Stock", Amount::new(dec!(1), "CSU")).with_cost(
1217                    rustledger_core::CostSpec::empty()
1218                        .with_number(rustledger_core::CostNumber::PerUnit {
1219                            value: dec!(2800.01),
1220                        })
1221                        .with_currency("CAD"),
1222                ),
1223            )
1224            .with_synthesized_posting(Posting::new(
1225                "Expenses:Commission",
1226                Amount::new(dec!(1), "CAD"),
1227            ))
1228            .with_synthesized_posting(Posting::auto("Assets:Cash"));
1229
1230        let result = interpolate(&txn).expect("interpolation should succeed");
1231
1232        // Check that Cash was filled
1233        assert_eq!(result.filled_indices, vec![2]);
1234
1235        // The interpolated amount should be -2801.01 (scale 2 from cost spec)
1236        let filled = &result.transaction.postings[2];
1237        let amount = get_amount(filled).expect("should have amount");
1238        assert_eq!(amount.currency, "CAD");
1239        assert_eq!(
1240            amount.number,
1241            dec!(-2801.01),
1242            "should be -2801.01 CAD (preserving cost spec precision)"
1243        );
1244
1245        // Transaction should balance (no residual)
1246        let residual = result
1247            .residuals
1248            .get("CAD")
1249            .copied()
1250            .unwrap_or(Decimal::ZERO);
1251        assert!(
1252            residual.is_zero(),
1253            "CAD residual should be 0, got {residual}"
1254        );
1255    }
1256
1257    // =========================================================================
1258    // Currency inference from cost basis tests
1259    // =========================================================================
1260
1261    /// Test that zero-amount postings are removed when transaction balances perfectly.
1262    /// Zero-amount interpolated postings are pruned by booking.
1263    ///
1264    /// When a transaction with cost basis balances to zero (cost equals
1265    /// cash), the elided counterpart fills with 0 and gets dropped from
1266    /// the booked output — matches Python beancount's display behavior.
1267    /// The #877 invariant (catching E1001 on the elided posting's
1268    /// account) is preserved by running the loader's early-phase
1269    /// account validator BEFORE booking; see `rustledger-validate`'s
1270    /// `Phase::Early` and `test_zero_interpolated_posting_keeps_e1001_on_unopened_account`
1271    /// in `rustledger-loader/tests/loader_test.rs` for the
1272    /// end-to-end coverage.
1273    ///
1274    /// Example:
1275    /// ```beancount
1276    /// Assets:Crypto    100 USDC {1.0 USD, 2022-04-16}
1277    /// Assets:Cash     -100 USD
1278    /// Income:Trading   ; <- fills to 0 USD, pruned
1279    /// ```
1280    #[test]
1281    fn test_interpolate_balanced_cost_prunes_zero_posting() {
1282        let txn = Transaction::new(date(2022, 4, 16), "Trade")
1283            .with_synthesized_posting(
1284                Posting::new("Assets:Crypto", Amount::new(dec!(100), "USDC")).with_cost(
1285                    rustledger_core::CostSpec::empty()
1286                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.0) })
1287                        .with_currency("USD"),
1288                ),
1289            )
1290            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-100), "USD")))
1291            .with_synthesized_posting(Posting::auto("Income:Trading"));
1292
1293        let result = interpolate(&txn).expect("interpolation should succeed");
1294
1295        assert!(
1296            result.filled_indices.is_empty(),
1297            "zero-amount filled posting should have been pruned"
1298        );
1299        assert_eq!(
1300            result.transaction.postings.len(),
1301            2,
1302            "Income:Trading filled to 0 USD should be pruned"
1303        );
1304        assert!(
1305            !result
1306                .transaction
1307                .postings
1308                .iter()
1309                .any(|p| p.account.as_str() == "Income:Trading"),
1310            "Income:Trading should not be in postings after pruning"
1311        );
1312    }
1313
1314    /// Zero-cost basis: empty posting fills to 0 and is pruned.
1315    ///
1316    /// Example:
1317    /// ```beancount
1318    /// Assets:Crypto    100 TOKEN {0 USD}
1319    /// Income:Bonus     ; <- fills to 0 USD, pruned
1320    /// ```
1321    #[test]
1322    fn test_interpolate_zero_cost_prunes_zero_posting() {
1323        let txn = Transaction::new(date(2022, 4, 16), "Free tokens")
1324            .with_synthesized_posting(
1325                Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
1326                    rustledger_core::CostSpec::empty()
1327                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
1328                        .with_currency("USD"),
1329                ),
1330            )
1331            .with_synthesized_posting(Posting::auto("Income:Bonus"));
1332
1333        let result = interpolate(&txn).expect("interpolation should succeed");
1334
1335        assert!(
1336            result.filled_indices.is_empty(),
1337            "zero-amount filled posting should have been pruned"
1338        );
1339        assert_eq!(result.transaction.postings.len(), 1);
1340    }
1341
1342    /// Zero total cost: empty posting fills to 0 and is pruned.
1343    ///
1344    /// Example:
1345    /// ```beancount
1346    /// Assets:Crypto    100 TOKEN {{0 USD}}
1347    /// Income:Bonus     ; <- fills to 0 USD, pruned
1348    /// ```
1349    #[test]
1350    fn test_interpolate_zero_total_cost_prunes_zero_posting() {
1351        let txn = Transaction::new(date(2022, 4, 16), "Free tokens")
1352            .with_synthesized_posting(
1353                Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
1354                    rustledger_core::CostSpec::empty()
1355                        .with_number(rustledger_core::CostNumber::Total { value: dec!(0) })
1356                        .with_currency("USD"),
1357                ),
1358            )
1359            .with_synthesized_posting(Posting::auto("Income:Bonus"));
1360
1361        let result = interpolate(&txn).expect("interpolation should succeed");
1362
1363        assert!(
1364            result.filled_indices.is_empty(),
1365            "zero-amount filled posting should have been pruned"
1366        );
1367        assert_eq!(result.transaction.postings.len(), 1);
1368    }
1369
1370    // ─── Issue #1026: empty cost spec + missing posting in same group ───
1371    //
1372    // bean-check rejects with "Too many missing numbers for currency
1373    // group 'CCY'" when a transaction has both:
1374    //   1. A posting with empty cost spec `{}` (cost-basis weight unknown
1375    //      until booking-pass lot matching).
1376    //   2. Another posting in the same currency group missing its amount.
1377    //
1378    // Pre-fix, rledger silently used the price annotation as the
1379    // posting's weight when cost was unknown, producing a balanced
1380    // residual and accepting the transaction.
1381
1382    /// Minimal repro from #1026's body: position with `{} @ price` plus
1383    /// missing-amount Income:PnL must error.
1384    #[test]
1385    fn test_interpolate_empty_cost_spec_with_missing_amount_errors() {
1386        use rustledger_core::CostSpec;
1387
1388        let txn = Transaction::new(date(2022, 1, 12), "sell what was never bought")
1389            .with_synthesized_posting(
1390                Posting::new(
1391                    "Assets:Htsec:Positions",
1392                    Amount::new(dec!(-13000.00), "SH513050"),
1393                )
1394                .with_cost(CostSpec::empty()) // empty `{}` — unknown cost
1395                .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1396                    dec!(1.300),
1397                    "CNY",
1398                ))),
1399            )
1400            .with_synthesized_posting(Posting::new(
1401                "Assets:Htsec:Cash",
1402                Amount::new(dec!(16900.00), "CNY"),
1403            ))
1404            .with_synthesized_posting(Posting::new(
1405                "Assets:Htsec:Cash",
1406                Amount::new(dec!(-0.85), "CNY"),
1407            ))
1408            .with_synthesized_posting(Posting::new(
1409                "Expenses:Htsec:Commission",
1410                Amount::new(dec!(0.85), "CNY"),
1411            ))
1412            .with_synthesized_posting(Posting::auto("Income:Htsec:PnL"));
1413
1414        let result = interpolate(&txn);
1415        assert!(
1416            matches!(result, Err(InterpolationError::MultipleMissing { .. })),
1417            "expected MultipleMissing error from empty cost spec + missing posting; got {result:?}"
1418        );
1419        if let Err(InterpolationError::MultipleMissing { currency, count }) = result {
1420            assert_eq!(currency.as_str(), "CNY");
1421            assert!(
1422                count >= 2,
1423                "expected count >= 2 unknowns in CNY group, got {count}"
1424            );
1425        }
1426    }
1427
1428    /// Empty cost spec by itself (no other missing posting) is OK — the
1429    /// booking pass will resolve the lot match. Pre- and post-fix should
1430    /// agree.
1431    #[test]
1432    fn test_interpolate_empty_cost_spec_alone_ok() {
1433        use rustledger_core::CostSpec;
1434
1435        let txn = Transaction::new(date(2022, 1, 12), "Sell HOOL")
1436            .with_synthesized_posting(
1437                Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
1438                    .with_cost(CostSpec::empty())
1439                    .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1440                        dec!(150),
1441                        "USD",
1442                    ))),
1443            )
1444            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")));
1445
1446        let result = interpolate(&txn);
1447        assert!(
1448            result.is_ok(),
1449            "single empty cost spec with no missing postings should succeed; got {result:?}"
1450        );
1451    }
1452
1453    /// Two empty cost specs in the same currency group: two cost-unknowns
1454    /// in one group, no missing-amount postings needed → still errors.
1455    #[test]
1456    fn test_interpolate_two_empty_cost_specs_same_currency_errors() {
1457        use rustledger_core::CostSpec;
1458
1459        let txn = Transaction::new(date(2022, 1, 12), "Two unknown-cost sells")
1460            .with_synthesized_posting(
1461                Posting::new("Assets:StockA", Amount::new(dec!(-10), "AAPL"))
1462                    .with_cost(CostSpec::empty())
1463                    .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1464                        dec!(150),
1465                        "USD",
1466                    ))),
1467            )
1468            .with_synthesized_posting(
1469                Posting::new("Assets:StockB", Amount::new(dec!(-5), "GOOG"))
1470                    .with_cost(CostSpec::empty())
1471                    .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1472                        dec!(2000),
1473                        "USD",
1474                    ))),
1475            )
1476            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(11500), "USD")));
1477
1478        let result = interpolate(&txn);
1479        assert!(
1480            matches!(result, Err(InterpolationError::MultipleMissing { .. })),
1481            "two empty cost specs in same currency should error; got {result:?}"
1482        );
1483    }
1484
1485    /// Cost-unknown in one currency + missing-amount posting in a
1486    /// DIFFERENT currency: should succeed. The two unknowns belong to
1487    /// disjoint currency groups, so the rule is satisfied per-group.
1488    /// Verifies the rule check is per-currency, not global.
1489    #[test]
1490    fn test_interpolate_empty_cost_spec_with_missing_in_different_currency_ok() {
1491        use rustledger_core::CostSpec;
1492
1493        let txn = Transaction::new(date(2022, 1, 12), "Sale + currency-known absorber")
1494            .with_synthesized_posting(
1495                Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
1496                    .with_cost(CostSpec::empty()) // cost-unknown in USD
1497                    .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1498                        dec!(150),
1499                        "USD",
1500                    ))),
1501            )
1502            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")))
1503            .with_synthesized_posting(Posting::new("Expenses:Fee", Amount::new(dec!(5), "EUR")))
1504            .with_synthesized_posting(Posting {
1505                // Missing amount, currency known via CurrencyOnly: lands in EUR.
1506                units: Some(IncompleteAmount::CurrencyOnly("EUR".into())),
1507                ..Posting::auto("Income:Misc")
1508            });
1509
1510        let result = interpolate(&txn);
1511        assert!(
1512            result.is_ok(),
1513            "cost-unknown in USD + missing-amount in EUR should succeed (disjoint groups); \
1514             got {result:?}"
1515        );
1516    }
1517
1518    /// Issue #1107: an interpolated residual must not inherit the high
1519    /// scale of a derived per-unit cost (which can be 26+ digits from
1520    /// `total / units` division). Python beancount quantizes the
1521    /// residual to currency precision derived from explicit posting
1522    /// units, not cost spec scales.
1523    ///
1524    /// Repro: a sell with explicit high-precision per-unit cost. Pre-fix,
1525    /// the cost scale (5) merged into `max_scale_by_currency[USD]`,
1526    /// rounding the residual to 5dp (`-36.72498`). Post-fix, only the
1527    /// `336.73 USD` cash side contributes to USD precision (scale=2), so
1528    /// the residual is `-36.72` (matches bean-query exactly).
1529    #[test]
1530    fn test_interpolate_residual_ignores_cost_spec_scale() {
1531        use rustledger_core::CostSpec;
1532
1533        let cost_spec = CostSpec {
1534            number: Some(rustledger_core::CostNumber::PerUnit {
1535                value: dec!(170.16734),
1536            }),
1537            currency: Some(Currency::from("USD")),
1538            date: None,
1539            label: None,
1540            merge: false,
1541        };
1542
1543        let txn = Transaction::new(date(2016, 2, 12), "Sell")
1544            .with_synthesized_posting(Posting::new(
1545                "Assets:Cash",
1546                Amount::new(dec!(336.73), "USD"),
1547            ))
1548            .with_synthesized_posting(
1549                Posting::new("Assets:Brokerage", Amount::new(dec!(-1.763), "STOCK"))
1550                    .with_cost(cost_spec)
1551                    .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1552                        dec!(191.00),
1553                        "USD",
1554                    ))),
1555            )
1556            .with_synthesized_posting(Posting::auto("Income:Capital-Gains"));
1557
1558        let result = interpolate(&txn).expect("interpolation should succeed");
1559        let filled = &result.transaction.postings[2];
1560        let amount = get_amount(filled).expect("Income should have amount");
1561
1562        assert_eq!(
1563            amount.currency.as_str(),
1564            "USD",
1565            "residual currency should be USD"
1566        );
1567        assert_eq!(
1568            amount.number.scale(),
1569            2,
1570            "residual scale must be 2 (USD precision from `336.73 USD`), \
1571             not 5 (from cost spec). Pre-fix this was 5. (#1107)"
1572        );
1573        assert_eq!(
1574            amount.number,
1575            dec!(-36.72),
1576            "residual value should match bean-query exactly (#1107). \
1577             Was -36.72498 before fix."
1578        );
1579    }
1580
1581    /// End-to-end #1107 repro through the booking pass — this is the
1582    /// path that actually surfaces in real ledgers, where the booking
1583    /// engine derives a 26+ digit per-unit cost from `{{total}} / units`
1584    /// (or lot-matches a `{}` sell against such a derived cost) and
1585    /// previously propagated that scale into the interpolated residual.
1586    ///
1587    /// Concretely models the healthequity fixture pattern: buy with
1588    /// `{{total}}` total cost, sell with `{}` lot-match. After booking,
1589    /// the sell's filled `CostSpec` carries the high-scale `per_unit` from
1590    /// the division — and interpolation must STILL round the missing
1591    /// Income residual to USD's 2dp (no posting-unit-scale cost-scale
1592    /// contamination).
1593    #[test]
1594    fn test_interpolate_residual_after_booking_total_cost_division() {
1595        use crate::book::BookingEngine;
1596        use rustledger_core::{Cost, CostSpec, IncompleteAmount, PriceAnnotation};
1597
1598        // Buy: 1.763 STOCK {{300.00 USD}} → booking derives
1599        // per_unit = 300.00 / 1.763 = ~170.16449... at 26-digit scale.
1600        let buy = Transaction::new(date(2016, 1, 1), "Buy")
1601            .with_synthesized_posting(
1602                Posting::new("Assets:Brokerage", Amount::new(dec!(1.763), "STOCK")).with_cost(
1603                    CostSpec {
1604                        number: Some(rustledger_core::CostNumber::Total {
1605                            value: dec!(300.00),
1606                        }),
1607                        currency: Some(Currency::from("USD")),
1608                        date: None,
1609                        label: None,
1610                        merge: false,
1611                    },
1612                ),
1613            )
1614            .with_synthesized_posting(Posting::new(
1615                "Assets:Cash",
1616                Amount::new(dec!(-300.00), "USD"),
1617            ));
1618
1619        // Sell: -1.763 STOCK {} @ 191.00 USD — empty cost spec; booking
1620        // lot-matches against the previous buy, filling the high-scale
1621        // derived per_unit. Income is missing, must be interpolated.
1622        let sell = Transaction::new(date(2016, 2, 12), "Sell")
1623            .with_synthesized_posting(Posting::new(
1624                "Assets:Cash",
1625                Amount::new(dec!(336.73), "USD"),
1626            ))
1627            .with_synthesized_posting(
1628                Posting::new("Assets:Brokerage", Amount::new(dec!(-1.763), "STOCK"))
1629                    .with_cost(CostSpec::empty())
1630                    .with_price(PriceAnnotation::unit(Amount::new(dec!(191.00), "USD"))),
1631            )
1632            .with_synthesized_posting(Posting::auto("Income:Capital-Gains"));
1633
1634        let mut engine = BookingEngine::new();
1635        engine.apply(&buy);
1636
1637        // book_and_interpolate handles the empty `{}` lot match AND
1638        // runs interpolation on the booked transaction. The Income
1639        // residual must end up at USD's 2dp scale — pre-fix this
1640        // inherited the lot's derived 26-digit per_unit scale.
1641        let result = engine
1642            .book_and_interpolate(&sell)
1643            .expect("booking+interpolation should succeed");
1644
1645        let income = &result.transaction.postings[2];
1646        let amount = get_amount(income).expect("Income should have an amount after interpolation");
1647
1648        assert_eq!(amount.currency.as_str(), "USD");
1649        assert!(
1650            amount.number.scale() <= 2,
1651            "residual scale must be ≤ 2 (USD's tracked precision), \
1652             not inherited from the lot's high-scale derived per_unit. \
1653             Got scale={} number={}",
1654            amount.number.scale(),
1655            amount.number
1656        );
1657
1658        // Use `_ = Cost::new` to keep the import live without an
1659        // unrelated unused-import warning if the test grows.
1660        let _ = Cost::new(dec!(1), "USD");
1661        let _: Option<IncompleteAmount> = None;
1662    }
1663
1664    /// UNASSIGNED missing posting (no currency context) instead of a
1665    /// currency-known one. bean-check rejects this because the
1666    /// unassigned could absorb residuals across all currencies including
1667    /// the cost-unknown's; the rejection is conservative-by-design.
1668    /// Pins the empirically-verified bean-check parity (#1026 review).
1669    #[test]
1670    fn test_interpolate_empty_cost_spec_with_unassigned_in_different_currency_errors() {
1671        use rustledger_core::CostSpec;
1672
1673        let txn = Transaction::new(date(2022, 1, 12), "Sale + unassigned absorber")
1674            .with_synthesized_posting(
1675                Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
1676                    .with_cost(CostSpec::empty())
1677                    .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1678                        dec!(150),
1679                        "USD",
1680                    ))),
1681            )
1682            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")))
1683            .with_synthesized_posting(Posting::new("Expenses:Fee", Amount::new(dec!(5), "EUR")))
1684            .with_synthesized_posting(Posting::auto("Income:Misc"));
1685
1686        let result = interpolate(&txn);
1687        assert!(
1688            matches!(result, Err(InterpolationError::MultipleMissing { .. })),
1689            "cost-unknown + unassigned-missing must error even when in different \
1690             currencies (bean-check parity); got {result:?}"
1691        );
1692    }
1693
1694    // ---- #1309 cluster 2: residual / price arithmetic ----------------
1695    // Exact-value assertions on the residual math so the surviving
1696    // mutants (cost/price `*`, residual `+=`, the multi-currency split
1697    // guard and index math) are killed.
1698
1699    #[test]
1700    fn interpolate_unit_price_is_units_times_price() {
1701        // 10 STK @ 3 USD → the elided cash leg is -30 USD.
1702        let txn = Transaction::new(date(2024, 1, 1), "Buy")
1703            .with_synthesized_posting(
1704                Posting::new("Assets:Stock", Amount::new(dec!(10), "STK")).with_price(
1705                    rustledger_core::PriceAnnotation::unit(Amount::new(dec!(3), "USD")),
1706                ),
1707            )
1708            .with_synthesized_posting(Posting::auto("Assets:Cash"));
1709        let r = interpolate(&txn).expect("interpolation should succeed");
1710        let cash = get_amount(&r.transaction.postings[1]).expect("filled");
1711        assert_eq!(cash.currency, "USD");
1712        assert_eq!(cash.number, dec!(-30)); // kills `abs * price` and `* signum -> +`
1713    }
1714
1715    #[test]
1716    fn interpolate_total_price_is_total() {
1717        // 10 STK @@ 30 USD → elided cash -30 USD.
1718        let txn = Transaction::new(date(2024, 1, 1), "Buy")
1719            .with_synthesized_posting(
1720                Posting::new("Assets:Stock", Amount::new(dec!(10), "STK")).with_price(
1721                    rustledger_core::PriceAnnotation::total(Amount::new(dec!(30), "USD")),
1722                ),
1723            )
1724            .with_synthesized_posting(Posting::auto("Assets:Cash"));
1725        let r = interpolate(&txn).expect("interpolation should succeed");
1726        let cash = get_amount(&r.transaction.postings[1]).expect("filled");
1727        assert_eq!(cash.number, dec!(-30)); // kills total-price `* signum -> +`
1728        assert_eq!(cash.currency, "USD"); // right magnitude in the right currency
1729    }
1730
1731    #[test]
1732    fn interpolate_three_posting_residual_sum() {
1733        // 100 USD + 25 USD + elided → cash -125 USD.
1734        let txn = Transaction::new(date(2024, 1, 1), "Split")
1735            .with_synthesized_posting(Posting::new("Expenses:A", Amount::new(dec!(100), "USD")))
1736            .with_synthesized_posting(Posting::new("Expenses:B", Amount::new(dec!(25), "USD")))
1737            .with_synthesized_posting(Posting::auto("Assets:Cash"));
1738        let r = interpolate(&txn).expect("interpolation should succeed");
1739        let cash = get_amount(&r.transaction.postings[2]).expect("filled");
1740        assert_eq!(cash.number, dec!(-125)); // kills residual `+= -> -=`/`*=`
1741    }
1742
1743    #[test]
1744    fn interpolate_single_elided_splits_two_currencies() {
1745        // One auto posting absorbs two currency residuals → two filled
1746        // postings (-100 USD, -50 EUR). Exercises the multi-currency
1747        // split path's guard and `len() - 1` index push.
1748        let txn = Transaction::new(date(2024, 1, 1), "FX")
1749            .with_synthesized_posting(Posting::new("Assets:USD", Amount::new(dec!(100), "USD")))
1750            .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(50), "EUR")))
1751            .with_synthesized_posting(Posting::auto("Equity:Balance"));
1752        let r = interpolate(&txn).expect("interpolation should succeed");
1753        let filled: Vec<Amount> = r
1754            .filled_indices
1755            .iter()
1756            .map(|&i| {
1757                get_amount(&r.transaction.postings[i])
1758                    .expect("filled")
1759                    .clone()
1760            })
1761            .collect();
1762        assert_eq!(filled.len(), 2, "one elided posting should split into two");
1763        assert!(
1764            filled
1765                .iter()
1766                .any(|a| a.currency == "USD" && a.number == dec!(-100))
1767        );
1768        assert!(
1769            filled
1770                .iter()
1771                .any(|a| a.currency == "EUR" && a.number == dec!(-50))
1772        );
1773    }
1774
1775    #[test]
1776    fn interpolate_post_fill_residual_returns_to_zero() {
1777        // After filling the elided leg, the tracked residual must return
1778        // to zero (kills the post-fill `residual += interpolated` mutants:
1779        // `-=` → 2R, `*=` → R·interpolated).
1780        let txn = Transaction::new(date(2024, 1, 1), "Split")
1781            .with_synthesized_posting(Posting::new("Expenses:A", Amount::new(dec!(100), "USD")))
1782            .with_synthesized_posting(Posting::new("Expenses:B", Amount::new(dec!(25), "USD")))
1783            .with_synthesized_posting(Posting::auto("Assets:Cash"));
1784        let r = interpolate(&txn).expect("interpolation should succeed");
1785        assert_eq!(
1786            r.residuals.get("USD").copied(),
1787            Some(dec!(0)),
1788            "residual must be exactly zero after the elided leg is filled"
1789        );
1790    }
1791
1792    #[test]
1793    fn interpolate_preserves_subcent_residual() {
1794        // Explicit USD legs net to zero; a 0.001 USD per-unit price
1795        // contribution leaves a sub-cent residual. The currency's tracked
1796        // scale is 2 (from the 1.00 USD legs), so naively rounding the
1797        // -0.001 fill to 0.00 would silently leave the txn unbalanced.
1798        // `round_interpolated` must keep full precision — kills the
1799        // `!residual.is_zero()` guard.
1800        let txn = Transaction::new(date(2024, 1, 1), "subcent")
1801            .with_synthesized_posting(Posting::new("Assets:A", Amount::new(dec!(1.00), "USD")))
1802            .with_synthesized_posting(Posting::new("Assets:B", Amount::new(dec!(-1.00), "USD")))
1803            .with_synthesized_posting(
1804                Posting::new("Assets:Stock", Amount::new(dec!(1), "STK")).with_price(
1805                    rustledger_core::PriceAnnotation::unit(Amount::new(dec!(0.001), "USD")),
1806                ),
1807            )
1808            .with_synthesized_posting(Posting::auto("Assets:Cash"));
1809        let r = interpolate(&txn).expect("interpolation should succeed");
1810        let cash = r
1811            .filled_indices
1812            .iter()
1813            .map(|&i| get_amount(&r.transaction.postings[i]).expect("filled"))
1814            .find(|a| a.currency == "USD")
1815            .expect("a USD fill");
1816        assert_eq!(
1817            cash.number,
1818            dec!(-0.001),
1819            "sub-cent residual must be preserved, not rounded to zero"
1820        );
1821    }
1822
1823    #[test]
1824    fn interpolate_currency_only_fill_zeroes_residual() {
1825        // A CurrencyOnly elided leg (`Assets:Cash USD`, number missing)
1826        // is filled via the known-currency path; the post-fill residual
1827        // must return to zero (kills that path's `residual += interpolated`).
1828        let txn = Transaction::new(date(2024, 1, 1), "currency-only")
1829            .with_synthesized_posting(Posting::new("Expenses:X", Amount::new(dec!(100), "USD")))
1830            .with_synthesized_posting(Posting::with_incomplete(
1831                "Assets:Cash",
1832                IncompleteAmount::CurrencyOnly("USD".into()),
1833            ));
1834        let r = interpolate(&txn).expect("interpolation should succeed");
1835        let cash = get_amount(&r.transaction.postings[1]).expect("filled");
1836        assert_eq!(cash.number, dec!(-100));
1837        assert_eq!(r.residuals.get("USD").copied(), Some(dec!(0)));
1838    }
1839
1840    #[test]
1841    fn interpolate_number_only_infers_currency_and_balances() {
1842        // A NumberOnly leg (`-100`, currency missing) infers its currency
1843        // from its OWN price annotation (the arm only consults the
1844        // posting's own cost/price, never siblings — a bare NumberOnly
1845        // with no cost/price would route to the unassigned path instead).
1846        // The `@ 1 USD` price is a currency hint with a unit multiplier,
1847        // so the leg contributes `-100` to the residual via that arm's
1848        // `residual += *number` — which this test kills.
1849        let txn = Transaction::new(date(2024, 1, 1), "number-only")
1850            .with_synthesized_posting(Posting::new("Expenses:X", Amount::new(dec!(100), "USD")))
1851            .with_synthesized_posting(
1852                Posting::with_incomplete("Assets:Cash", IncompleteAmount::NumberOnly(dec!(-100)))
1853                    .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1854                        dec!(1),
1855                        "USD",
1856                    ))),
1857            );
1858        let r = interpolate(&txn).expect("interpolation should succeed");
1859        assert_eq!(
1860            r.residuals.get("USD").copied(),
1861            Some(dec!(0)),
1862            "NumberOnly leg's number must net the residual to zero"
1863        );
1864    }
1865}