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