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