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