Skip to main content

rustledger_booking/
lib.rs

1//! Beancount booking engine with interpolation.
2//!
3//! This crate provides:
4//! - Transaction interpolation (filling in missing amounts)
5//! - Transaction balancing verification
6//! - Tolerance calculation
7//!
8//! # Interpolation
9//!
10//! When a transaction has exactly one posting per currency without an amount,
11//! that amount can be calculated to make the transaction balance.
12//!
13//! ```ignore
14//! use rustledger_booking::interpolate;
15//!
16//! // Transaction with one missing amount
17//! // 2024-01-15 * "Groceries"
18//! //   Expenses:Food  50.00 USD
19//! //   Assets:Cash               <- amount inferred as -50.00 USD
20//! ```
21
22#![forbid(unsafe_code)]
23#![warn(missing_docs)]
24
25mod book;
26mod interpolate;
27mod pad;
28
29pub use book::{
30    BookedTransaction, BookingEngine, BookingError, CapitalGain, LedgerBookResult, book,
31    book_transactions,
32};
33pub use interpolate::{InterpolationError, InterpolationResult, interpolate};
34pub use pad::{
35    PadError, PadResult, SYNTH_PAD_NARRATION_PREFIX, is_synthesized_pad, merge_with_padding,
36    merge_with_padding_spanned, process_pads,
37};
38
39use bigdecimal::BigDecimal;
40use rust_decimal::Decimal;
41use rust_decimal::prelude::Signed;
42use rustc_hash::FxHashMap;
43use rustledger_core::{Amount, Currency, IncompleteAmount, Transaction};
44
45/// Option knobs for [`transaction_tolerances`], mirroring the ledger options
46/// that drive tolerance inference (`tolerance_multiplier`,
47/// `infer_tolerance_from_cost`, `inferred_tolerance_default`).
48#[derive(Debug, Clone)]
49pub struct ToleranceOptions<'a> {
50    /// Multiplier applied to each amount's quantum (beancount default 0.5).
51    pub multiplier: Decimal,
52    /// Whether per-unit costs and prices feed the tolerance (accumulated,
53    /// then max'd per currency), per `option "infer_tolerance_from_cost"`.
54    pub infer_from_cost: bool,
55    /// Per-currency tolerance floors from `option "inferred_tolerance_default"`;
56    /// the key `"*"` is a wildcard floor applied to every currency that
57    /// appears as a posting UNIT currency in the transaction (a currency
58    /// present only via cost/price inference gets no wildcard floor,
59    /// though a named per-currency default still reaches it) — behavior
60    /// inherited verbatim from the validator.
61    pub defaults: &'a FxHashMap<String, Decimal>,
62}
63
64/// Calculate the quantum (smallest unit) of a decimal number based on its precision.
65/// For example: 10.436 has quantum 0.001, 100.00 has quantum 0.01
66#[must_use]
67pub fn decimal_quantum(value: Decimal) -> Decimal {
68    let scale = value.scale();
69    if scale == 0 {
70        Decimal::ONE
71    } else {
72        Decimal::new(1, scale)
73    }
74}
75
76/// Calculate per-currency balance tolerances for a transaction — the
77/// canonical tolerance semantics used by the validation pipeline.
78///
79/// This is the tolerance model that decides whether a transaction balances
80/// (beancount's `infer_tolerance_from_quantum`): each posting amount with
81/// decimal places contributes `quantum(amount) x multiplier`, max'd per
82/// currency; integer amounts contribute nothing (exact balance required).
83///
84/// When `infer_tolerance_from_cost` is enabled, each posting with a
85/// per-unit cost (or price) contributes
86/// `units_quantum * cost_per_unit * multiplier`; these contributions are
87/// ACCUMULATED (summed) per cost currency across the transaction's
88/// postings, and the accumulated value is then max'd against the base
89/// quantum tolerance for that currency. (The doc used to claim the
90/// per-posting maximum — the implementation, moved verbatim from the
91/// validator, has always summed.)
92#[must_use]
93pub fn transaction_tolerances(
94    txn: &Transaction,
95    opts: &ToleranceOptions<'_>,
96) -> FxHashMap<rustledger_core::Currency, Decimal> {
97    // Pre-allocate for typical case (1-2 currencies)
98    let mut tolerances: FxHashMap<rustledger_core::Currency, Decimal> =
99        FxHashMap::with_capacity_and_hasher(txn.postings.len().min(4), Default::default());
100
101    // Default tolerance based on quantum of amounts in postings.
102    // Only amounts with decimal places contribute (Python's `if expo < 0:` guard).
103    // Integer amounts (scale=0) don't contribute — if all amounts for a currency
104    // are integers, the tolerance for that currency stays at 0 (exact balance required).
105    for posting in &txn.postings {
106        if let Some(units) = posting.amount()
107            && units.number.scale() > 0
108        {
109            let quantum = decimal_quantum(units.number);
110            // Use half the quantum as base tolerance (like Python beancount)
111            let base_tolerance = quantum * opts.multiplier;
112
113            tolerances
114                .entry(units.currency.clone())
115                .and_modify(|t| *t = (*t).max(base_tolerance))
116                .or_insert(base_tolerance);
117        }
118    }
119
120    // Calculate cost-inferred tolerance if enabled.
121    // In Python, cost/price tolerance is only computed for postings where units
122    // have decimal places (expo < 0). The cost tolerance is ACCUMULATED (summed)
123    // across postings, then max'd with the existing tolerance per currency.
124    if opts.infer_from_cost {
125        // Accumulated cost/price tolerances per currency
126        let mut cost_tolerances: FxHashMap<rustledger_core::Currency, Decimal> =
127            FxHashMap::with_capacity_and_hasher(txn.postings.len().min(4), Default::default());
128
129        for posting in &txn.postings {
130            if let Some(units) = posting.amount() {
131                // Only process postings with decimal amounts (Python: if expo < 0)
132                if units.number.scale() == 0 {
133                    continue;
134                }
135                let units_quantum = decimal_quantum(units.number);
136                let tolerance = units_quantum * opts.multiplier;
137
138                // Cost contribution — only per-unit cost feeds into
139                // tolerance inference. `PerUnitFromTotal` and `PerUnit`
140                // both expose a per-unit value via `per_unit()`.
141                if let Some(cost_spec) = &posting.cost
142                    && let Some(cost_per_unit) = cost_spec.number.and_then(|cn| cn.per_unit())
143                    && let Some(cost_currency) = &cost_spec.currency
144                {
145                    let cost_tolerance = tolerance * cost_per_unit;
146                    *cost_tolerances.entry(cost_currency.clone()).or_default() += cost_tolerance;
147                }
148
149                // Price contribution: only complete amounts contribute
150                // (incomplete/empty price annotations are filled in by
151                // interpolation later). `kind` (Unit vs Total) doesn't
152                // change the tolerance math here — both use `tolerance *
153                // price_amt.number`.
154                if let Some(price) = &posting.price
155                    && let Some(price_amt) = price
156                        .amount
157                        .as_ref()
158                        .and_then(rustledger_core::IncompleteAmount::as_amount)
159                {
160                    let price_tolerance = tolerance * price_amt.number;
161                    *cost_tolerances
162                        .entry(price_amt.currency.clone())
163                        .or_default() += price_tolerance;
164                }
165            }
166        }
167
168        // Merge cost tolerances: take max of existing and cost-inferred
169        for (currency, cost_tol) in cost_tolerances {
170            tolerances
171                .entry(currency)
172                .and_modify(|t| *t = (*t).max(cost_tol))
173                .or_insert(cost_tol);
174        }
175    }
176
177    // Apply per-currency default tolerances from `inferred_tolerance_default` option.
178    // These act as a floor: if the computed tolerance for a currency is less than the
179    // default, the default is used. The special key "*" floors every currency that
180    // appears as a posting UNIT currency (not currencies present only via
181    // cost/price inference — named defaults below reach those too).
182    if !opts.defaults.is_empty() {
183        // Apply the wildcard default first (if any)
184        if let Some(wildcard_default) = opts.defaults.get("*") {
185            // Apply wildcard to all currencies that appear in the transaction
186            for posting in &txn.postings {
187                if let Some(units) = posting.amount() {
188                    tolerances
189                        .entry(units.currency.clone())
190                        .and_modify(|t| *t = (*t).max(*wildcard_default))
191                        .or_insert(*wildcard_default);
192                }
193            }
194        }
195
196        // Apply per-currency defaults (overrides wildcard for specific currencies)
197        for (currency_str, default_tol) in opts.defaults {
198            if currency_str == "*" {
199                continue;
200            }
201            let currency = rustledger_core::Currency::from(currency_str.as_str());
202            tolerances
203                .entry(currency)
204                .and_modify(|t| *t = (*t).max(*default_tol))
205                .or_insert(*default_tol);
206        }
207    }
208
209    tolerances
210}
211
212/// Calculate the tolerance for a bare set of amounts (low-level primitive).
213///
214/// Tolerance is the maximum of all individual amount tolerances, using each
215/// amount's fixed [`Amount::inferred_tolerance`]. This is **not** the
216/// pipeline's transaction-balancing tolerance: it ignores the ledger options
217/// (`tolerance_multiplier`, `infer_tolerance_from_cost`,
218/// `inferred_tolerance_default`). For the semantics that decide whether a
219/// transaction balances, use [`transaction_tolerances`].
220#[must_use]
221pub fn calculate_tolerance(amounts: &[&Amount]) -> FxHashMap<Currency, Decimal> {
222    // Pre-allocate for typical case (1-3 currencies per transaction)
223    let mut tolerances: FxHashMap<Currency, Decimal> =
224        FxHashMap::with_capacity_and_hasher(amounts.len().min(4), Default::default());
225
226    for amount in amounts {
227        let tol = amount.inferred_tolerance();
228        tolerances
229            .entry(amount.currency.clone())
230            .and_modify(|t| *t = (*t).max(tol))
231            .or_insert(tol);
232    }
233
234    tolerances
235}
236
237/// Extract the currency named in a posting's price annotation, if any.
238///
239/// Returns the currency on `IncompleteAmount::Complete`. `CurrencyOnly`,
240/// `NumberOnly`, and the bare-sigil form (`amount: None`) all return
241/// `None` — they're shapes where the currency is either missing or
242/// supplied later by interpolation. `kind` (Unit vs Total) is irrelevant
243/// at this layer.
244#[must_use]
245pub(crate) fn price_currency_of(posting: &rustledger_core::Posting) -> Option<Currency> {
246    posting
247        .price
248        .as_ref()
249        .and_then(|p| p.amount.as_ref())
250        .and_then(IncompleteAmount::as_amount)
251        .map(|a| a.currency.clone())
252}
253
254/// Infer the cost currency from other postings in the transaction.
255///
256/// Python beancount infers cost currency from simple postings (those without
257/// cost specs) when a cost is specified without a currency like `{100}`.
258///
259/// Currency inference follows this priority:
260/// 1. An explicit currency in the cost specification itself (handled by the caller).
261/// 2. A price annotation on a simple posting (the price currency takes precedence).
262/// 3. The currency of other simple postings (units or currency-only amounts).
263/// 4. The currency from a cost spec (e.g., `{0 USD}` for zero-cost items).
264#[must_use]
265pub(crate) fn infer_cost_currency_from_postings(transaction: &Transaction) -> Option<Currency> {
266    // First pass: look for simple postings (no cost spec) - these take priority
267    for posting in &transaction.postings {
268        // Skip postings with cost specs in first pass
269        if posting.cost.is_some() {
270            continue;
271        }
272
273        // Get the currency from this posting's units
274        if let Some(units) = &posting.units {
275            match units {
276                IncompleteAmount::Complete(amount) => {
277                    // If this posting has a price annotation, the "real" currency
278                    // is the price currency, not the units currency
279                    if let Some(c) = price_currency_of(posting) {
280                        return Some(c);
281                    }
282                    // Simple posting - use its currency
283                    return Some(amount.currency.clone());
284                }
285                IncompleteAmount::CurrencyOnly(currency) => {
286                    return Some(currency.clone());
287                }
288                IncompleteAmount::NumberOnly(_) => {}
289            }
290        }
291    }
292
293    // Second pass: look for cost spec currencies (e.g., `{0 USD}`)
294    // This handles zero-cost postings where the cost currency should be used
295    for posting in &transaction.postings {
296        if let Some(cost) = &posting.cost
297            && let Some(currency) = &cost.currency
298        {
299            return Some(currency.clone());
300        }
301    }
302
303    None
304}
305
306/// Numeric backend for the posting-weight engine. `Decimal` is the fast path;
307/// `BigDecimal` the arbitrary-precision path used near the `rust_decimal`
308/// 28-digit ceiling. Both implement this trait so the balance-weight ladder
309/// (cost-spec resolution + price formula) lives in exactly ONE place
310/// ([`residual_weight`]): a new `CostNumber` variant or a sign fix then forces a
311/// compile error / change at a single site instead of silently drifting between
312/// the fast and precise residual functions.
313///
314/// `abs`/`signum` are taken on the source `Decimal` (exact — they add no
315/// digits); only the *multiplications* run in `D`, so `D = BigDecimal`
316/// reproduces the precise path's arithmetic byte-for-byte.
317trait WeightNum: Clone + Default + std::ops::AddAssign + std::ops::Mul<Output = Self> {
318    fn from_decimal(d: Decimal) -> Self;
319}
320
321impl WeightNum for Decimal {
322    fn from_decimal(d: Decimal) -> Self {
323        d
324    }
325}
326
327impl WeightNum for BigDecimal {
328    fn from_decimal(d: Decimal) -> Self {
329        to_big(d)
330    }
331}
332
333/// Resolve the currency a posting's cost weight is denominated in: the explicit
334/// cost currency, else the price currency, else `infer_currency()` (called
335/// lazily — only when the first two are absent). Returns `None` if the posting
336/// has no cost spec or no currency can be determined.
337#[must_use]
338pub(crate) fn cost_currency_of(
339    posting: &rustledger_core::Posting,
340    infer_currency: impl FnOnce() -> Option<Currency>,
341) -> Option<Currency> {
342    let cost_spec = posting.cost.as_ref()?;
343    cost_spec
344        .currency
345        .clone()
346        .or_else(|| price_currency_of(posting))
347        .or_else(infer_currency)
348}
349
350/// The canonical per-posting **cost** weight contribution — the single
351/// `CostNumber` ladder shared by [`residual_weight`] and `interpolate` (so the
352/// "cost beats price" weight rule and a future `CostNumber` variant live in one
353/// place rather than drifting between balance-checking and interpolation).
354///
355/// Returns `None` for a posting with no cost spec, an empty `{}` spec (no
356/// determinable number), or when no cost currency resolves. `interpolate`
357/// instantiates this at `Decimal`.
358fn cost_weight<D: WeightNum>(
359    posting: &rustledger_core::Posting,
360    units: &Amount,
361    infer_currency: impl FnOnce() -> Option<Currency>,
362) -> Option<(Currency, D)> {
363    let cost_spec = posting.cost.as_ref()?;
364    // Match the number FIRST so an empty `{}` spec short-circuits without
365    // resolving (and possibly inferring) the cost currency.
366    let number = cost_spec.number.as_ref()?;
367    let weight = cost_number_weight_generic::<D>(units.number, number);
368    let cost_curr = cost_currency_of(posting, infer_currency)?;
369    Some((cost_curr, weight))
370}
371
372/// The `CostNumber`-variant weight arithmetic, generic over the numeric
373/// backend — the single implementation behind [`cost_number_weight`] and
374/// [`cost_weight`]. A new `CostNumber` variant forces a change HERE and
375/// nowhere else.
376fn cost_number_weight_generic<D: WeightNum>(
377    units_number: Decimal,
378    number: &rustledger_core::CostNumber,
379) -> D {
380    let signum = units_number.signum();
381    // `PerUnitFromTotal` and `Total` both carry a preserved total — using it
382    // avoids the division-then-multiplication precision loss of recomputing from
383    // `per_unit`. `PerUnit` goes through multiplication.
384    match *number {
385        rustledger_core::CostNumber::Total { value: total } => {
386            D::from_decimal(total) * D::from_decimal(signum)
387        }
388        rustledger_core::CostNumber::PerUnitFromTotal(b) => {
389            D::from_decimal(b.total) * D::from_decimal(signum)
390        }
391        rustledger_core::CostNumber::PerUnit { value: per_unit } => {
392            D::from_decimal(units_number) * D::from_decimal(per_unit)
393        }
394        // Compound `{a # b}` (beancount compound_amount): the cost totals
395        // `N*a + b`, so the weight is the per-unit product (sign embedded
396        // in `units`) plus the signed lump total (#1700).
397        rustledger_core::CostNumber::Compound { per_unit, total } => {
398            let mut w = D::from_decimal(units_number) * D::from_decimal(per_unit);
399            w += D::from_decimal(total) * D::from_decimal(signum);
400            w
401        }
402    }
403}
404
405/// The canonical weight of a cost number: what `units_number` of a posting
406/// with this cost spec contributes to the transaction balance, in the cost
407/// currency (Beancount's "weight" of a costed posting).
408///
409/// This is the exact arithmetic the balance validator's residual uses —
410/// `Total`/`PerUnitFromTotal` take the preserved total (sign following
411/// units), avoiding the division-then-multiplication precision loss of
412/// recomputing from `per_unit` (#1106/#1113); `Compound {a # b}` totals
413/// `N·a + b` (#1700). Consumers surfacing a per-posting weight (BQL `weight`
414/// column, `currency_accounts` grouping) MUST use this rather than re-derive
415/// the ladder, or they drift from `rledger check` on those shapes.
416#[must_use]
417pub fn cost_number_weight(units_number: Decimal, number: &rustledger_core::CostNumber) -> Decimal {
418    cost_number_weight_generic::<Decimal>(units_number, number)
419}
420
421/// The canonical weight of a price annotation: what `units_number` of a
422/// posting priced `@`/`@@` contributes to the transaction balance, in the
423/// price currency.
424///
425/// `@` (per-unit): `|units| × price × sign(units)`. `@@` (total): the price
426/// is a positive magnitude in the source, so the weight is
427/// `price × sign(units)` — credit-side postings flip to `−price`
428/// (issue #1052). Zero units weigh zero for both kinds. Same single-source
429/// rule as [`cost_number_weight`].
430#[must_use]
431pub fn price_weight(
432    units_number: Decimal,
433    price_number: Decimal,
434    kind: rustledger_core::PriceKind,
435) -> Decimal {
436    price_weight_generic::<Decimal>(units_number, price_number, kind)
437}
438
439/// The price-annotation weight arithmetic, generic over the numeric backend —
440/// the single implementation behind [`price_weight`] and [`residual_weight`].
441///
442/// The expanded `abs * price * signum` form (rather than `units * price`) is
443/// kept so `D = Decimal` and `D = BigDecimal` reproduce the pre-refactor
444/// residual arithmetic exactly.
445fn price_weight_generic<D: WeightNum>(
446    units_number: Decimal,
447    price_number: Decimal,
448    kind: rustledger_core::PriceKind,
449) -> D {
450    let signum = units_number.signum();
451    match kind {
452        rustledger_core::PriceKind::Unit => {
453            D::from_decimal(units_number.abs())
454                * D::from_decimal(price_number)
455                * D::from_decimal(signum)
456        }
457        rustledger_core::PriceKind::Total => {
458            D::from_decimal(price_number) * D::from_decimal(signum)
459        }
460    }
461}
462
463/// The canonical per-posting balance weight, summed per currency, generic over
464/// the numeric backend. Single source of truth for [`calculate_residual`] and
465/// [`calculate_residual_precise`].
466///
467/// Weight rule (Beancount): a cost spec puts the weight in the cost currency
468/// (`cost` beats `price`); else a price annotation puts it in the price
469/// currency; else the weight is the units themselves.
470fn residual_weight<D: WeightNum>(transaction: &Transaction) -> FxHashMap<Currency, D> {
471    // Pre-allocate for typical case (1-2 currencies per transaction)
472    let mut residuals: FxHashMap<Currency, D> =
473        FxHashMap::with_capacity_and_hasher(transaction.postings.len().min(4), Default::default());
474
475    // Lazily compute inferred currency only when needed (most transactions don't need it)
476    let mut inferred_cost_currency: Option<Option<Currency>> = None;
477    let get_inferred_currency = |cache: &mut Option<Option<Currency>>| -> Option<Currency> {
478        cache
479            .get_or_insert_with(|| infer_cost_currency_from_postings(transaction))
480            .clone()
481    };
482
483    for posting in &transaction.postings {
484        // Only process complete amounts
485        let Some(IncompleteAmount::Complete(units)) = &posting.units else {
486            continue;
487        };
488
489        // Determine the "weight" of this posting for balance purposes.
490        let cost_contribution = cost_weight::<D>(posting, units, || {
491            get_inferred_currency(&mut inferred_cost_currency)
492        });
493
494        if let Some((currency, amount)) = cost_contribution {
495            // Cost-based posting: weight is in the cost currency
496            *residuals.entry(currency).or_default() += amount;
497        } else if posting.cost.is_some() {
498            // Cost spec exists but has no determinable cost number
499            // (e.g., empty `{}`). The CANONICAL weight of a cost-tracked
500            // posting is `units × cost`, NOT `units × price` — even if a
501            // price annotation is present. Falling through to the price
502            // branch would silently produce a balanced residual using
503            // the wrong weight (issue #1026). Skip contribution; the
504            // booking pass will resolve via lot matching, and the
505            // interpolation rule (in `interpolate.rs`) accounts for
506            // this posting as one cost-unknown for its currency group.
507        } else if let Some(price) = &posting.price {
508            // Price annotation: converts units to the price currency.
509            if let Some(amt) = price.amount.as_ref().and_then(IncompleteAmount::as_amount) {
510                let signed = price_weight_generic::<D>(units.number, amt.number, price.kind);
511                *residuals.entry(amt.currency.clone()).or_default() += signed;
512            } else {
513                // Incomplete or bare-sigil price annotation — can't
514                // calculate a price-currency conversion, fall back to units.
515                *residuals.entry(units.currency.clone()).or_default() +=
516                    D::from_decimal(units.number);
517            }
518        } else {
519            // Simple posting: weight is just the units
520            *residuals.entry(units.currency.clone()).or_default() += D::from_decimal(units.number);
521        }
522    }
523
524    residuals
525}
526
527/// Calculate the residual (imbalance) of a transaction.
528///
529/// Returns a map of currency -> residual amount.
530/// A balanced transaction has all residuals within tolerance.
531///
532/// # TLA+ Specification
533///
534/// Implements balance checking from `DoubleEntry.tla`:
535/// - Invariant: `TransactionsBalance` - For every transaction, `sum(postings) = 0`
536/// - Each currency is checked independently
537/// - A non-zero residual indicates a violation of double-entry bookkeeping
538///
539/// See: `spec/tla/DoubleEntry.tla`
540#[must_use]
541// clippy::implicit_hasher still fires for a concrete `FxBuildHasher` (it wants
542// the fn generic over `S: BuildHasher`); the explicit fast hasher is the point.
543#[allow(clippy::implicit_hasher)]
544pub fn calculate_residual(transaction: &Transaction) -> FxHashMap<Currency, Decimal> {
545    residual_weight::<Decimal>(transaction)
546}
547
548/// Convert a `rust_decimal::Decimal` to `BigDecimal` for arbitrary-precision arithmetic.
549///
550/// Individual `Decimal` values are representable exactly (≤28 significant digits).
551/// The precision loss only occurs during arithmetic, so converting before operations
552/// preserves full precision.
553fn to_big(d: Decimal) -> BigDecimal {
554    use std::str::FromStr;
555    // rust_decimal Display is exact; BigDecimal FromStr handles any decimal string
556    BigDecimal::from_str(&d.to_string()).expect("Decimal always produces valid decimal string")
557}
558
559/// Calculate the residual of a transaction using arbitrary-precision arithmetic.
560///
561/// This mirrors [`calculate_residual`] but uses `BigDecimal` to avoid precision loss
562/// when amounts have near-28-digit precision. `rust_decimal` is limited to 28-29
563/// significant digits; this function handles arbitrary precision correctly.
564#[must_use]
565#[allow(clippy::implicit_hasher)]
566pub fn calculate_residual_precise(transaction: &Transaction) -> FxHashMap<Currency, BigDecimal> {
567    residual_weight::<BigDecimal>(transaction)
568}
569
570/// Check if a transaction is balanced within the given tolerances
571/// (low-level primitive).
572///
573/// The caller supplies the tolerance map. The validation pipeline does not
574/// call this: it computes tolerances via [`transaction_tolerances`] and
575/// escalates non-zero residuals to [`calculate_residual_precise`] (the
576/// two-tier check from #1240). Pair this with [`transaction_tolerances`] —
577/// not [`calculate_tolerance`] — if you need pipeline-equivalent balancing.
578#[must_use]
579#[allow(clippy::implicit_hasher)]
580pub fn is_balanced(transaction: &Transaction, tolerances: &FxHashMap<Currency, Decimal>) -> bool {
581    let residuals = calculate_residual(transaction);
582
583    for (currency, residual) in residuals {
584        let tolerance = tolerances.get(&currency).copied().unwrap_or(Decimal::ZERO); // Default 0 (exact balance for integer-only currencies)
585
586        if residual.abs() > tolerance {
587            return false;
588        }
589    }
590
591    true
592}
593
594/// Normalize total prices (`@@`) to per-unit prices (`@`) on a transaction.
595///
596/// This converts a `PriceAnnotation` with `PriceKind::Total` to one with
597/// `PriceKind::Unit` by dividing
598/// the total price by the number of units. This should be called AFTER validation
599/// (balance checking) to preserve exact total prices for precise residual calculation.
600///
601/// Matches Python beancount behavior where `@@` is converted to `@`.
602pub fn normalize_prices(txn: &mut Transaction) {
603    use rustledger_core::{PriceAnnotation, PriceKind};
604
605    for posting in &mut txn.postings {
606        if let (Some(IncompleteAmount::Complete(units)), Some(price)) =
607            (&posting.units, &posting.price)
608            && price.kind == PriceKind::Total
609        {
610            let normalized = match price.amount.as_ref().and_then(IncompleteAmount::as_amount) {
611                Some(total_amount) if !units.number.is_zero() => {
612                    let per_unit = total_amount.number / units.number.abs();
613                    Some(PriceAnnotation::unit(Amount::new(
614                        per_unit,
615                        &total_amount.currency,
616                    )))
617                }
618                Some(_) => None, // units.number is zero — leave alone
619                None => {
620                    // Empty (`@@` with no amount) — Total → Unit sigil swap.
621                    // `total_incomplete` with no complete amount cannot be
622                    // normalized because we don't have a number to divide.
623                    if price.amount.is_none() {
624                        Some(PriceAnnotation::unit_empty())
625                    } else {
626                        None
627                    }
628                }
629            };
630            if let Some(normalized_price) = normalized {
631                posting.price = Some(normalized_price);
632            }
633        }
634    }
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use rust_decimal_macros::dec;
641    use rustledger_core::{CostSpec, IncompleteAmount, NaiveDate, Posting, PriceAnnotation};
642
643    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
644        rustledger_core::naive_date(year, month, day).unwrap()
645    }
646
647    // =========================================================================
648    // cost_number_weight / price_weight — the public single-source arithmetic
649    // =========================================================================
650
651    #[test]
652    fn cost_number_weight_covers_all_variants() {
653        use rustledger_core::{BookedCost, CostNumber};
654        // PerUnit: units × per_unit.
655        assert_eq!(
656            cost_number_weight(dec!(10), &CostNumber::PerUnit { value: dec!(5.00) }),
657            dec!(50.00),
658        );
659        // Total: preserved total, sign following units.
660        assert_eq!(
661            cost_number_weight(
662                dec!(3),
663                &CostNumber::Total {
664                    value: dec!(100.00)
665                }
666            ),
667            dec!(100.00),
668        );
669        assert_eq!(
670            cost_number_weight(
671                dec!(-3),
672                &CostNumber::Total {
673                    value: dec!(100.00)
674                }
675            ),
676            dec!(-100.00),
677        );
678        // PerUnitFromTotal: the preserved total EXACTLY — not per_unit × units,
679        // which for 100/3 would give 99.99999... at the 28-digit ceiling.
680        let booked = CostNumber::PerUnitFromTotal(BookedCost {
681            per_unit: dec!(100.00) / dec!(3),
682            total: dec!(100.00),
683        });
684        assert_eq!(cost_number_weight(dec!(3), &booked), dec!(100.00));
685        assert_eq!(cost_number_weight(dec!(-3), &booked), dec!(-100.00));
686        // Compound {a # b}: N·a + b, lump signed with units (#1700).
687        let compound = CostNumber::Compound {
688            per_unit: dec!(5.00),
689            total: dec!(10.00),
690        };
691        assert_eq!(cost_number_weight(dec!(10), &compound), dec!(60.00));
692        assert_eq!(cost_number_weight(dec!(-10), &compound), dec!(-60.00));
693    }
694
695    #[test]
696    fn price_weight_unit_and_total_signs() {
697        use rustledger_core::PriceKind;
698        // `@` per-unit: units × price, sign through units.
699        assert_eq!(
700            price_weight(dec!(10), dec!(1.50), PriceKind::Unit),
701            dec!(15.00),
702        );
703        assert_eq!(
704            price_weight(dec!(-10), dec!(1.50), PriceKind::Unit),
705            dec!(-15.00),
706        );
707        // `@@` total: positive magnitude in source, sign follows units —
708        // the #1052 credit-side flip.
709        assert_eq!(
710            price_weight(dec!(10), dec!(15.00), PriceKind::Total),
711            dec!(15.00),
712        );
713        assert_eq!(
714            price_weight(dec!(-10), dec!(15.00), PriceKind::Total),
715            dec!(-15.00),
716        );
717        // Zero units weigh zero for both kinds.
718        assert_eq!(
719            price_weight(dec!(0), dec!(15.00), PriceKind::Total),
720            dec!(0)
721        );
722    }
723
724    // =========================================================================
725    // Basic residual tests (existing)
726    // =========================================================================
727
728    #[test]
729    fn test_calculate_residual_balanced() {
730        let txn = Transaction::new(date(2024, 1, 15), "Test")
731            .with_synthesized_posting(Posting::new(
732                "Expenses:Food",
733                Amount::new(dec!(50.00), "USD"),
734            ))
735            .with_synthesized_posting(Posting::new(
736                "Assets:Cash",
737                Amount::new(dec!(-50.00), "USD"),
738            ));
739
740        let residual = calculate_residual(&txn);
741        assert_eq!(residual.get("USD"), Some(&dec!(0)));
742    }
743
744    #[test]
745    fn test_calculate_residual_unbalanced() {
746        let txn = Transaction::new(date(2024, 1, 15), "Test")
747            .with_synthesized_posting(Posting::new(
748                "Expenses:Food",
749                Amount::new(dec!(50.00), "USD"),
750            ))
751            .with_synthesized_posting(Posting::new(
752                "Assets:Cash",
753                Amount::new(dec!(-45.00), "USD"),
754            ));
755
756        let residual = calculate_residual(&txn);
757        assert_eq!(residual.get("USD"), Some(&dec!(5.00)));
758    }
759
760    #[test]
761    fn test_is_balanced() {
762        let txn = Transaction::new(date(2024, 1, 15), "Test")
763            .with_synthesized_posting(Posting::new(
764                "Expenses:Food",
765                Amount::new(dec!(50.00), "USD"),
766            ))
767            .with_synthesized_posting(Posting::new(
768                "Assets:Cash",
769                Amount::new(dec!(-50.00), "USD"),
770            ));
771
772        let tolerances = calculate_tolerance(&[
773            &Amount::new(dec!(50.00), "USD"),
774            &Amount::new(dec!(-50.00), "USD"),
775        ]);
776
777        assert!(is_balanced(&txn, &tolerances));
778    }
779
780    #[test]
781    fn test_is_balanced_within_tolerance() {
782        let txn = Transaction::new(date(2024, 1, 15), "Test")
783            .with_synthesized_posting(Posting::new(
784                "Expenses:Food",
785                Amount::new(dec!(50.004), "USD"),
786            ))
787            .with_synthesized_posting(Posting::new(
788                "Assets:Cash",
789                Amount::new(dec!(-50.00), "USD"),
790            ));
791
792        let tolerances = calculate_tolerance(&[
793            &Amount::new(dec!(50.004), "USD"),
794            &Amount::new(dec!(-50.00), "USD"),
795        ]);
796
797        // 0.004 is within tolerance of 0.005 (scale 2 -> 0.005)
798        assert!(is_balanced(&txn, &tolerances));
799    }
800
801    #[test]
802    fn test_is_balanced_detects_imbalance() {
803        // Mutation guard (#1238): the existing is_balanced tests only
804        // assert the TRUE (balanced) cases, so replacing the whole body
805        // with `true` survived the suite — the balance check could be
806        // wholly broken and no test would notice. Assert the FALSE case.
807        let txn = Transaction::new(date(2024, 1, 15), "Test")
808            .with_synthesized_posting(Posting::new(
809                "Expenses:Food",
810                Amount::new(dec!(50.00), "USD"),
811            ))
812            .with_synthesized_posting(Posting::new(
813                "Assets:Cash",
814                Amount::new(dec!(-49.00), "USD"),
815            ));
816        // Residual is 1.00 USD against zero tolerance — clearly unbalanced.
817        let mut tolerances = FxHashMap::default();
818        tolerances.insert(Currency::from("USD"), Decimal::ZERO);
819        assert!(
820            !is_balanced(&txn, &tolerances),
821            "a 1.00 USD residual with zero tolerance must be detected as unbalanced"
822        );
823    }
824
825    #[test]
826    fn test_is_balanced_at_exact_tolerance_boundary() {
827        // Mutation guard (#1238): the comparison is `residual.abs() >
828        // tolerance`, so a residual EXACTLY at the tolerance is balanced
829        // (strict greater-than). This kills the `>`->`>=` and `>`->`==`
830        // mutants, both of which would wrongly reject the boundary case.
831        let txn = Transaction::new(date(2024, 1, 15), "Test")
832            .with_synthesized_posting(Posting::new(
833                "Expenses:Food",
834                Amount::new(dec!(50.01), "USD"),
835            ))
836            .with_synthesized_posting(Posting::new(
837                "Assets:Cash",
838                Amount::new(dec!(-50.00), "USD"),
839            ));
840        // Residual 0.01 exactly equals the tolerance: balanced under `>`.
841        let mut tolerances = FxHashMap::default();
842        tolerances.insert(Currency::from("USD"), dec!(0.01));
843        assert!(
844            is_balanced(&txn, &tolerances),
845            "a residual exactly at the tolerance must be treated as balanced"
846        );
847    }
848
849    #[test]
850    fn test_calculate_tolerance() {
851        let amounts = [
852            Amount::new(dec!(100), "USD"),    // scale 0 -> tol 0.5
853            Amount::new(dec!(50.00), "USD"),  // scale 2 -> tol 0.005
854            Amount::new(dec!(25.000), "EUR"), // scale 3 -> tol 0.0005
855        ];
856
857        let refs: Vec<&Amount> = amounts.iter().collect();
858        let tolerances = calculate_tolerance(&refs);
859
860        // USD should use the max tolerance (0.5 from scale 0)
861        assert_eq!(tolerances.get("USD"), Some(&dec!(0.5)));
862        assert_eq!(tolerances.get("EUR"), Some(&dec!(0.0005)));
863    }
864
865    // =========================================================================
866    // Cost-based residual tests
867    // =========================================================================
868
869    /// Test residual calculation with per-unit cost.
870    /// Buy 10 AAPL at $150 each = $1500 total cost in USD.
871    #[test]
872    fn test_calculate_residual_with_per_unit_cost() {
873        let txn = Transaction::new(date(2024, 1, 15), "Buy stock")
874            .with_synthesized_posting(
875                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
876                    CostSpec::empty()
877                        .with_number(rustledger_core::CostNumber::PerUnit {
878                            value: dec!(150.00),
879                        })
880                        .with_currency("USD"),
881                ),
882            )
883            .with_synthesized_posting(Posting::new(
884                "Assets:Cash",
885                Amount::new(dec!(-1500.00), "USD"),
886            ));
887
888        let residual = calculate_residual(&txn);
889        // Cost posting contributes 10 * 150 = 1500 USD
890        // Cash posting contributes -1500 USD
891        // Residual should be 0
892        assert_eq!(residual.get("USD"), Some(&dec!(0)));
893        // AAPL should not appear in residuals (cost converts to USD)
894        assert_eq!(residual.get("AAPL"), None);
895    }
896
897    /// Fitness function: the fast (`Decimal`) and precise (`BigDecimal`) residual
898    /// paths now share one generic engine ([`residual_weight`]), so they must
899    /// produce equal residuals per currency. Guards against a future
900    /// re-specialization of one path drifting from the other. Exercises every
901    /// weight arm in a single transaction.
902    #[test]
903    fn fast_and_precise_residual_agree_across_weight_arms() {
904        use std::str::FromStr;
905
906        let txn = Transaction::new(date(2024, 1, 15), "Every weight arm")
907            // per-unit cost
908            .with_synthesized_posting(
909                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
910                    CostSpec::empty()
911                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150.00) })
912                        .with_currency("USD"),
913                ),
914            )
915            // total cost, negative units
916            .with_synthesized_posting(
917                Posting::new("Assets:Bond", Amount::new(dec!(-3), "BOND")).with_cost(
918                    CostSpec::empty()
919                        .with_number(rustledger_core::CostNumber::Total { value: dec!(450.00) })
920                        .with_currency("USD"),
921                ),
922            )
923            // unit price
924            .with_synthesized_posting(
925                Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD"))
926                    .with_price(PriceAnnotation::unit(Amount::new(dec!(0.85), "EUR"))),
927            )
928            // total price
929            .with_synthesized_posting(
930                Posting::new("Assets:GBP", Amount::new(dec!(20.00), "GBP"))
931                    .with_price(PriceAnnotation::total(Amount::new(dec!(26.00), "EUR"))),
932            )
933            // simple
934            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-12.34), "USD")));
935
936        let fast = calculate_residual(&txn);
937        let precise = calculate_residual_precise(&txn);
938
939        assert_eq!(
940            fast.len(),
941            precise.len(),
942            "fast {fast:?} and precise {precise:?} cover different currency sets"
943        );
944        for (currency, fval) in &fast {
945            let pval = precise.get(currency).expect("currency present in precise");
946            // Compare via the precise value's string form parsed back to Decimal
947            // (exact for these values) — avoids BigDecimal scale-sensitive `==`.
948            let pval_as_dec = Decimal::from_str(&pval.to_string()).unwrap();
949            assert_eq!(
950                *fval, pval_as_dec,
951                "fast and precise residual disagree for {currency}: {fval} vs {pval}"
952            );
953        }
954    }
955
956    /// Test residual calculation with total cost.
957    /// Buy 10 AAPL with total cost of $1500.
958    #[test]
959    fn test_calculate_residual_with_total_cost() {
960        let txn = Transaction::new(date(2024, 1, 15), "Buy stock")
961            .with_synthesized_posting(
962                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
963                    CostSpec::empty()
964                        .with_number(rustledger_core::CostNumber::Total {
965                            value: dec!(1500.00),
966                        })
967                        .with_currency("USD"),
968                ),
969            )
970            .with_synthesized_posting(Posting::new(
971                "Assets:Cash",
972                Amount::new(dec!(-1500.00), "USD"),
973            ));
974
975        let residual = calculate_residual(&txn);
976        // Total cost posting contributes 1500 * signum(10) = 1500 USD
977        // Cash posting contributes -1500 USD
978        assert_eq!(residual.get("USD"), Some(&dec!(0)));
979    }
980
981    /// Test residual calculation with total cost and negative units (sell).
982    #[test]
983    fn test_calculate_residual_with_total_cost_negative_units() {
984        let txn = Transaction::new(date(2024, 1, 15), "Sell stock")
985            .with_synthesized_posting(
986                Posting::new("Assets:Stock", Amount::new(dec!(-10), "AAPL")).with_cost(
987                    CostSpec::empty()
988                        .with_number(rustledger_core::CostNumber::Total {
989                            value: dec!(1500.00),
990                        })
991                        .with_currency("USD"),
992                ),
993            )
994            .with_synthesized_posting(Posting::new(
995                "Assets:Cash",
996                Amount::new(dec!(1500.00), "USD"),
997            ));
998
999        let residual = calculate_residual(&txn);
1000        // Total cost with negative units: 1500 * signum(-10) = -1500 USD
1001        // Cash posting contributes +1500 USD
1002        assert_eq!(residual.get("USD"), Some(&dec!(0)));
1003    }
1004
1005    /// Test cost spec without amount/currency falls back to units.
1006    #[test]
1007    fn test_calculate_residual_cost_without_amount_skips() {
1008        // When a posting has an empty cost spec (e.g., `{}`) and no price annotation,
1009        // it doesn't contribute to the residual because the cost will be determined
1010        // by lot matching during booking. This matches Python beancount behavior.
1011        let txn = Transaction::new(date(2024, 1, 15), "Test")
1012            .with_synthesized_posting(
1013                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1014                    .with_cost(CostSpec::empty()), // Empty cost spec - doesn't contribute
1015            )
1016            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-10), "AAPL")));
1017
1018        let residual = calculate_residual(&txn);
1019        // Empty cost spec posting doesn't contribute, only the second posting does
1020        assert_eq!(residual.get("AAPL"), Some(&dec!(-10)));
1021    }
1022
1023    /// Issue #1026: when an empty cost spec is paired with a price
1024    /// annotation (`{} @ price`), the residual computation must NOT
1025    /// fall through to using the price as the posting's weight. The
1026    /// canonical weight of a cost-tracked posting is `units × cost`,
1027    /// not `units × price`. Pre-fix, this branch produced a balanced
1028    /// residual using the wrong weight; the htsec compat fixture (and
1029    /// the interpolate.rs caller chain) was the visible victim.
1030    ///
1031    /// Pinned here at the lib.rs level so a future revert of the
1032    /// branch reordering would fail this test directly, independent
1033    /// of the interpolate.rs end-to-end tests.
1034    #[test]
1035    fn test_calculate_residual_empty_cost_spec_with_price_skips_not_uses_price() {
1036        let txn = Transaction::new(date(2024, 1, 15), "Sale, empty cost + price")
1037            .with_synthesized_posting(
1038                Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
1039                    .with_cost(CostSpec::empty())
1040                    .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1041                        dec!(150),
1042                        "USD",
1043                    ))),
1044            )
1045            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")));
1046
1047        let residual = calculate_residual(&txn);
1048        // Pre-fix: residual[USD] = 0 (price-as-weight contributed
1049        // -1500, cancelling cash's +1500).
1050        // Post-fix: residual[USD] = +1500 (cost-unknown skipped, only
1051        // cash contributes; the residual stays open for booking-pass
1052        // lot matching to resolve via cost basis).
1053        assert_eq!(residual.get("USD"), Some(&dec!(1500)));
1054    }
1055
1056    /// Companion to the previous test for the `BigDecimal` variant.
1057    /// Same fix, same semantics.
1058    #[test]
1059    fn test_calculate_residual_precise_empty_cost_spec_with_price_skips_not_uses_price() {
1060        use bigdecimal::BigDecimal;
1061        use std::str::FromStr;
1062
1063        let txn = Transaction::new(date(2024, 1, 15), "Sale, empty cost + price")
1064            .with_synthesized_posting(
1065                Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
1066                    .with_cost(CostSpec::empty())
1067                    .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
1068                        dec!(150),
1069                        "USD",
1070                    ))),
1071            )
1072            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")));
1073
1074        let residual = calculate_residual_precise(&txn);
1075        assert_eq!(
1076            residual.get("USD"),
1077            Some(&BigDecimal::from_str("1500").unwrap())
1078        );
1079    }
1080
1081    // =========================================================================
1082    // Price annotation residual tests
1083    // =========================================================================
1084
1085    /// Test residual with per-unit price annotation (@).
1086    /// -100 USD @ 0.85 EUR means we're converting 100 USD to EUR at 0.85 rate.
1087    #[test]
1088    fn test_calculate_residual_with_unit_price() {
1089        let txn = Transaction::new(date(2024, 1, 15), "Currency exchange")
1090            .with_synthesized_posting(
1091                Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD"))
1092                    .with_price(PriceAnnotation::unit(Amount::new(dec!(0.85), "EUR"))),
1093            )
1094            .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
1095
1096        let residual = calculate_residual(&txn);
1097        // Price posting: |-100| * 0.85 * signum(-100) = -85 EUR
1098        // EUR posting: +85 EUR
1099        // Total: 0 EUR
1100        assert_eq!(residual.get("EUR"), Some(&dec!(0)));
1101        // USD should not appear (converted to EUR)
1102        assert_eq!(residual.get("USD"), None);
1103    }
1104
1105    /// Test residual with total price annotation (@@).
1106    #[test]
1107    fn test_calculate_residual_with_total_price() {
1108        let txn = Transaction::new(date(2024, 1, 15), "Currency exchange")
1109            .with_synthesized_posting(
1110                Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD"))
1111                    .with_price(PriceAnnotation::total(Amount::new(dec!(85.00), "EUR"))),
1112            )
1113            .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
1114
1115        let residual = calculate_residual(&txn);
1116        // Total price: 85 * signum(-100) = -85 EUR
1117        // EUR posting: +85 EUR
1118        assert_eq!(residual.get("EUR"), Some(&dec!(0)));
1119    }
1120
1121    /// Test residual with positive units and unit price.
1122    #[test]
1123    fn test_calculate_residual_with_unit_price_positive() {
1124        let txn = Transaction::new(date(2024, 1, 15), "Buy EUR")
1125            .with_synthesized_posting(
1126                Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR"))
1127                    .with_price(PriceAnnotation::unit(Amount::new(dec!(1.18), "USD"))),
1128            )
1129            .with_synthesized_posting(Posting::new(
1130                "Assets:USD",
1131                Amount::new(dec!(-100.30), "USD"),
1132            ));
1133
1134        let residual = calculate_residual(&txn);
1135        // Price posting: |85| * 1.18 * signum(85) = 100.30 USD
1136        // USD posting: -100.30 USD
1137        assert_eq!(residual.get("USD"), Some(&dec!(0)));
1138    }
1139
1140    /// Test `UnitIncomplete` price annotation with complete amount.
1141    #[test]
1142    fn test_calculate_residual_unit_incomplete_with_amount() {
1143        let txn = Transaction::new(date(2024, 1, 15), "Exchange")
1144            .with_synthesized_posting(
1145                Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD")).with_price(
1146                    PriceAnnotation::unit_incomplete(IncompleteAmount::Complete(Amount::new(
1147                        dec!(0.85),
1148                        "EUR",
1149                    ))),
1150                ),
1151            )
1152            .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
1153
1154        let residual = calculate_residual(&txn);
1155        assert_eq!(residual.get("EUR"), Some(&dec!(0)));
1156    }
1157
1158    /// Test `TotalIncomplete` price annotation with complete amount.
1159    #[test]
1160    fn test_calculate_residual_total_incomplete_with_amount() {
1161        let txn = Transaction::new(date(2024, 1, 15), "Exchange")
1162            .with_synthesized_posting(
1163                Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD")).with_price(
1164                    PriceAnnotation::total_incomplete(IncompleteAmount::Complete(Amount::new(
1165                        dec!(85.00),
1166                        "EUR",
1167                    ))),
1168                ),
1169            )
1170            .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
1171
1172        let residual = calculate_residual(&txn);
1173        assert_eq!(residual.get("EUR"), Some(&dec!(0)));
1174    }
1175
1176    /// Test `UnitIncomplete` without amount falls back to units.
1177    #[test]
1178    fn test_calculate_residual_unit_incomplete_no_amount_fallback() {
1179        let txn = Transaction::new(date(2024, 1, 15), "Test")
1180            .with_synthesized_posting(
1181                Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD")).with_price(
1182                    PriceAnnotation::unit_incomplete(IncompleteAmount::NumberOnly(dec!(0.85))),
1183                ),
1184            )
1185            .with_synthesized_posting(Posting::new(
1186                "Assets:USD",
1187                Amount::new(dec!(-100.00), "USD"),
1188            ));
1189
1190        let residual = calculate_residual(&txn);
1191        // Falls back to units since no currency in incomplete amount
1192        assert_eq!(residual.get("USD"), Some(&dec!(0)));
1193    }
1194
1195    /// Test `TotalIncomplete` without amount falls back to units.
1196    #[test]
1197    fn test_calculate_residual_total_incomplete_no_amount_fallback() {
1198        let txn = Transaction::new(date(2024, 1, 15), "Test")
1199            .with_synthesized_posting(
1200                Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD")).with_price(
1201                    PriceAnnotation::total_incomplete(IncompleteAmount::NumberOnly(dec!(85.00))),
1202                ),
1203            )
1204            .with_synthesized_posting(Posting::new(
1205                "Assets:USD",
1206                Amount::new(dec!(-100.00), "USD"),
1207            ));
1208
1209        let residual = calculate_residual(&txn);
1210        assert_eq!(residual.get("USD"), Some(&dec!(0)));
1211    }
1212
1213    /// Test `UnitEmpty` price annotation falls back to units.
1214    #[test]
1215    fn test_calculate_residual_unit_empty_fallback() {
1216        let txn = Transaction::new(date(2024, 1, 15), "Test")
1217            .with_synthesized_posting(
1218                Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD"))
1219                    .with_price(PriceAnnotation::unit_empty()),
1220            )
1221            .with_synthesized_posting(Posting::new(
1222                "Assets:USD",
1223                Amount::new(dec!(-100.00), "USD"),
1224            ));
1225
1226        let residual = calculate_residual(&txn);
1227        // Falls back to units
1228        assert_eq!(residual.get("USD"), Some(&dec!(0)));
1229    }
1230
1231    /// Test `TotalEmpty` price annotation falls back to units.
1232    #[test]
1233    fn test_calculate_residual_total_empty_fallback() {
1234        let txn = Transaction::new(date(2024, 1, 15), "Test")
1235            .with_synthesized_posting(
1236                Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD"))
1237                    .with_price(PriceAnnotation::total_empty()),
1238            )
1239            .with_synthesized_posting(Posting::new(
1240                "Assets:USD",
1241                Amount::new(dec!(-100.00), "USD"),
1242            ));
1243
1244        let residual = calculate_residual(&txn);
1245        assert_eq!(residual.get("USD"), Some(&dec!(0)));
1246    }
1247
1248    // =========================================================================
1249    // Mixed and edge case tests
1250    // =========================================================================
1251
1252    /// Test transaction with both cost and regular postings.
1253    #[test]
1254    fn test_calculate_residual_mixed_cost_and_simple() {
1255        let txn = Transaction::new(date(2024, 1, 15), "Buy with fee")
1256            .with_synthesized_posting(
1257                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1258                    CostSpec::empty()
1259                        .with_number(rustledger_core::CostNumber::PerUnit {
1260                            value: dec!(150.00),
1261                        })
1262                        .with_currency("USD"),
1263                ),
1264            )
1265            .with_synthesized_posting(Posting::new(
1266                "Expenses:Fees",
1267                Amount::new(dec!(10.00), "USD"),
1268            ))
1269            .with_synthesized_posting(Posting::new(
1270                "Assets:Cash",
1271                Amount::new(dec!(-1510.00), "USD"),
1272            ));
1273
1274        let residual = calculate_residual(&txn);
1275        // 10 * 150 + 10 - 1510 = 0
1276        assert_eq!(residual.get("USD"), Some(&dec!(0)));
1277    }
1278
1279    /// Test sell with cost basis and capital gains.
1280    #[test]
1281    fn test_calculate_residual_sell_with_gains() {
1282        let txn = Transaction::new(date(2024, 6, 15), "Sell stock")
1283            .with_synthesized_posting(
1284                Posting::new("Assets:Stock", Amount::new(dec!(-10), "AAPL"))
1285                    .with_cost(
1286                        CostSpec::empty()
1287                            .with_number(rustledger_core::CostNumber::PerUnit {
1288                                value: dec!(150.00),
1289                            })
1290                            .with_currency("USD"),
1291                    )
1292                    .with_price(PriceAnnotation::unit(Amount::new(dec!(175.00), "USD"))),
1293            )
1294            .with_synthesized_posting(Posting::new(
1295                "Assets:Cash",
1296                Amount::new(dec!(1750.00), "USD"),
1297            ))
1298            .with_synthesized_posting(Posting::new(
1299                "Income:CapitalGains",
1300                Amount::new(dec!(-250.00), "USD"),
1301            ));
1302
1303        let residual = calculate_residual(&txn);
1304        // Stock posting with cost: -10 * 150 = -1500 USD (cost takes precedence)
1305        // Cash: +1750 USD
1306        // Gains: -250 USD
1307        // Total: -1500 + 1750 - 250 = 0
1308        assert_eq!(residual.get("USD"), Some(&dec!(0)));
1309    }
1310
1311    /// Test multi-currency transaction with costs.
1312    #[test]
1313    fn test_calculate_residual_multi_currency_with_cost() {
1314        let txn = Transaction::new(date(2024, 1, 15), "Multi-currency")
1315            .with_synthesized_posting(
1316                Posting::new("Assets:Stock:US", Amount::new(dec!(10), "AAPL")).with_cost(
1317                    CostSpec::empty()
1318                        .with_number(rustledger_core::CostNumber::PerUnit {
1319                            value: dec!(150.00),
1320                        })
1321                        .with_currency("USD"),
1322                ),
1323            )
1324            .with_synthesized_posting(
1325                Posting::new("Assets:Stock:EU", Amount::new(dec!(5), "SAP")).with_cost(
1326                    CostSpec::empty()
1327                        .with_number(rustledger_core::CostNumber::PerUnit {
1328                            value: dec!(100.00),
1329                        })
1330                        .with_currency("EUR"),
1331                ),
1332            )
1333            .with_synthesized_posting(Posting::new(
1334                "Assets:Cash:USD",
1335                Amount::new(dec!(-1500.00), "USD"),
1336            ))
1337            .with_synthesized_posting(Posting::new(
1338                "Assets:Cash:EUR",
1339                Amount::new(dec!(-500.00), "EUR"),
1340            ));
1341
1342        let residual = calculate_residual(&txn);
1343        assert_eq!(residual.get("USD"), Some(&dec!(0)));
1344        assert_eq!(residual.get("EUR"), Some(&dec!(0)));
1345    }
1346
1347    /// Test that incomplete units (auto postings) are skipped.
1348    #[test]
1349    fn test_calculate_residual_skips_incomplete_units() {
1350        let txn = Transaction::new(date(2024, 1, 15), "Test")
1351            .with_synthesized_posting(Posting::new(
1352                "Expenses:Food",
1353                Amount::new(dec!(50.00), "USD"),
1354            ))
1355            .with_synthesized_posting(Posting::auto("Assets:Cash")); // No units
1356
1357        let residual = calculate_residual(&txn);
1358        // Only the complete posting is counted
1359        assert_eq!(residual.get("USD"), Some(&dec!(50.00)));
1360    }
1361
1362    // =========================================================================
1363    // Cost currency inference tests (issue #203)
1364    // =========================================================================
1365
1366    /// Test cost currency is inferred from other postings.
1367    /// This is the exact case from issue #203.
1368    #[test]
1369    fn test_calculate_residual_infers_cost_currency_from_other_posting() {
1370        // 2026-01-01 * "Opening balance"
1371        //   Assets:Vanguard:IRA:Trad:VFIFX  10 VFIFX {100}
1372        //   Equity:Opening-Balances      -1000 USD
1373        //
1374        // Python beancount infers the cost currency as USD from the second posting.
1375        let txn = Transaction::new(date(2026, 1, 1), "Opening balance")
1376            .with_synthesized_posting(
1377                Posting::new(
1378                    "Assets:Vanguard:IRA:Trad:VFIFX",
1379                    Amount::new(dec!(10), "VFIFX"),
1380                )
1381                .with_cost(
1382                    CostSpec::empty()
1383                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) }),
1384                ),
1385            )
1386            .with_synthesized_posting(Posting::new(
1387                "Equity:Opening-Balances",
1388                Amount::new(dec!(-1000), "USD"),
1389            ));
1390
1391        let residual = calculate_residual(&txn);
1392        // Cost posting should contribute 10 * 100 = 1000 USD (inferred from other posting)
1393        // Equity posting contributes -1000 USD
1394        // Residual should be 0
1395        assert_eq!(
1396            residual.get("USD"),
1397            Some(&dec!(0)),
1398            "Should balance when cost currency is inferred from other posting"
1399        );
1400        // VFIFX should not appear in residuals
1401        assert_eq!(residual.get("VFIFX"), None);
1402    }
1403
1404    /// Test cost currency inference with total cost.
1405    #[test]
1406    fn test_calculate_residual_infers_cost_currency_total_cost() {
1407        // 10 VFIFX {{1000}} with -1000 USD posting
1408        let txn = Transaction::new(date(2026, 1, 1), "Test")
1409            .with_synthesized_posting(
1410                Posting::new("Assets:Stock", Amount::new(dec!(10), "VFIFX")).with_cost(
1411                    CostSpec::empty()
1412                        .with_number(rustledger_core::CostNumber::Total { value: dec!(1000) }),
1413                ),
1414            )
1415            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1000), "USD")));
1416
1417        let residual = calculate_residual(&txn);
1418        assert_eq!(residual.get("USD"), Some(&dec!(0)));
1419    }
1420
1421    /// Test that explicit cost currency takes precedence over inference.
1422    #[test]
1423    fn test_calculate_residual_explicit_cost_currency_takes_precedence() {
1424        // If cost has explicit currency, don't infer from other postings
1425        let txn = Transaction::new(date(2026, 1, 1), "Test")
1426            .with_synthesized_posting(
1427                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1428                    CostSpec::empty()
1429                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) })
1430                        .with_currency("EUR"), // Explicit EUR
1431                ),
1432            )
1433            .with_synthesized_posting(Posting::new(
1434                "Assets:Cash",
1435                Amount::new(dec!(-1000), "USD"), // USD posting
1436            ));
1437
1438        let residual = calculate_residual(&txn);
1439        // Should use EUR (explicit) not USD (from other posting)
1440        assert_eq!(residual.get("EUR"), Some(&dec!(1000)));
1441        assert_eq!(residual.get("USD"), Some(&dec!(-1000)));
1442    }
1443
1444    /// Test that price annotation takes precedence over other posting inference.
1445    #[test]
1446    fn test_calculate_residual_price_annotation_takes_precedence() {
1447        // If cost has price annotation, use that currency
1448        let txn = Transaction::new(date(2026, 1, 1), "Test")
1449            .with_synthesized_posting(
1450                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1451                    .with_cost(
1452                        CostSpec::empty()
1453                            .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) }),
1454                    )
1455                    .with_price(PriceAnnotation::unit(Amount::new(dec!(105), "EUR"))),
1456            )
1457            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1000), "USD")));
1458
1459        let residual = calculate_residual(&txn);
1460        // Should use EUR (from price annotation) not USD (from other posting)
1461        assert_eq!(residual.get("EUR"), Some(&dec!(1000)));
1462        assert_eq!(residual.get("USD"), Some(&dec!(-1000)));
1463    }
1464
1465    // =========================================================================
1466    // infer_cost_currency_from_postings tests
1467    // =========================================================================
1468
1469    /// Test that cost spec currency is used as fallback when no simple postings exist.
1470    #[test]
1471    fn test_infer_cost_currency_from_cost_spec() {
1472        // Transaction with only cost-spec posting - should get currency from cost spec
1473        let txn = Transaction::new(date(2022, 4, 16), "Free tokens")
1474            .with_synthesized_posting(
1475                Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
1476                    CostSpec::empty()
1477                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
1478                        .with_currency("USD"),
1479                ),
1480            )
1481            .with_synthesized_posting(Posting::auto("Income:Bonus"));
1482
1483        let inferred = infer_cost_currency_from_postings(&txn);
1484        assert_eq!(inferred.as_deref(), Some("USD"));
1485    }
1486
1487    /// Test that simple posting currency takes precedence over cost spec currency.
1488    #[test]
1489    fn test_infer_cost_currency_simple_takes_precedence() {
1490        // Transaction with both simple posting and cost spec - simple should win
1491        let txn = Transaction::new(date(2022, 4, 16), "Trade")
1492            .with_synthesized_posting(
1493                Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
1494                    CostSpec::empty()
1495                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(10) })
1496                        .with_currency("EUR"),
1497                ),
1498            )
1499            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1000), "USD")));
1500
1501        let inferred = infer_cost_currency_from_postings(&txn);
1502        // Should get USD from the simple posting, not EUR from cost spec
1503        assert_eq!(inferred.as_deref(), Some("USD"));
1504    }
1505
1506    /// Test that zero-cost spec currency is still used for inference.
1507    #[test]
1508    fn test_infer_cost_currency_zero_cost() {
1509        // Zero cost should still provide the currency
1510        let txn = Transaction::new(date(2022, 4, 16), "Airdrop")
1511            .with_synthesized_posting(
1512                Posting::new("Assets:Crypto", Amount::new(dec!(1000), "SHIB")).with_cost(
1513                    CostSpec::empty()
1514                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
1515                        .with_currency("JPY"),
1516                ),
1517            )
1518            .with_synthesized_posting(Posting::auto("Income:Airdrop"));
1519
1520        let inferred = infer_cost_currency_from_postings(&txn);
1521        assert_eq!(inferred.as_deref(), Some("JPY"));
1522    }
1523}