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/// Calculate the tolerance for a set of amounts.
46///
47/// Tolerance is the maximum of all individual amount tolerances.
48#[must_use]
49pub fn calculate_tolerance(amounts: &[&Amount]) -> FxHashMap<Currency, Decimal> {
50    // Pre-allocate for typical case (1-3 currencies per transaction)
51    let mut tolerances: FxHashMap<Currency, Decimal> =
52        FxHashMap::with_capacity_and_hasher(amounts.len().min(4), Default::default());
53
54    for amount in amounts {
55        let tol = amount.inferred_tolerance();
56        tolerances
57            .entry(amount.currency.clone())
58            .and_modify(|t| *t = (*t).max(tol))
59            .or_insert(tol);
60    }
61
62    tolerances
63}
64
65/// Extract the currency named in a posting's price annotation, if any.
66///
67/// Returns the currency on `IncompleteAmount::Complete`. `CurrencyOnly`,
68/// `NumberOnly`, and the bare-sigil form (`amount: None`) all return
69/// `None` — they're shapes where the currency is either missing or
70/// supplied later by interpolation. `kind` (Unit vs Total) is irrelevant
71/// at this layer.
72#[must_use]
73pub(crate) fn price_currency_of(posting: &rustledger_core::Posting) -> Option<Currency> {
74    posting
75        .price
76        .as_ref()
77        .and_then(|p| p.amount.as_ref())
78        .and_then(IncompleteAmount::as_amount)
79        .map(|a| a.currency.clone())
80}
81
82/// Infer the cost currency from other postings in the transaction.
83///
84/// Python beancount infers cost currency from simple postings (those without
85/// cost specs) when a cost is specified without a currency like `{100}`.
86///
87/// Currency inference follows this priority:
88/// 1. An explicit currency in the cost specification itself (handled by the caller).
89/// 2. A price annotation on a simple posting (the price currency takes precedence).
90/// 3. The currency of other simple postings (units or currency-only amounts).
91/// 4. The currency from a cost spec (e.g., `{0 USD}` for zero-cost items).
92#[must_use]
93pub(crate) fn infer_cost_currency_from_postings(transaction: &Transaction) -> Option<Currency> {
94    // First pass: look for simple postings (no cost spec) - these take priority
95    for posting in &transaction.postings {
96        // Skip postings with cost specs in first pass
97        if posting.cost.is_some() {
98            continue;
99        }
100
101        // Get the currency from this posting's units
102        if let Some(units) = &posting.units {
103            match units {
104                IncompleteAmount::Complete(amount) => {
105                    // If this posting has a price annotation, the "real" currency
106                    // is the price currency, not the units currency
107                    if let Some(c) = price_currency_of(posting) {
108                        return Some(c);
109                    }
110                    // Simple posting - use its currency
111                    return Some(amount.currency.clone());
112                }
113                IncompleteAmount::CurrencyOnly(currency) => {
114                    return Some(currency.clone());
115                }
116                IncompleteAmount::NumberOnly(_) => {}
117            }
118        }
119    }
120
121    // Second pass: look for cost spec currencies (e.g., `{0 USD}`)
122    // This handles zero-cost postings where the cost currency should be used
123    for posting in &transaction.postings {
124        if let Some(cost) = &posting.cost
125            && let Some(currency) = &cost.currency
126        {
127            return Some(currency.clone());
128        }
129    }
130
131    None
132}
133
134/// Numeric backend for the posting-weight engine. `Decimal` is the fast path;
135/// `BigDecimal` the arbitrary-precision path used near the `rust_decimal`
136/// 28-digit ceiling. Both implement this trait so the balance-weight ladder
137/// (cost-spec resolution + price formula) lives in exactly ONE place
138/// ([`residual_weight`]): a new `CostNumber` variant or a sign fix then forces a
139/// compile error / change at a single site instead of silently drifting between
140/// the fast and precise residual functions.
141///
142/// `abs`/`signum` are taken on the source `Decimal` (exact — they add no
143/// digits); only the *multiplications* run in `D`, so `D = BigDecimal`
144/// reproduces the precise path's arithmetic byte-for-byte.
145trait WeightNum: Clone + Default + std::ops::AddAssign + std::ops::Mul<Output = Self> {
146    fn from_decimal(d: Decimal) -> Self;
147}
148
149impl WeightNum for Decimal {
150    fn from_decimal(d: Decimal) -> Self {
151        d
152    }
153}
154
155impl WeightNum for BigDecimal {
156    fn from_decimal(d: Decimal) -> Self {
157        to_big(d)
158    }
159}
160
161/// Resolve the currency a posting's cost weight is denominated in: the explicit
162/// cost currency, else the price currency, else `infer_currency()` (called
163/// lazily — only when the first two are absent). Returns `None` if the posting
164/// has no cost spec or no currency can be determined.
165#[must_use]
166pub(crate) fn cost_currency_of(
167    posting: &rustledger_core::Posting,
168    infer_currency: impl FnOnce() -> Option<Currency>,
169) -> Option<Currency> {
170    let cost_spec = posting.cost.as_ref()?;
171    cost_spec
172        .currency
173        .clone()
174        .or_else(|| price_currency_of(posting))
175        .or_else(infer_currency)
176}
177
178/// The canonical per-posting **cost** weight contribution — the single
179/// `CostNumber` ladder shared by [`residual_weight`] and `interpolate` (so the
180/// "cost beats price" weight rule and a future `CostNumber` variant live in one
181/// place rather than drifting between balance-checking and interpolation).
182///
183/// Returns `None` for a posting with no cost spec, an empty `{}` spec (no
184/// determinable number), or when no cost currency resolves. `interpolate`
185/// instantiates this at `Decimal`.
186fn cost_weight<D: WeightNum>(
187    posting: &rustledger_core::Posting,
188    units: &Amount,
189    infer_currency: impl FnOnce() -> Option<Currency>,
190) -> Option<(Currency, D)> {
191    let cost_spec = posting.cost.as_ref()?;
192    let signum = units.number.signum();
193    // `PerUnitFromTotal` and `Total` both carry a preserved total — using it
194    // avoids the division-then-multiplication precision loss of recomputing from
195    // `per_unit`. `PerUnit` goes through multiplication. Match the number FIRST
196    // so an empty `{}` spec short-circuits without resolving (and possibly
197    // inferring) the cost currency.
198    let weight = match cost_spec.number {
199        Some(rustledger_core::CostNumber::Total { value: total }) => {
200            D::from_decimal(total) * D::from_decimal(signum)
201        }
202        Some(rustledger_core::CostNumber::PerUnitFromTotal(b)) => {
203            D::from_decimal(b.total) * D::from_decimal(signum)
204        }
205        Some(rustledger_core::CostNumber::PerUnit { value: per_unit }) => {
206            D::from_decimal(units.number) * D::from_decimal(per_unit)
207        }
208        None => return None, // empty `{}`
209    };
210    let cost_curr = cost_currency_of(posting, infer_currency)?;
211    Some((cost_curr, weight))
212}
213
214/// The canonical per-posting balance weight, summed per currency, generic over
215/// the numeric backend. Single source of truth for [`calculate_residual`] and
216/// [`calculate_residual_precise`].
217///
218/// Weight rule (Beancount): a cost spec puts the weight in the cost currency
219/// (`cost` beats `price`); else a price annotation puts it in the price
220/// currency; else the weight is the units themselves.
221fn residual_weight<D: WeightNum>(transaction: &Transaction) -> FxHashMap<Currency, D> {
222    // Pre-allocate for typical case (1-2 currencies per transaction)
223    let mut residuals: FxHashMap<Currency, D> =
224        FxHashMap::with_capacity_and_hasher(transaction.postings.len().min(4), Default::default());
225
226    // Lazily compute inferred currency only when needed (most transactions don't need it)
227    let mut inferred_cost_currency: Option<Option<Currency>> = None;
228    let get_inferred_currency = |cache: &mut Option<Option<Currency>>| -> Option<Currency> {
229        cache
230            .get_or_insert_with(|| infer_cost_currency_from_postings(transaction))
231            .clone()
232    };
233
234    for posting in &transaction.postings {
235        // Only process complete amounts
236        let Some(IncompleteAmount::Complete(units)) = &posting.units else {
237            continue;
238        };
239        let signum = units.number.signum();
240
241        // Determine the "weight" of this posting for balance purposes.
242        let cost_contribution = cost_weight::<D>(posting, units, || {
243            get_inferred_currency(&mut inferred_cost_currency)
244        });
245
246        if let Some((currency, amount)) = cost_contribution {
247            // Cost-based posting: weight is in the cost currency
248            *residuals.entry(currency).or_default() += amount;
249        } else if posting.cost.is_some() {
250            // Cost spec exists but has no determinable cost number
251            // (e.g., empty `{}`). The CANONICAL weight of a cost-tracked
252            // posting is `units × cost`, NOT `units × price` — even if a
253            // price annotation is present. Falling through to the price
254            // branch would silently produce a balanced residual using
255            // the wrong weight (issue #1026). Skip contribution; the
256            // booking pass will resolve via lot matching, and the
257            // interpolation rule (in `interpolate.rs`) accounts for
258            // this posting as one cost-unknown for its currency group.
259        } else if let Some(price) = &posting.price {
260            // Price annotation: converts units to the price currency.
261            if let Some(amt) = price.amount.as_ref().and_then(IncompleteAmount::as_amount) {
262                // `kind = Unit` ⇒ `|units| * price * sign(units)`;
263                // `kind = Total` ⇒ `price * sign(units)`. The expanded
264                // `abs * price * signum` form (rather than `units * price`) is
265                // kept so `D = Decimal` and `D = BigDecimal` reproduce the
266                // pre-refactor arithmetic exactly.
267                let signed = match price.kind {
268                    rustledger_core::PriceKind::Unit => {
269                        D::from_decimal(units.number.abs())
270                            * D::from_decimal(amt.number)
271                            * D::from_decimal(signum)
272                    }
273                    rustledger_core::PriceKind::Total => {
274                        D::from_decimal(amt.number) * D::from_decimal(signum)
275                    }
276                };
277                *residuals.entry(amt.currency.clone()).or_default() += signed;
278            } else {
279                // Incomplete or bare-sigil price annotation — can't
280                // calculate a price-currency conversion, fall back to units.
281                *residuals.entry(units.currency.clone()).or_default() +=
282                    D::from_decimal(units.number);
283            }
284        } else {
285            // Simple posting: weight is just the units
286            *residuals.entry(units.currency.clone()).or_default() += D::from_decimal(units.number);
287        }
288    }
289
290    residuals
291}
292
293/// Calculate the residual (imbalance) of a transaction.
294///
295/// Returns a map of currency -> residual amount.
296/// A balanced transaction has all residuals within tolerance.
297///
298/// # TLA+ Specification
299///
300/// Implements balance checking from `DoubleEntry.tla`:
301/// - Invariant: `TransactionsBalance` - For every transaction, `sum(postings) = 0`
302/// - Each currency is checked independently
303/// - A non-zero residual indicates a violation of double-entry bookkeeping
304///
305/// See: `spec/tla/DoubleEntry.tla`
306#[must_use]
307// clippy::implicit_hasher still fires for a concrete `FxBuildHasher` (it wants
308// the fn generic over `S: BuildHasher`); the explicit fast hasher is the point.
309#[allow(clippy::implicit_hasher)]
310pub fn calculate_residual(transaction: &Transaction) -> FxHashMap<Currency, Decimal> {
311    residual_weight::<Decimal>(transaction)
312}
313
314/// Convert a `rust_decimal::Decimal` to `BigDecimal` for arbitrary-precision arithmetic.
315///
316/// Individual `Decimal` values are representable exactly (≤28 significant digits).
317/// The precision loss only occurs during arithmetic, so converting before operations
318/// preserves full precision.
319fn to_big(d: Decimal) -> BigDecimal {
320    use std::str::FromStr;
321    // rust_decimal Display is exact; BigDecimal FromStr handles any decimal string
322    BigDecimal::from_str(&d.to_string()).expect("Decimal always produces valid decimal string")
323}
324
325/// Calculate the residual of a transaction using arbitrary-precision arithmetic.
326///
327/// This mirrors [`calculate_residual`] but uses `BigDecimal` to avoid precision loss
328/// when amounts have near-28-digit precision. `rust_decimal` is limited to 28-29
329/// significant digits; this function handles arbitrary precision correctly.
330#[must_use]
331#[allow(clippy::implicit_hasher)]
332pub fn calculate_residual_precise(transaction: &Transaction) -> FxHashMap<Currency, BigDecimal> {
333    residual_weight::<BigDecimal>(transaction)
334}
335
336/// Check if a transaction is balanced within tolerance.
337#[must_use]
338#[allow(clippy::implicit_hasher)]
339pub fn is_balanced(transaction: &Transaction, tolerances: &FxHashMap<Currency, Decimal>) -> bool {
340    let residuals = calculate_residual(transaction);
341
342    for (currency, residual) in residuals {
343        let tolerance = tolerances.get(&currency).copied().unwrap_or(Decimal::ZERO); // Default 0 (exact balance for integer-only currencies)
344
345        if residual.abs() > tolerance {
346            return false;
347        }
348    }
349
350    true
351}
352
353/// Normalize total prices (`@@`) to per-unit prices (`@`) on a transaction.
354///
355/// This converts a `PriceAnnotation` with `PriceKind::Total` to one with
356/// `PriceKind::Unit` by dividing
357/// the total price by the number of units. This should be called AFTER validation
358/// (balance checking) to preserve exact total prices for precise residual calculation.
359///
360/// Matches Python beancount behavior where `@@` is converted to `@`.
361pub fn normalize_prices(txn: &mut Transaction) {
362    use rustledger_core::{PriceAnnotation, PriceKind};
363
364    for posting in &mut txn.postings {
365        if let (Some(IncompleteAmount::Complete(units)), Some(price)) =
366            (&posting.units, &posting.price)
367            && price.kind == PriceKind::Total
368        {
369            let normalized = match price.amount.as_ref().and_then(IncompleteAmount::as_amount) {
370                Some(total_amount) if !units.number.is_zero() => {
371                    let per_unit = total_amount.number / units.number.abs();
372                    Some(PriceAnnotation::unit(Amount::new(
373                        per_unit,
374                        &total_amount.currency,
375                    )))
376                }
377                Some(_) => None, // units.number is zero — leave alone
378                None => {
379                    // Empty (`@@` with no amount) — Total → Unit sigil swap.
380                    // `total_incomplete` with no complete amount cannot be
381                    // normalized because we don't have a number to divide.
382                    if price.amount.is_none() {
383                        Some(PriceAnnotation::unit_empty())
384                    } else {
385                        None
386                    }
387                }
388            };
389            if let Some(normalized_price) = normalized {
390                posting.price = Some(normalized_price);
391            }
392        }
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use rust_decimal_macros::dec;
400    use rustledger_core::{CostSpec, IncompleteAmount, NaiveDate, Posting, PriceAnnotation};
401
402    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
403        rustledger_core::naive_date(year, month, day).unwrap()
404    }
405
406    // =========================================================================
407    // Basic residual tests (existing)
408    // =========================================================================
409
410    #[test]
411    fn test_calculate_residual_balanced() {
412        let txn = Transaction::new(date(2024, 1, 15), "Test")
413            .with_synthesized_posting(Posting::new(
414                "Expenses:Food",
415                Amount::new(dec!(50.00), "USD"),
416            ))
417            .with_synthesized_posting(Posting::new(
418                "Assets:Cash",
419                Amount::new(dec!(-50.00), "USD"),
420            ));
421
422        let residual = calculate_residual(&txn);
423        assert_eq!(residual.get("USD"), Some(&dec!(0)));
424    }
425
426    #[test]
427    fn test_calculate_residual_unbalanced() {
428        let txn = Transaction::new(date(2024, 1, 15), "Test")
429            .with_synthesized_posting(Posting::new(
430                "Expenses:Food",
431                Amount::new(dec!(50.00), "USD"),
432            ))
433            .with_synthesized_posting(Posting::new(
434                "Assets:Cash",
435                Amount::new(dec!(-45.00), "USD"),
436            ));
437
438        let residual = calculate_residual(&txn);
439        assert_eq!(residual.get("USD"), Some(&dec!(5.00)));
440    }
441
442    #[test]
443    fn test_is_balanced() {
444        let txn = Transaction::new(date(2024, 1, 15), "Test")
445            .with_synthesized_posting(Posting::new(
446                "Expenses:Food",
447                Amount::new(dec!(50.00), "USD"),
448            ))
449            .with_synthesized_posting(Posting::new(
450                "Assets:Cash",
451                Amount::new(dec!(-50.00), "USD"),
452            ));
453
454        let tolerances = calculate_tolerance(&[
455            &Amount::new(dec!(50.00), "USD"),
456            &Amount::new(dec!(-50.00), "USD"),
457        ]);
458
459        assert!(is_balanced(&txn, &tolerances));
460    }
461
462    #[test]
463    fn test_is_balanced_within_tolerance() {
464        let txn = Transaction::new(date(2024, 1, 15), "Test")
465            .with_synthesized_posting(Posting::new(
466                "Expenses:Food",
467                Amount::new(dec!(50.004), "USD"),
468            ))
469            .with_synthesized_posting(Posting::new(
470                "Assets:Cash",
471                Amount::new(dec!(-50.00), "USD"),
472            ));
473
474        let tolerances = calculate_tolerance(&[
475            &Amount::new(dec!(50.004), "USD"),
476            &Amount::new(dec!(-50.00), "USD"),
477        ]);
478
479        // 0.004 is within tolerance of 0.005 (scale 2 -> 0.005)
480        assert!(is_balanced(&txn, &tolerances));
481    }
482
483    #[test]
484    fn test_is_balanced_detects_imbalance() {
485        // Mutation guard (#1238): the existing is_balanced tests only
486        // assert the TRUE (balanced) cases, so replacing the whole body
487        // with `true` survived the suite — the balance check could be
488        // wholly broken and no test would notice. Assert the FALSE case.
489        let txn = Transaction::new(date(2024, 1, 15), "Test")
490            .with_synthesized_posting(Posting::new(
491                "Expenses:Food",
492                Amount::new(dec!(50.00), "USD"),
493            ))
494            .with_synthesized_posting(Posting::new(
495                "Assets:Cash",
496                Amount::new(dec!(-49.00), "USD"),
497            ));
498        // Residual is 1.00 USD against zero tolerance — clearly unbalanced.
499        let mut tolerances = FxHashMap::default();
500        tolerances.insert(Currency::from("USD"), Decimal::ZERO);
501        assert!(
502            !is_balanced(&txn, &tolerances),
503            "a 1.00 USD residual with zero tolerance must be detected as unbalanced"
504        );
505    }
506
507    #[test]
508    fn test_is_balanced_at_exact_tolerance_boundary() {
509        // Mutation guard (#1238): the comparison is `residual.abs() >
510        // tolerance`, so a residual EXACTLY at the tolerance is balanced
511        // (strict greater-than). This kills the `>`->`>=` and `>`->`==`
512        // mutants, both of which would wrongly reject the boundary case.
513        let txn = Transaction::new(date(2024, 1, 15), "Test")
514            .with_synthesized_posting(Posting::new(
515                "Expenses:Food",
516                Amount::new(dec!(50.01), "USD"),
517            ))
518            .with_synthesized_posting(Posting::new(
519                "Assets:Cash",
520                Amount::new(dec!(-50.00), "USD"),
521            ));
522        // Residual 0.01 exactly equals the tolerance: balanced under `>`.
523        let mut tolerances = FxHashMap::default();
524        tolerances.insert(Currency::from("USD"), dec!(0.01));
525        assert!(
526            is_balanced(&txn, &tolerances),
527            "a residual exactly at the tolerance must be treated as balanced"
528        );
529    }
530
531    #[test]
532    fn test_calculate_tolerance() {
533        let amounts = [
534            Amount::new(dec!(100), "USD"),    // scale 0 -> tol 0.5
535            Amount::new(dec!(50.00), "USD"),  // scale 2 -> tol 0.005
536            Amount::new(dec!(25.000), "EUR"), // scale 3 -> tol 0.0005
537        ];
538
539        let refs: Vec<&Amount> = amounts.iter().collect();
540        let tolerances = calculate_tolerance(&refs);
541
542        // USD should use the max tolerance (0.5 from scale 0)
543        assert_eq!(tolerances.get("USD"), Some(&dec!(0.5)));
544        assert_eq!(tolerances.get("EUR"), Some(&dec!(0.0005)));
545    }
546
547    // =========================================================================
548    // Cost-based residual tests
549    // =========================================================================
550
551    /// Test residual calculation with per-unit cost.
552    /// Buy 10 AAPL at $150 each = $1500 total cost in USD.
553    #[test]
554    fn test_calculate_residual_with_per_unit_cost() {
555        let txn = Transaction::new(date(2024, 1, 15), "Buy stock")
556            .with_synthesized_posting(
557                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
558                    CostSpec::empty()
559                        .with_number(rustledger_core::CostNumber::PerUnit {
560                            value: dec!(150.00),
561                        })
562                        .with_currency("USD"),
563                ),
564            )
565            .with_synthesized_posting(Posting::new(
566                "Assets:Cash",
567                Amount::new(dec!(-1500.00), "USD"),
568            ));
569
570        let residual = calculate_residual(&txn);
571        // Cost posting contributes 10 * 150 = 1500 USD
572        // Cash posting contributes -1500 USD
573        // Residual should be 0
574        assert_eq!(residual.get("USD"), Some(&dec!(0)));
575        // AAPL should not appear in residuals (cost converts to USD)
576        assert_eq!(residual.get("AAPL"), None);
577    }
578
579    /// Fitness function: the fast (`Decimal`) and precise (`BigDecimal`) residual
580    /// paths now share one generic engine ([`residual_weight`]), so they must
581    /// produce equal residuals per currency. Guards against a future
582    /// re-specialization of one path drifting from the other. Exercises every
583    /// weight arm in a single transaction.
584    #[test]
585    fn fast_and_precise_residual_agree_across_weight_arms() {
586        use std::str::FromStr;
587
588        let txn = Transaction::new(date(2024, 1, 15), "Every weight arm")
589            // per-unit cost
590            .with_synthesized_posting(
591                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
592                    CostSpec::empty()
593                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150.00) })
594                        .with_currency("USD"),
595                ),
596            )
597            // total cost, negative units
598            .with_synthesized_posting(
599                Posting::new("Assets:Bond", Amount::new(dec!(-3), "BOND")).with_cost(
600                    CostSpec::empty()
601                        .with_number(rustledger_core::CostNumber::Total { value: dec!(450.00) })
602                        .with_currency("USD"),
603                ),
604            )
605            // unit price
606            .with_synthesized_posting(
607                Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD"))
608                    .with_price(PriceAnnotation::unit(Amount::new(dec!(0.85), "EUR"))),
609            )
610            // total price
611            .with_synthesized_posting(
612                Posting::new("Assets:GBP", Amount::new(dec!(20.00), "GBP"))
613                    .with_price(PriceAnnotation::total(Amount::new(dec!(26.00), "EUR"))),
614            )
615            // simple
616            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-12.34), "USD")));
617
618        let fast = calculate_residual(&txn);
619        let precise = calculate_residual_precise(&txn);
620
621        assert_eq!(
622            fast.len(),
623            precise.len(),
624            "fast {fast:?} and precise {precise:?} cover different currency sets"
625        );
626        for (currency, fval) in &fast {
627            let pval = precise.get(currency).expect("currency present in precise");
628            // Compare via the precise value's string form parsed back to Decimal
629            // (exact for these values) — avoids BigDecimal scale-sensitive `==`.
630            let pval_as_dec = Decimal::from_str(&pval.to_string()).unwrap();
631            assert_eq!(
632                *fval, pval_as_dec,
633                "fast and precise residual disagree for {currency}: {fval} vs {pval}"
634            );
635        }
636    }
637
638    /// Test residual calculation with total cost.
639    /// Buy 10 AAPL with total cost of $1500.
640    #[test]
641    fn test_calculate_residual_with_total_cost() {
642        let txn = Transaction::new(date(2024, 1, 15), "Buy stock")
643            .with_synthesized_posting(
644                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
645                    CostSpec::empty()
646                        .with_number(rustledger_core::CostNumber::Total {
647                            value: dec!(1500.00),
648                        })
649                        .with_currency("USD"),
650                ),
651            )
652            .with_synthesized_posting(Posting::new(
653                "Assets:Cash",
654                Amount::new(dec!(-1500.00), "USD"),
655            ));
656
657        let residual = calculate_residual(&txn);
658        // Total cost posting contributes 1500 * signum(10) = 1500 USD
659        // Cash posting contributes -1500 USD
660        assert_eq!(residual.get("USD"), Some(&dec!(0)));
661    }
662
663    /// Test residual calculation with total cost and negative units (sell).
664    #[test]
665    fn test_calculate_residual_with_total_cost_negative_units() {
666        let txn = Transaction::new(date(2024, 1, 15), "Sell stock")
667            .with_synthesized_posting(
668                Posting::new("Assets:Stock", Amount::new(dec!(-10), "AAPL")).with_cost(
669                    CostSpec::empty()
670                        .with_number(rustledger_core::CostNumber::Total {
671                            value: dec!(1500.00),
672                        })
673                        .with_currency("USD"),
674                ),
675            )
676            .with_synthesized_posting(Posting::new(
677                "Assets:Cash",
678                Amount::new(dec!(1500.00), "USD"),
679            ));
680
681        let residual = calculate_residual(&txn);
682        // Total cost with negative units: 1500 * signum(-10) = -1500 USD
683        // Cash posting contributes +1500 USD
684        assert_eq!(residual.get("USD"), Some(&dec!(0)));
685    }
686
687    /// Test cost spec without amount/currency falls back to units.
688    #[test]
689    fn test_calculate_residual_cost_without_amount_skips() {
690        // When a posting has an empty cost spec (e.g., `{}`) and no price annotation,
691        // it doesn't contribute to the residual because the cost will be determined
692        // by lot matching during booking. This matches Python beancount behavior.
693        let txn = Transaction::new(date(2024, 1, 15), "Test")
694            .with_synthesized_posting(
695                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
696                    .with_cost(CostSpec::empty()), // Empty cost spec - doesn't contribute
697            )
698            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-10), "AAPL")));
699
700        let residual = calculate_residual(&txn);
701        // Empty cost spec posting doesn't contribute, only the second posting does
702        assert_eq!(residual.get("AAPL"), Some(&dec!(-10)));
703    }
704
705    /// Issue #1026: when an empty cost spec is paired with a price
706    /// annotation (`{} @ price`), the residual computation must NOT
707    /// fall through to using the price as the posting's weight. The
708    /// canonical weight of a cost-tracked posting is `units × cost`,
709    /// not `units × price`. Pre-fix, this branch produced a balanced
710    /// residual using the wrong weight; the htsec compat fixture (and
711    /// the interpolate.rs caller chain) was the visible victim.
712    ///
713    /// Pinned here at the lib.rs level so a future revert of the
714    /// branch reordering would fail this test directly, independent
715    /// of the interpolate.rs end-to-end tests.
716    #[test]
717    fn test_calculate_residual_empty_cost_spec_with_price_skips_not_uses_price() {
718        let txn = Transaction::new(date(2024, 1, 15), "Sale, empty cost + price")
719            .with_synthesized_posting(
720                Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
721                    .with_cost(CostSpec::empty())
722                    .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
723                        dec!(150),
724                        "USD",
725                    ))),
726            )
727            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")));
728
729        let residual = calculate_residual(&txn);
730        // Pre-fix: residual[USD] = 0 (price-as-weight contributed
731        // -1500, cancelling cash's +1500).
732        // Post-fix: residual[USD] = +1500 (cost-unknown skipped, only
733        // cash contributes; the residual stays open for booking-pass
734        // lot matching to resolve via cost basis).
735        assert_eq!(residual.get("USD"), Some(&dec!(1500)));
736    }
737
738    /// Companion to the previous test for the `BigDecimal` variant.
739    /// Same fix, same semantics.
740    #[test]
741    fn test_calculate_residual_precise_empty_cost_spec_with_price_skips_not_uses_price() {
742        use bigdecimal::BigDecimal;
743        use std::str::FromStr;
744
745        let txn = Transaction::new(date(2024, 1, 15), "Sale, empty cost + price")
746            .with_synthesized_posting(
747                Posting::new("Assets:Stock", Amount::new(dec!(-10), "HOOL"))
748                    .with_cost(CostSpec::empty())
749                    .with_price(rustledger_core::PriceAnnotation::unit(Amount::new(
750                        dec!(150),
751                        "USD",
752                    ))),
753            )
754            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1500), "USD")));
755
756        let residual = calculate_residual_precise(&txn);
757        assert_eq!(
758            residual.get("USD"),
759            Some(&BigDecimal::from_str("1500").unwrap())
760        );
761    }
762
763    // =========================================================================
764    // Price annotation residual tests
765    // =========================================================================
766
767    /// Test residual with per-unit price annotation (@).
768    /// -100 USD @ 0.85 EUR means we're converting 100 USD to EUR at 0.85 rate.
769    #[test]
770    fn test_calculate_residual_with_unit_price() {
771        let txn = Transaction::new(date(2024, 1, 15), "Currency exchange")
772            .with_synthesized_posting(
773                Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD"))
774                    .with_price(PriceAnnotation::unit(Amount::new(dec!(0.85), "EUR"))),
775            )
776            .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
777
778        let residual = calculate_residual(&txn);
779        // Price posting: |-100| * 0.85 * signum(-100) = -85 EUR
780        // EUR posting: +85 EUR
781        // Total: 0 EUR
782        assert_eq!(residual.get("EUR"), Some(&dec!(0)));
783        // USD should not appear (converted to EUR)
784        assert_eq!(residual.get("USD"), None);
785    }
786
787    /// Test residual with total price annotation (@@).
788    #[test]
789    fn test_calculate_residual_with_total_price() {
790        let txn = Transaction::new(date(2024, 1, 15), "Currency exchange")
791            .with_synthesized_posting(
792                Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD"))
793                    .with_price(PriceAnnotation::total(Amount::new(dec!(85.00), "EUR"))),
794            )
795            .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
796
797        let residual = calculate_residual(&txn);
798        // Total price: 85 * signum(-100) = -85 EUR
799        // EUR posting: +85 EUR
800        assert_eq!(residual.get("EUR"), Some(&dec!(0)));
801    }
802
803    /// Test residual with positive units and unit price.
804    #[test]
805    fn test_calculate_residual_with_unit_price_positive() {
806        let txn = Transaction::new(date(2024, 1, 15), "Buy EUR")
807            .with_synthesized_posting(
808                Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR"))
809                    .with_price(PriceAnnotation::unit(Amount::new(dec!(1.18), "USD"))),
810            )
811            .with_synthesized_posting(Posting::new(
812                "Assets:USD",
813                Amount::new(dec!(-100.30), "USD"),
814            ));
815
816        let residual = calculate_residual(&txn);
817        // Price posting: |85| * 1.18 * signum(85) = 100.30 USD
818        // USD posting: -100.30 USD
819        assert_eq!(residual.get("USD"), Some(&dec!(0)));
820    }
821
822    /// Test `UnitIncomplete` price annotation with complete amount.
823    #[test]
824    fn test_calculate_residual_unit_incomplete_with_amount() {
825        let txn = Transaction::new(date(2024, 1, 15), "Exchange")
826            .with_synthesized_posting(
827                Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD")).with_price(
828                    PriceAnnotation::unit_incomplete(IncompleteAmount::Complete(Amount::new(
829                        dec!(0.85),
830                        "EUR",
831                    ))),
832                ),
833            )
834            .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
835
836        let residual = calculate_residual(&txn);
837        assert_eq!(residual.get("EUR"), Some(&dec!(0)));
838    }
839
840    /// Test `TotalIncomplete` price annotation with complete amount.
841    #[test]
842    fn test_calculate_residual_total_incomplete_with_amount() {
843        let txn = Transaction::new(date(2024, 1, 15), "Exchange")
844            .with_synthesized_posting(
845                Posting::new("Assets:USD", Amount::new(dec!(-100.00), "USD")).with_price(
846                    PriceAnnotation::total_incomplete(IncompleteAmount::Complete(Amount::new(
847                        dec!(85.00),
848                        "EUR",
849                    ))),
850                ),
851            )
852            .with_synthesized_posting(Posting::new("Assets:EUR", Amount::new(dec!(85.00), "EUR")));
853
854        let residual = calculate_residual(&txn);
855        assert_eq!(residual.get("EUR"), Some(&dec!(0)));
856    }
857
858    /// Test `UnitIncomplete` without amount falls back to units.
859    #[test]
860    fn test_calculate_residual_unit_incomplete_no_amount_fallback() {
861        let txn = Transaction::new(date(2024, 1, 15), "Test")
862            .with_synthesized_posting(
863                Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD")).with_price(
864                    PriceAnnotation::unit_incomplete(IncompleteAmount::NumberOnly(dec!(0.85))),
865                ),
866            )
867            .with_synthesized_posting(Posting::new(
868                "Assets:USD",
869                Amount::new(dec!(-100.00), "USD"),
870            ));
871
872        let residual = calculate_residual(&txn);
873        // Falls back to units since no currency in incomplete amount
874        assert_eq!(residual.get("USD"), Some(&dec!(0)));
875    }
876
877    /// Test `TotalIncomplete` without amount falls back to units.
878    #[test]
879    fn test_calculate_residual_total_incomplete_no_amount_fallback() {
880        let txn = Transaction::new(date(2024, 1, 15), "Test")
881            .with_synthesized_posting(
882                Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD")).with_price(
883                    PriceAnnotation::total_incomplete(IncompleteAmount::NumberOnly(dec!(85.00))),
884                ),
885            )
886            .with_synthesized_posting(Posting::new(
887                "Assets:USD",
888                Amount::new(dec!(-100.00), "USD"),
889            ));
890
891        let residual = calculate_residual(&txn);
892        assert_eq!(residual.get("USD"), Some(&dec!(0)));
893    }
894
895    /// Test `UnitEmpty` price annotation falls back to units.
896    #[test]
897    fn test_calculate_residual_unit_empty_fallback() {
898        let txn = Transaction::new(date(2024, 1, 15), "Test")
899            .with_synthesized_posting(
900                Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD"))
901                    .with_price(PriceAnnotation::unit_empty()),
902            )
903            .with_synthesized_posting(Posting::new(
904                "Assets:USD",
905                Amount::new(dec!(-100.00), "USD"),
906            ));
907
908        let residual = calculate_residual(&txn);
909        // Falls back to units
910        assert_eq!(residual.get("USD"), Some(&dec!(0)));
911    }
912
913    /// Test `TotalEmpty` price annotation falls back to units.
914    #[test]
915    fn test_calculate_residual_total_empty_fallback() {
916        let txn = Transaction::new(date(2024, 1, 15), "Test")
917            .with_synthesized_posting(
918                Posting::new("Assets:USD", Amount::new(dec!(100.00), "USD"))
919                    .with_price(PriceAnnotation::total_empty()),
920            )
921            .with_synthesized_posting(Posting::new(
922                "Assets:USD",
923                Amount::new(dec!(-100.00), "USD"),
924            ));
925
926        let residual = calculate_residual(&txn);
927        assert_eq!(residual.get("USD"), Some(&dec!(0)));
928    }
929
930    // =========================================================================
931    // Mixed and edge case tests
932    // =========================================================================
933
934    /// Test transaction with both cost and regular postings.
935    #[test]
936    fn test_calculate_residual_mixed_cost_and_simple() {
937        let txn = Transaction::new(date(2024, 1, 15), "Buy with fee")
938            .with_synthesized_posting(
939                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
940                    CostSpec::empty()
941                        .with_number(rustledger_core::CostNumber::PerUnit {
942                            value: dec!(150.00),
943                        })
944                        .with_currency("USD"),
945                ),
946            )
947            .with_synthesized_posting(Posting::new(
948                "Expenses:Fees",
949                Amount::new(dec!(10.00), "USD"),
950            ))
951            .with_synthesized_posting(Posting::new(
952                "Assets:Cash",
953                Amount::new(dec!(-1510.00), "USD"),
954            ));
955
956        let residual = calculate_residual(&txn);
957        // 10 * 150 + 10 - 1510 = 0
958        assert_eq!(residual.get("USD"), Some(&dec!(0)));
959    }
960
961    /// Test sell with cost basis and capital gains.
962    #[test]
963    fn test_calculate_residual_sell_with_gains() {
964        let txn = Transaction::new(date(2024, 6, 15), "Sell stock")
965            .with_synthesized_posting(
966                Posting::new("Assets:Stock", Amount::new(dec!(-10), "AAPL"))
967                    .with_cost(
968                        CostSpec::empty()
969                            .with_number(rustledger_core::CostNumber::PerUnit {
970                                value: dec!(150.00),
971                            })
972                            .with_currency("USD"),
973                    )
974                    .with_price(PriceAnnotation::unit(Amount::new(dec!(175.00), "USD"))),
975            )
976            .with_synthesized_posting(Posting::new(
977                "Assets:Cash",
978                Amount::new(dec!(1750.00), "USD"),
979            ))
980            .with_synthesized_posting(Posting::new(
981                "Income:CapitalGains",
982                Amount::new(dec!(-250.00), "USD"),
983            ));
984
985        let residual = calculate_residual(&txn);
986        // Stock posting with cost: -10 * 150 = -1500 USD (cost takes precedence)
987        // Cash: +1750 USD
988        // Gains: -250 USD
989        // Total: -1500 + 1750 - 250 = 0
990        assert_eq!(residual.get("USD"), Some(&dec!(0)));
991    }
992
993    /// Test multi-currency transaction with costs.
994    #[test]
995    fn test_calculate_residual_multi_currency_with_cost() {
996        let txn = Transaction::new(date(2024, 1, 15), "Multi-currency")
997            .with_synthesized_posting(
998                Posting::new("Assets:Stock:US", Amount::new(dec!(10), "AAPL")).with_cost(
999                    CostSpec::empty()
1000                        .with_number(rustledger_core::CostNumber::PerUnit {
1001                            value: dec!(150.00),
1002                        })
1003                        .with_currency("USD"),
1004                ),
1005            )
1006            .with_synthesized_posting(
1007                Posting::new("Assets:Stock:EU", Amount::new(dec!(5), "SAP")).with_cost(
1008                    CostSpec::empty()
1009                        .with_number(rustledger_core::CostNumber::PerUnit {
1010                            value: dec!(100.00),
1011                        })
1012                        .with_currency("EUR"),
1013                ),
1014            )
1015            .with_synthesized_posting(Posting::new(
1016                "Assets:Cash:USD",
1017                Amount::new(dec!(-1500.00), "USD"),
1018            ))
1019            .with_synthesized_posting(Posting::new(
1020                "Assets:Cash:EUR",
1021                Amount::new(dec!(-500.00), "EUR"),
1022            ));
1023
1024        let residual = calculate_residual(&txn);
1025        assert_eq!(residual.get("USD"), Some(&dec!(0)));
1026        assert_eq!(residual.get("EUR"), Some(&dec!(0)));
1027    }
1028
1029    /// Test that incomplete units (auto postings) are skipped.
1030    #[test]
1031    fn test_calculate_residual_skips_incomplete_units() {
1032        let txn = Transaction::new(date(2024, 1, 15), "Test")
1033            .with_synthesized_posting(Posting::new(
1034                "Expenses:Food",
1035                Amount::new(dec!(50.00), "USD"),
1036            ))
1037            .with_synthesized_posting(Posting::auto("Assets:Cash")); // No units
1038
1039        let residual = calculate_residual(&txn);
1040        // Only the complete posting is counted
1041        assert_eq!(residual.get("USD"), Some(&dec!(50.00)));
1042    }
1043
1044    // =========================================================================
1045    // Cost currency inference tests (issue #203)
1046    // =========================================================================
1047
1048    /// Test cost currency is inferred from other postings.
1049    /// This is the exact case from issue #203.
1050    #[test]
1051    fn test_calculate_residual_infers_cost_currency_from_other_posting() {
1052        // 2026-01-01 * "Opening balance"
1053        //   Assets:Vanguard:IRA:Trad:VFIFX  10 VFIFX {100}
1054        //   Equity:Opening-Balances      -1000 USD
1055        //
1056        // Python beancount infers the cost currency as USD from the second posting.
1057        let txn = Transaction::new(date(2026, 1, 1), "Opening balance")
1058            .with_synthesized_posting(
1059                Posting::new(
1060                    "Assets:Vanguard:IRA:Trad:VFIFX",
1061                    Amount::new(dec!(10), "VFIFX"),
1062                )
1063                .with_cost(
1064                    CostSpec::empty()
1065                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) }),
1066                ),
1067            )
1068            .with_synthesized_posting(Posting::new(
1069                "Equity:Opening-Balances",
1070                Amount::new(dec!(-1000), "USD"),
1071            ));
1072
1073        let residual = calculate_residual(&txn);
1074        // Cost posting should contribute 10 * 100 = 1000 USD (inferred from other posting)
1075        // Equity posting contributes -1000 USD
1076        // Residual should be 0
1077        assert_eq!(
1078            residual.get("USD"),
1079            Some(&dec!(0)),
1080            "Should balance when cost currency is inferred from other posting"
1081        );
1082        // VFIFX should not appear in residuals
1083        assert_eq!(residual.get("VFIFX"), None);
1084    }
1085
1086    /// Test cost currency inference with total cost.
1087    #[test]
1088    fn test_calculate_residual_infers_cost_currency_total_cost() {
1089        // 10 VFIFX {{1000}} with -1000 USD posting
1090        let txn = Transaction::new(date(2026, 1, 1), "Test")
1091            .with_synthesized_posting(
1092                Posting::new("Assets:Stock", Amount::new(dec!(10), "VFIFX")).with_cost(
1093                    CostSpec::empty()
1094                        .with_number(rustledger_core::CostNumber::Total { value: dec!(1000) }),
1095                ),
1096            )
1097            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1000), "USD")));
1098
1099        let residual = calculate_residual(&txn);
1100        assert_eq!(residual.get("USD"), Some(&dec!(0)));
1101    }
1102
1103    /// Test that explicit cost currency takes precedence over inference.
1104    #[test]
1105    fn test_calculate_residual_explicit_cost_currency_takes_precedence() {
1106        // If cost has explicit currency, don't infer from other postings
1107        let txn = Transaction::new(date(2026, 1, 1), "Test")
1108            .with_synthesized_posting(
1109                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1110                    CostSpec::empty()
1111                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) })
1112                        .with_currency("EUR"), // Explicit EUR
1113                ),
1114            )
1115            .with_synthesized_posting(Posting::new(
1116                "Assets:Cash",
1117                Amount::new(dec!(-1000), "USD"), // USD posting
1118            ));
1119
1120        let residual = calculate_residual(&txn);
1121        // Should use EUR (explicit) not USD (from other posting)
1122        assert_eq!(residual.get("EUR"), Some(&dec!(1000)));
1123        assert_eq!(residual.get("USD"), Some(&dec!(-1000)));
1124    }
1125
1126    /// Test that price annotation takes precedence over other posting inference.
1127    #[test]
1128    fn test_calculate_residual_price_annotation_takes_precedence() {
1129        // If cost has price annotation, use that currency
1130        let txn = Transaction::new(date(2026, 1, 1), "Test")
1131            .with_synthesized_posting(
1132                Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1133                    .with_cost(
1134                        CostSpec::empty()
1135                            .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) }),
1136                    )
1137                    .with_price(PriceAnnotation::unit(Amount::new(dec!(105), "EUR"))),
1138            )
1139            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1000), "USD")));
1140
1141        let residual = calculate_residual(&txn);
1142        // Should use EUR (from price annotation) not USD (from other posting)
1143        assert_eq!(residual.get("EUR"), Some(&dec!(1000)));
1144        assert_eq!(residual.get("USD"), Some(&dec!(-1000)));
1145    }
1146
1147    // =========================================================================
1148    // infer_cost_currency_from_postings tests
1149    // =========================================================================
1150
1151    /// Test that cost spec currency is used as fallback when no simple postings exist.
1152    #[test]
1153    fn test_infer_cost_currency_from_cost_spec() {
1154        // Transaction with only cost-spec posting - should get currency from cost spec
1155        let txn = Transaction::new(date(2022, 4, 16), "Free tokens")
1156            .with_synthesized_posting(
1157                Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
1158                    CostSpec::empty()
1159                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
1160                        .with_currency("USD"),
1161                ),
1162            )
1163            .with_synthesized_posting(Posting::auto("Income:Bonus"));
1164
1165        let inferred = infer_cost_currency_from_postings(&txn);
1166        assert_eq!(inferred.as_deref(), Some("USD"));
1167    }
1168
1169    /// Test that simple posting currency takes precedence over cost spec currency.
1170    #[test]
1171    fn test_infer_cost_currency_simple_takes_precedence() {
1172        // Transaction with both simple posting and cost spec - simple should win
1173        let txn = Transaction::new(date(2022, 4, 16), "Trade")
1174            .with_synthesized_posting(
1175                Posting::new("Assets:Crypto", Amount::new(dec!(100), "TOKEN")).with_cost(
1176                    CostSpec::empty()
1177                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(10) })
1178                        .with_currency("EUR"),
1179                ),
1180            )
1181            .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1000), "USD")));
1182
1183        let inferred = infer_cost_currency_from_postings(&txn);
1184        // Should get USD from the simple posting, not EUR from cost spec
1185        assert_eq!(inferred.as_deref(), Some("USD"));
1186    }
1187
1188    /// Test that zero-cost spec currency is still used for inference.
1189    #[test]
1190    fn test_infer_cost_currency_zero_cost() {
1191        // Zero cost should still provide the currency
1192        let txn = Transaction::new(date(2022, 4, 16), "Airdrop")
1193            .with_synthesized_posting(
1194                Posting::new("Assets:Crypto", Amount::new(dec!(1000), "SHIB")).with_cost(
1195                    CostSpec::empty()
1196                        .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
1197                        .with_currency("JPY"),
1198                ),
1199            )
1200            .with_synthesized_posting(Posting::auto("Income:Airdrop"));
1201
1202        let inferred = infer_cost_currency_from_postings(&txn);
1203        assert_eq!(inferred.as_deref(), Some("JPY"));
1204    }
1205}