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