Skip to main content

rustledger_core/inventory/
mod.rs

1//! Inventory type representing a collection of positions.
2//!
3//! An [`Inventory`] tracks the holdings of an account as a collection of
4//! [`Position`]s. It provides methods for adding and reducing positions
5//! using different booking methods (FIFO, LIFO, STRICT, NONE).
6
7// ratchet: fxhash-only — hot path; use FxHashMap/FxHashSet, not std SipHash collections (#1237).
8use imbl::Vector;
9use rust_decimal::Decimal;
10use rustc_hash::FxHashMap;
11use serde::{Deserialize, Serialize};
12use smallvec::SmallVec;
13use std::fmt;
14use std::str::FromStr;
15
16use crate::{Account, Amount, CostSpec, Currency, Position, is_subaccount_or_equal};
17
18/// Inline storage for `BookingResult::matched`.
19///
20/// STRICT booking (the default) always produces exactly one matched lot
21/// per posting; FIFO / LIFO frequently match a single lot too. Inline
22/// cap of 1 covers the hot case with zero heap allocation while still
23/// spilling to the heap for multi-lot matches.
24///
25/// **API surface note**: this is `pub(crate)` deliberately — we don't
26/// want to commit downstream consumers to `smallvec` as part of our
27/// public API contract. External code reads `BookingResult.matched` via
28/// the slice deref (`.iter()`, `.len()`, indexing) which works
29/// transparently. The concrete `SmallVec<[Position; 1]>` type is still
30/// reachable via the field type but isn't promoted into the crate root.
31pub(crate) type MatchedLots = SmallVec<[Position; 1]>;
32
33mod booking;
34
35/// Booking method determines how lots are matched when reducing positions.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
37#[cfg_attr(
38    feature = "rkyv",
39    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
40)]
41pub enum BookingMethod {
42    /// Lots must match exactly (unambiguous).
43    /// If multiple lots match the cost spec, an error is raised.
44    #[default]
45    Strict,
46    /// Like STRICT, but exact-size matches accept oldest lot.
47    /// If reduction amount equals total inventory, it's considered unambiguous.
48    StrictWithSize,
49    /// First In, First Out. Oldest lots are reduced first.
50    Fifo,
51    /// Last In, First Out. Newest lots are reduced first.
52    Lifo,
53    /// Highest In, First Out. Highest-cost lots are reduced first.
54    Hifo,
55    /// Average cost booking. All lots of a currency are merged.
56    Average,
57    /// No cost tracking. Units are reduced without matching lots.
58    None,
59}
60
61impl FromStr for BookingMethod {
62    type Err = String;
63
64    fn from_str(s: &str) -> Result<Self, Self::Err> {
65        match s.to_uppercase().as_str() {
66            "STRICT" => Ok(Self::Strict),
67            "STRICT_WITH_SIZE" => Ok(Self::StrictWithSize),
68            "FIFO" => Ok(Self::Fifo),
69            "LIFO" => Ok(Self::Lifo),
70            "HIFO" => Ok(Self::Hifo),
71            "AVERAGE" => Ok(Self::Average),
72            "NONE" => Ok(Self::None),
73            _ => Err(format!("unknown booking method: {s}")),
74        }
75    }
76}
77
78impl fmt::Display for BookingMethod {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            Self::Strict => write!(f, "STRICT"),
82            Self::StrictWithSize => write!(f, "STRICT_WITH_SIZE"),
83            Self::Fifo => write!(f, "FIFO"),
84            Self::Lifo => write!(f, "LIFO"),
85            Self::Hifo => write!(f, "HIFO"),
86            Self::Average => write!(f, "AVERAGE"),
87            Self::None => write!(f, "NONE"),
88        }
89    }
90}
91
92/// Controls which positions are considered when checking whether incoming
93/// units reduce (i.e. have the opposite sign of) an existing inventory.
94///
95/// - [`AllPositions`](ReductionScope::AllPositions): every position is
96///   considered, regardless of whether it carries a cost.
97/// - [`CostBearingOnly`](ReductionScope::CostBearingOnly): only positions
98///   with a cost are considered.  This prevents a negative simple (no-cost)
99///   position — left behind by a sell-without-cost-spec — from causing a
100///   subsequent cost-bearing augmentation to be misclassified as a reduction.
101///   See: issue #875, beancount#889.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103pub enum ReductionScope {
104    /// Consider all positions (cost-bearing and simple).
105    AllPositions,
106    /// Consider only positions that carry a cost.
107    CostBearingOnly,
108}
109
110/// Result of a booking operation.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct BookingResult {
113    /// Positions that were matched/reduced.
114    ///
115    /// Backed by [`SmallVec<[Position; 1]>`](smallvec::SmallVec) so the
116    /// single-match common case (always true under STRICT, common under
117    /// FIFO/LIFO) doesn't touch the heap. The concrete type derefs to
118    /// `[Position]`, so read-side patterns like `.iter()`,
119    /// `.len()`, `.is_empty()`, and indexing work unchanged.
120    ///
121    /// **Breaking API change in 0.15.0**: prior versions used
122    /// `Vec<Position>`. Downstream code that named the type explicitly
123    /// (`let v: Vec<Position> = result.matched`) or called Vec-specific
124    /// methods (`.capacity()`, `.reserve()`) needs to adapt; reading
125    /// the field through the slice deref keeps working.
126    pub matched: MatchedLots,
127    /// The cost basis of the matched positions (for capital gains).
128    pub cost_basis: Option<Amount>,
129}
130
131/// Error that can occur during booking.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum BookingError {
134    /// Multiple lots match but booking method requires unambiguous match.
135    AmbiguousMatch {
136        /// Number of lots that matched.
137        num_matches: usize,
138        /// The currency being reduced.
139        currency: crate::Currency,
140    },
141    /// No lots match the cost specification.
142    NoMatchingLot {
143        /// The currency being reduced.
144        currency: crate::Currency,
145        /// The cost spec that didn't match.
146        cost_spec: CostSpec,
147    },
148    /// Not enough units in matching lots.
149    InsufficientUnits {
150        /// The currency being reduced.
151        currency: crate::Currency,
152        /// Units requested.
153        requested: Decimal,
154        /// Units available.
155        available: Decimal,
156    },
157    /// Currency mismatch between reduction and inventory.
158    CurrencyMismatch {
159        /// Expected currency.
160        expected: crate::Currency,
161        /// Got currency.
162        got: crate::Currency,
163    },
164}
165
166impl fmt::Display for BookingError {
167    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168        match self {
169            Self::AmbiguousMatch {
170                num_matches,
171                currency,
172            } => write!(
173                f,
174                "Ambiguous match: {num_matches} lots match for {currency}"
175            ),
176            Self::NoMatchingLot {
177                currency,
178                cost_spec,
179            } => {
180                write!(f, "No matching lot for {currency} with cost {cost_spec}")
181            }
182            Self::InsufficientUnits {
183                currency,
184                requested,
185                available,
186            } => write!(
187                f,
188                "Insufficient units of {currency}: requested {requested}, available {available}"
189            ),
190            Self::CurrencyMismatch { expected, got } => {
191                write!(f, "Currency mismatch: expected {expected}, got {got}")
192            }
193        }
194    }
195}
196
197impl std::error::Error for BookingError {}
198
199impl BookingError {
200    /// Wrap this booking error with the account context that produced it.
201    ///
202    /// `Inventory` itself doesn't know which account it belongs to, so the
203    /// raw `BookingError` carries no `account` field. The caller (booking
204    /// engine, validator) knows the account and uses this constructor to
205    /// produce the user-facing error.
206    ///
207    /// The resulting [`AccountedBookingError`] is the **single canonical
208    /// rendering** of an inventory failure for user-facing output. Both the
209    /// booking layer and the validator format errors via this type so the
210    /// wording cannot drift between them — the failure mode that produced
211    /// #748.
212    #[must_use]
213    pub const fn with_account(self, account: crate::Account) -> AccountedBookingError {
214        AccountedBookingError {
215            error: self,
216            account,
217        }
218    }
219}
220
221/// A [`BookingError`] paired with the account that produced it.
222///
223/// This is the canonical user-facing inventory error type. Its `Display`
224/// impl is the **single source of truth** for booking-error wording across
225/// `rustledger-booking` and `rustledger-validate`. Conformance assertions
226/// (e.g. pta-standards `reduction-exceeds-inventory` requires the literal
227/// substring `"not enough"`) are pinned by this Display.
228///
229/// Construct via [`BookingError::with_account`].
230#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct AccountedBookingError {
232    /// The underlying inventory-level error.
233    pub error: BookingError,
234    /// The account whose inventory produced the error.
235    pub account: crate::Account,
236}
237
238impl fmt::Display for AccountedBookingError {
239    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240        match &self.error {
241            BookingError::InsufficientUnits {
242                requested,
243                available,
244                ..
245            } => write!(
246                f,
247                "Not enough units in {}: requested {}, available {}; not enough to reduce",
248                self.account, requested, available
249            ),
250            BookingError::NoMatchingLot { currency, .. } => {
251                write!(f, "No matching lot for {} in {}", currency, self.account)
252            }
253            BookingError::AmbiguousMatch {
254                num_matches,
255                currency,
256            } => write!(
257                f,
258                "Ambiguous lot match for {}: {} lots match in {}",
259                currency, num_matches, self.account
260            ),
261            // Currency mismatch is semantically a specialization of
262            // NoMatchingLot (there is no lot for the given currency in this
263            // inventory), so we render and classify it the same way. Consumers
264            // filtering on E4001 don't need to special-case CurrencyMismatch.
265            //
266            // This variant is defensive: no `Inventory::reduce` path in
267            // `rustledger-core` currently emits it, but we still render it
268            // consistently in case a future emission site is added.
269            BookingError::CurrencyMismatch { got, .. } => {
270                write!(f, "No matching lot for {} in {}", got, self.account)
271            }
272        }
273    }
274}
275
276impl std::error::Error for AccountedBookingError {}
277
278/// An inventory is a collection of positions.
279///
280/// It tracks all positions for an account and supports booking operations
281/// for adding and reducing positions.
282///
283/// # Examples
284///
285/// ```
286/// use rustledger_core::{Inventory, Position, Amount, Cost, BookingMethod};
287/// use rust_decimal_macros::dec;
288///
289/// let mut inv = Inventory::new();
290///
291/// // Add a simple position
292/// inv.add(Position::simple(Amount::new(dec!(100), "USD")));
293/// assert_eq!(inv.units("USD"), dec!(100));
294///
295/// // Add a position with cost
296/// let cost = Cost::new(dec!(150.00), "USD");
297/// inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
298/// assert_eq!(inv.units("AAPL"), dec!(10));
299/// ```
300#[derive(Debug, Clone, Default, Serialize, Deserialize)]
301pub struct Inventory {
302    /// Persistent (structurally-shared) RRB-tree-backed vector. Cloning
303    /// is O(1) (Arc bump on the tree root); `push_back` / indexed mutation
304    /// are O(log N) per op but share structure with previous versions.
305    /// This is the critical property for JOURNAL-style row-per-snapshot
306    /// patterns in BQL (issue #1086): N nested snapshots cost O(base + Σ
307    /// deltas) memory instead of O(N · base), and the per-row clone cost
308    /// drops from O(positions) to O(1).
309    ///
310    /// The trade is real: booking and BQL aggregator mutations pay an
311    /// O(log N) tree walk vs `Vec`'s amortized O(1) push. Measured impact
312    /// scales with inventory size M: +85 ns/op at M=10, +1.6 µs/op at
313    /// M=100, +19 µs/op at M=500 (criterion `reduce_fifo/*`). For typical
314    /// small-M ledgers the overhead is sub-millisecond per `rledger
315    /// check`; the users who feel it are users with very large inventories,
316    /// the same users who hit the JOURNAL OOM today.
317    ///
318    /// `rkyv` derives were dropped because (a) `imbl::Vector` has no `rkyv`
319    /// impl and (b) no code path currently archives an `Inventory`
320    /// (confirmed in the `SmallVec` experiment for #1069). Pre-1.0 break;
321    /// downstream callers archiving `Inventory` directly will need to
322    /// archive `Vec<Position>` themselves. Serde wire format is unchanged
323    /// (sequence-typed, identical for both backings).
324    positions: Vector<Position>,
325    /// Index for O(1) lookup of simple positions (no cost) by currency.
326    /// Maps currency to position index in the `positions` vector.
327    /// Not serialized - rebuilt on demand.
328    #[serde(skip)]
329    simple_index: FxHashMap<crate::Currency, usize>,
330    /// Cache of total units per currency for O(1) `units()` lookups.
331    /// Updated incrementally on `add()` and `reduce()`.
332    /// Not serialized - rebuilt on demand.
333    #[serde(skip)]
334    units_cache: FxHashMap<crate::Currency, Decimal>,
335}
336
337impl PartialEq for Inventory {
338    fn eq(&self, other: &Self) -> bool {
339        // Only compare positions, not the index (which is derived data)
340        self.positions == other.positions
341    }
342}
343
344impl Eq for Inventory {}
345
346impl Inventory {
347    /// Create an empty inventory.
348    #[must_use]
349    pub fn new() -> Self {
350        Self::default()
351    }
352
353    /// Iterate over all positions.
354    ///
355    /// Previously returned `&[Position]`; now returns an iterator
356    /// because the underlying storage is a tree-based persistent
357    /// vector (`imbl::Vector`) that doesn't expose a contiguous slice.
358    /// Most callers already iterate — for callers that need
359    /// random-access / indexed / `.len()` slice semantics, see
360    /// [`Self::position_list`].
361    pub fn positions(&self) -> impl Iterator<Item = &Position> + '_ {
362        self.positions.iter()
363    }
364
365    /// Materialize all positions as a `Vec<&Position>` for slice-style
366    /// access (indexing, `.len()`, `.first()`, `.is_empty()`).
367    ///
368    /// Allocates `O(N)` pointers per call. Callers that only iterate
369    /// once should use [`Self::positions`] instead — this is for code
370    /// paths that need slice semantics.
371    #[must_use]
372    pub fn position_list(&self) -> Vec<&Position> {
373        self.positions.iter().collect()
374    }
375
376    /// Get mutable access to the underlying positions vector.
377    ///
378    /// Returns `&mut imbl::Vector<Position>` (was `&mut Vec<Position>`
379    /// before issue #1086). `imbl::Vector` supports the same surface
380    /// for `push_back`, `pop_back`, `retain`, indexed access, and
381    /// iteration — but mutations are O(log N) with structural sharing
382    /// instead of O(1) amortized.
383    pub const fn positions_mut(&mut self) -> &mut Vector<Position> {
384        &mut self.positions
385    }
386
387    /// Check if inventory is empty.
388    #[must_use]
389    pub fn is_empty(&self) -> bool {
390        self.positions.is_empty()
391            || self
392                .positions
393                .iter()
394                .all(super::position::Position::is_empty)
395    }
396
397    /// Get the number of positions (including empty ones).
398    #[must_use]
399    pub fn len(&self) -> usize {
400        self.positions.len()
401    }
402
403    /// Get total units of a currency (ignoring cost lots).
404    ///
405    /// This sums all positions of the given currency regardless of cost basis.
406    /// Uses an internal cache for O(1) lookups.
407    #[must_use]
408    pub fn units(&self, currency: &str) -> Decimal {
409        // Use cache if available, otherwise compute and the caller should
410        // ensure cache is built via rebuild_caches() after deserialization
411        self.units_cache.get(currency).copied().unwrap_or_else(|| {
412            // Fallback to computation if cache miss (e.g., after deserialization)
413            self.positions
414                .iter()
415                .filter(|p| p.units.currency == currency)
416                .map(|p| p.units.number)
417                .sum()
418        })
419    }
420
421    /// Get all currencies in this inventory.
422    #[must_use]
423    pub fn currencies(&self) -> Vec<&str> {
424        let mut currencies: Vec<&str> = self
425            .positions
426            .iter()
427            .filter(|p| !p.is_empty())
428            .map(|p| p.units.currency.as_str())
429            .collect();
430        currencies.sort_unstable();
431        currencies.dedup();
432        currencies
433    }
434
435    /// Check if the given units would reduce (not augment) this inventory.
436    ///
437    /// Returns `true` if there's a position with the same currency but opposite
438    /// sign, meaning these units would reduce the inventory rather than add to it.
439    ///
440    /// When `has_cost_spec` is `true`, only positions **with** a cost basis are
441    /// considered for reduction matching.  Simple (no-cost) positions are ignored
442    /// because they live in a different "cost layer" — a sell-without-cost-spec
443    /// that left a negative simple position should not cause a subsequent
444    /// cost-bearing augmentation to be misclassified as a reduction.
445    /// See: issue #875, beancount#889.
446    ///
447    /// This is used to determine whether a posting is a sale/reduction or a
448    /// purchase/augmentation.
449    #[must_use]
450    pub fn is_reduced_by(&self, units: &Amount, scope: ReductionScope) -> bool {
451        self.positions.iter().any(|pos| {
452            pos.units.currency == units.currency
453                && pos.units.number.is_sign_positive() != units.number.is_sign_positive()
454                && match scope {
455                    ReductionScope::AllPositions => true,
456                    ReductionScope::CostBearingOnly => pos.cost.is_some(),
457                }
458        })
459    }
460
461    /// Whether a posting of `units` carrying `cost` would REDUCE this inventory
462    /// under `method` — the single source for the reduction-vs-augmentation
463    /// decision shared by the booking engine (`BookingEngine::apply`) and the
464    /// Late validator's inventory pass.
465    ///
466    /// A posting reduces only when it carries a cost spec (`cost.is_some()` —
467    /// presence of the spec, which includes an empty/unresolved one like `{}`),
468    /// the booking method isn't `NONE` (issue #1182 — `NONE` accumulates every
469    /// posting as an augmentation, with no lot matching), and the inventory holds
470    /// a cost-bearing position of the opposite sign in the same currency
471    /// ([`Self::is_reduced_by`] with [`ReductionScope::CostBearingOnly`]). This
472    /// gate was previously written byte-for-byte in both crates and the #1182 fix
473    /// had to be applied twice.
474    #[must_use]
475    pub fn is_booking_reduction(
476        &self,
477        units: &Amount,
478        cost: Option<&CostSpec>,
479        method: BookingMethod,
480    ) -> bool {
481        method != BookingMethod::None
482            && cost.is_some()
483            && self.is_reduced_by(units, ReductionScope::CostBearingOnly)
484    }
485
486    /// Get the total book value (cost basis) for a currency.
487    ///
488    /// Returns the sum of all cost bases for positions of the given currency.
489    #[must_use]
490    pub fn book_value(&self, units_currency: &str) -> FxHashMap<crate::Currency, Decimal> {
491        let mut totals: FxHashMap<crate::Currency, Decimal> = FxHashMap::default();
492
493        for pos in &self.positions {
494            if pos.units.currency == units_currency
495                && let Some(book) = pos.book_value()
496            {
497                *totals.entry(book.currency.clone()).or_default() += book.number;
498            }
499        }
500
501        totals
502    }
503
504    /// Add a position to the inventory.
505    ///
506    /// For positions without cost, this merges with existing positions
507    /// of the same currency using O(1) `HashMap` lookup.
508    ///
509    /// For positions with cost, this adds as a new lot (O(1)).
510    /// Lot aggregation for display purposes is handled separately at output time
511    /// (e.g., in the query result formatter).
512    ///
513    /// # TLA+ Specification
514    ///
515    /// Implements `AddAmount` action from `Conservation.tla`:
516    /// - Invariant: `inventory + totalReduced = totalAdded`
517    /// - After add: `totalAdded' = totalAdded + amount`
518    ///
519    /// See: `spec/tla/Conservation.tla`
520    pub fn add(&mut self, position: Position) {
521        if position.is_empty() {
522            return;
523        }
524
525        // Update units cache
526        *self
527            .units_cache
528            .entry(position.units.currency.clone())
529            .or_default() += position.units.number;
530
531        // For positions without cost, use index for O(1) lookup
532        if position.cost.is_none() {
533            if let Some(&idx) = self.simple_index.get(&position.units.currency) {
534                // Merge with existing position
535                debug_assert!(self.positions[idx].cost.is_none());
536                self.positions[idx].units += &position.units;
537                return;
538            }
539            // No existing position - add new one and index it
540            let idx = self.positions.len();
541            self.simple_index
542                .insert(position.units.currency.clone(), idx);
543            self.positions.push_back(position);
544            return;
545        }
546
547        // For positions with cost, just add as a new lot.
548        // This is O(1) and keeps all lots separate, matching Python beancount behavior.
549        // Lot aggregation for display purposes is handled separately in query output.
550        self.positions.push_back(position);
551    }
552
553    /// Reduce positions from the inventory using the specified booking method.
554    ///
555    /// # Arguments
556    ///
557    /// * `units` - The units to reduce (negative for selling)
558    /// * `cost_spec` - Optional cost specification for matching lots
559    /// * `method` - The booking method to use
560    ///
561    /// # Returns
562    ///
563    /// Returns a `BookingResult` with the matched positions and cost basis,
564    /// or a `BookingError` if the reduction cannot be performed.
565    ///
566    /// # TLA+ Specification
567    ///
568    /// Implements `ReduceAmount` action from `Conservation.tla`:
569    /// - Invariant: `inventory + totalReduced = totalAdded`
570    /// - After reduce: `totalReduced' = totalReduced + amount`
571    /// - Precondition: `amount <= inventory` (else `InsufficientUnits` error)
572    ///
573    /// Lot selection follows these TLA+ specs based on `method`:
574    /// - `Fifo`: `FIFOCorrect.tla` - Oldest lots first (`selected_date <= all other dates`)
575    /// - `Lifo`: `LIFOCorrect.tla` - Newest lots first (`selected_date >= all other dates`)
576    /// - `Hifo`: `HIFOCorrect.tla` - Highest cost first (`selected_cost >= all other costs`)
577    ///
578    /// See: `spec/tla/Conservation.tla`, `spec/tla/FIFOCorrect.tla`, etc.
579    pub fn reduce(
580        &mut self,
581        units: &Amount,
582        cost_spec: Option<&CostSpec>,
583        method: BookingMethod,
584    ) -> Result<BookingResult, BookingError> {
585        let spec = cost_spec.cloned().unwrap_or_default();
586
587        // Force a uniquely-owned positions Vector before any reduction mutates
588        // it. `self.positions` is often structurally shared — the booking engine
589        // clones an account's inventory into a working copy via imbl's O(1)
590        // `clone` — and every reduction method below mutates it in place (via
591        // `IndexMut` / `retain`). Mutating a SHARED imbl `Vector` in place drives
592        // `imbl-sized-chunks`' copy-on-write into a use-after-free of the
593        // interned `Arc<str>` inside `Position` — heap corruption / SIGSEGV on
594        // large ledgers with many lot reductions (found by the rich-workload
595        // profiler). Rebuilding from cloned positions restores a refcount-1
596        // Vector with correct `Arc` refcounting, so in-place mutation below has
597        // no shared chunk to corrupt.
598        self.positions = self.positions.iter().cloned().collect();
599
600        // {*} merge operator: merge all lots into a single weighted-average-cost
601        // lot before reducing, regardless of the account's booking method.
602        if spec.merge {
603            return self.reduce_merge(units);
604        }
605
606        match method {
607            BookingMethod::Strict => self.reduce_strict(units, &spec),
608            BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
609            BookingMethod::Fifo => self.reduce_fifo(units, &spec),
610            BookingMethod::Lifo => self.reduce_lifo(units, &spec),
611            BookingMethod::Hifo => self.reduce_hifo(units, &spec),
612            BookingMethod::Average => self.reduce_average(units),
613            BookingMethod::None => self.reduce_none(units),
614        }
615    }
616
617    /// Remove all empty positions.
618    pub fn compact(&mut self) {
619        self.positions.retain(|p| !p.is_empty());
620        self.rebuild_index();
621    }
622
623    /// Rebuild all caches (`simple_index` and `units_cache`) from positions.
624    /// Called after operations that may invalidate caches (like retain or deserialization).
625    fn rebuild_index(&mut self) {
626        self.simple_index.clear();
627        self.units_cache.clear();
628
629        for (idx, pos) in self.positions.iter().enumerate() {
630            // Update units cache for all positions
631            *self
632                .units_cache
633                .entry(pos.units.currency.clone())
634                .or_default() += pos.units.number;
635
636            // Update simple_index only for positions without cost
637            if pos.cost.is_none() {
638                debug_assert!(
639                    !self.simple_index.contains_key(&pos.units.currency),
640                    "Invariant violated: multiple simple positions for currency {}",
641                    pos.units.currency
642                );
643                self.simple_index.insert(pos.units.currency.clone(), idx);
644            }
645        }
646    }
647
648    /// Merge this inventory with another.
649    pub fn merge(&mut self, other: &Self) {
650        for pos in &other.positions {
651            self.add(pos.clone());
652        }
653    }
654
655    /// Convert inventory to cost basis.
656    ///
657    /// Returns a new inventory where all positions are converted to their
658    /// cost basis. Positions without cost are returned as-is.
659    #[must_use]
660    pub fn at_cost(&self) -> Self {
661        let mut result = Self::new();
662
663        for pos in &self.positions {
664            if pos.is_empty() {
665                continue;
666            }
667
668            if let Some(cost) = &pos.cost {
669                // Convert to cost basis
670                let total = pos.units.number * cost.number;
671                result.add(Position::simple(Amount::new(total, &cost.currency)));
672            } else {
673                // No cost, keep as-is
674                result.add(pos.clone());
675            }
676        }
677
678        result
679    }
680
681    /// Convert inventory to units only.
682    ///
683    /// Returns a new inventory where all positions have their cost removed,
684    /// effectively aggregating by currency only.
685    #[must_use]
686    pub fn at_units(&self) -> Self {
687        let mut result = Self::new();
688
689        for pos in &self.positions {
690            if pos.is_empty() {
691                continue;
692            }
693
694            // Strip cost, keep only units
695            result.add(Position::simple(pos.units.clone()));
696        }
697
698        result
699    }
700}
701
702/// Sum the units of `currency` across `account` AND all of its sub-accounts,
703/// over a map of per-account inventories.
704///
705/// Beancount's `balance Assets:Bank` assertion — and the pad math that targets
706/// it — includes `Assets:Bank:Checking`, `Assets:Bank:Savings`, etc. (verified
707/// against `bean-check`: an assertion on a parent passes when the balance is held
708/// in a sub-account). Sub-account membership uses [`is_subaccount_or_equal`], so
709/// the segment-boundary rule (`Assets:BankAlias` does NOT match `Assets:Bank`)
710/// is shared.
711///
712/// This is the single source for that sum, used by both the booking pad engine
713/// and the Late balance validator. They previously computed the pad/assertion
714/// difference differently — booking summed only the leaf account
715/// (`Inventory::units`) while the validator summed sub-accounts — so a pad
716/// targeting a non-leaf account inserted the wrong synthetic amount.
717pub fn sum_account_and_subaccounts<'a, I>(
718    inventories: I,
719    account: &str,
720    currency: &Currency,
721) -> Decimal
722where
723    I: IntoIterator<Item = (&'a Account, &'a Inventory)>,
724{
725    inventories
726        .into_iter()
727        .filter(|(inv_account, _)| is_subaccount_or_equal(inv_account.as_str(), account))
728        .map(|(_, inv)| inv.units(currency))
729        .sum()
730}
731
732impl fmt::Display for Inventory {
733    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
734        if self.is_empty() {
735            return write!(f, "(empty)");
736        }
737
738        // Sort positions alphabetically by currency, then by cost for consistency
739        let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
740        non_empty.sort_by(|a, b| {
741            // First by currency
742            let cmp = a.units.currency.cmp(&b.units.currency);
743            if cmp != std::cmp::Ordering::Equal {
744                return cmp;
745            }
746            // Then by cost (if present)
747            match (&a.cost, &b.cost) {
748                (Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
749                (Some(_), None) => std::cmp::Ordering::Greater,
750                (None, Some(_)) => std::cmp::Ordering::Less,
751                (None, None) => std::cmp::Ordering::Equal,
752            }
753        });
754
755        for (i, pos) in non_empty.iter().enumerate() {
756            if i > 0 {
757                write!(f, ", ")?;
758            }
759            write!(f, "{pos}")?;
760        }
761        Ok(())
762    }
763}
764
765impl FromIterator<Position> for Inventory {
766    fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
767        let mut inv = Self::new();
768        for pos in iter {
769            inv.add(pos);
770        }
771        inv
772    }
773}
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778    use crate::Cost;
779    use crate::NaiveDate;
780    use rust_decimal_macros::dec;
781
782    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
783        crate::naive_date(year, month, day).unwrap()
784    }
785
786    #[test]
787    fn test_empty_inventory() {
788        let inv = Inventory::new();
789        assert!(inv.is_empty());
790        assert_eq!(inv.len(), 0);
791    }
792
793    #[test]
794    fn test_add_simple() {
795        let mut inv = Inventory::new();
796        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
797
798        assert!(!inv.is_empty());
799        assert_eq!(inv.units("USD"), dec!(100));
800    }
801
802    #[test]
803    fn test_add_merge_simple() {
804        let mut inv = Inventory::new();
805        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
806        inv.add(Position::simple(Amount::new(dec!(50), "USD")));
807
808        // Should merge into one position
809        assert_eq!(inv.len(), 1);
810        assert_eq!(inv.units("USD"), dec!(150));
811    }
812
813    #[test]
814    fn test_add_with_cost_no_merge() {
815        let mut inv = Inventory::new();
816
817        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
818        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
819
820        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
821        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
822
823        // Should NOT merge - different costs
824        assert_eq!(inv.len(), 2);
825        assert_eq!(inv.units("AAPL"), dec!(15));
826    }
827
828    #[test]
829    fn test_currencies() {
830        let mut inv = Inventory::new();
831        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
832        inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
833        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
834
835        let currencies = inv.currencies();
836        assert_eq!(currencies.len(), 3);
837        assert!(currencies.contains(&"USD"));
838        assert!(currencies.contains(&"EUR"));
839        assert!(currencies.contains(&"AAPL"));
840    }
841
842    #[test]
843    fn test_reduce_strict_unique() {
844        let mut inv = Inventory::new();
845        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
846        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
847
848        let result = inv
849            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
850            .unwrap();
851
852        assert_eq!(inv.units("AAPL"), dec!(5));
853        assert!(result.cost_basis.is_some());
854        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); // 5 * 150
855    }
856
857    #[test]
858    fn test_reduce_strict_multiple_match_with_different_costs_is_ambiguous() {
859        let mut inv = Inventory::new();
860
861        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
862        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
863
864        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
865        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
866
867        // Per Python beancount: a wildcard reduction (`-3 AAPL` with no cost
868        // spec) against an inventory with lots at different costs is
869        // genuinely ambiguous and must error. Issue #737.
870        let result = inv.reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict);
871
872        assert!(
873            matches!(result, Err(BookingError::AmbiguousMatch { .. })),
874            "expected AmbiguousMatch, got {result:?}"
875        );
876        // Inventory unchanged after a failed reduction
877        assert_eq!(inv.units("AAPL"), dec!(15));
878    }
879
880    #[test]
881    fn test_reduce_strict_multiple_match_with_identical_costs_uses_fifo() {
882        let mut inv = Inventory::new();
883
884        // Two lots with identical cost — interchangeable, so FIFO is fine.
885        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
886
887        inv.add(Position::with_cost(
888            Amount::new(dec!(10), "AAPL"),
889            cost.clone(),
890        ));
891        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
892
893        let result = inv
894            .reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
895            .expect("identical lots should fall back to FIFO without error");
896
897        assert_eq!(inv.units("AAPL"), dec!(12));
898        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00));
899    }
900
901    #[test]
902    fn test_reduce_strict_multiple_match_different_dates_same_cost_uses_fifo() {
903        let mut inv = Inventory::new();
904
905        // Two lots at the same cost number but different acquisition dates.
906        // The user's cost spec could not have constrained the date without
907        // naming it, so the lots are interchangeable for the spec — FIFO.
908        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
909        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 15));
910
911        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
912        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
913
914        let result = inv
915            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
916            .expect("same cost number, different dates should fall back to FIFO");
917
918        assert_eq!(inv.units("AAPL"), dec!(15));
919        // Reduced from the first (oldest) lot at 150.00 USD: 5 * 150 = 750.
920        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
921    }
922
923    #[test]
924    fn test_reduce_strict_multiple_match_total_match_exception() {
925        let mut inv = Inventory::new();
926
927        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
928        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
929
930        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
931        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
932
933        // Selling exactly the entire inventory (10 + 5 = 15) is unambiguous
934        // even with mixed costs — the user is liquidating the position.
935        let result = inv
936            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Strict)
937            .expect("total-match exception should accept a full liquidation");
938
939        assert_eq!(inv.units("AAPL"), dec!(0));
940        // Cost basis = 10*150 + 5*160 = 1500 + 800 = 2300
941        assert_eq!(result.cost_basis.unwrap().number, dec!(2300.00));
942    }
943
944    #[test]
945    fn test_reduce_strict_with_spec() {
946        let mut inv = Inventory::new();
947
948        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
949        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
950
951        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
952        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
953
954        // Reducing with cost spec should work
955        let spec = CostSpec::empty().with_date(date(2024, 1, 1));
956        let result = inv
957            .reduce(
958                &Amount::new(dec!(-3), "AAPL"),
959                Some(&spec),
960                BookingMethod::Strict,
961            )
962            .unwrap();
963
964        assert_eq!(inv.units("AAPL"), dec!(12)); // 7 + 5
965        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); // 3 * 150
966    }
967
968    #[test]
969    fn test_reduce_fifo() {
970        let mut inv = Inventory::new();
971
972        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
973        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
974        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
975
976        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
977        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
978        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
979
980        // FIFO should reduce from oldest (cost 100) first
981        let result = inv
982            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
983            .unwrap();
984
985        assert_eq!(inv.units("AAPL"), dec!(15));
986        // Cost basis: 10 * 100 + 5 * 150 = 1000 + 750 = 1750
987        assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
988    }
989
990    #[test]
991    fn test_reduce_lifo() {
992        let mut inv = Inventory::new();
993
994        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
995        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
996        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
997
998        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
999        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1000        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1001
1002        // LIFO should reduce from newest (cost 200) first
1003        let result = inv
1004            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
1005            .unwrap();
1006
1007        assert_eq!(inv.units("AAPL"), dec!(15));
1008        // Cost basis: 10 * 200 + 5 * 150 = 2000 + 750 = 2750
1009        assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
1010    }
1011
1012    #[test]
1013    fn test_reduce_insufficient() {
1014        let mut inv = Inventory::new();
1015        let cost = Cost::new(dec!(150.00), "USD");
1016        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1017
1018        let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
1019
1020        assert!(matches!(
1021            result,
1022            Err(BookingError::InsufficientUnits { .. })
1023        ));
1024    }
1025
1026    #[test]
1027    fn test_book_value() {
1028        let mut inv = Inventory::new();
1029
1030        let cost1 = Cost::new(dec!(100.00), "USD");
1031        let cost2 = Cost::new(dec!(150.00), "USD");
1032
1033        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1034        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1035
1036        let book = inv.book_value("AAPL");
1037        assert_eq!(book.get("USD"), Some(&dec!(1750.00))); // 10*100 + 5*150
1038    }
1039
1040    #[test]
1041    fn test_display() {
1042        let mut inv = Inventory::new();
1043        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1044
1045        let s = format!("{inv}");
1046        assert!(s.contains("100 USD"));
1047    }
1048
1049    #[test]
1050    fn test_display_empty() {
1051        let inv = Inventory::new();
1052        assert_eq!(format!("{inv}"), "(empty)");
1053    }
1054
1055    #[test]
1056    fn test_from_iterator() {
1057        let positions = vec![
1058            Position::simple(Amount::new(dec!(100), "USD")),
1059            Position::simple(Amount::new(dec!(50), "USD")),
1060        ];
1061
1062        let inv: Inventory = positions.into_iter().collect();
1063        assert_eq!(inv.units("USD"), dec!(150));
1064    }
1065
1066    #[test]
1067    fn test_add_costed_positions_kept_separate() {
1068        // Costed positions are kept as separate lots for O(1) add performance.
1069        // Aggregation happens at display time (in query output).
1070        let mut inv = Inventory::new();
1071
1072        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1073
1074        // Buy 10 shares
1075        inv.add(Position::with_cost(
1076            Amount::new(dec!(10), "AAPL"),
1077            cost.clone(),
1078        ));
1079        assert_eq!(inv.len(), 1);
1080        assert_eq!(inv.units("AAPL"), dec!(10));
1081
1082        // Sell 10 shares - kept as separate lot for tracking
1083        inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1084        assert_eq!(inv.len(), 2); // Both lots kept
1085        assert_eq!(inv.units("AAPL"), dec!(0)); // Net units still zero
1086    }
1087
1088    #[test]
1089    fn test_add_costed_positions_net_units() {
1090        // Verify that units() correctly sums across all lots
1091        let mut inv = Inventory::new();
1092
1093        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1094
1095        // Buy 10 shares
1096        inv.add(Position::with_cost(
1097            Amount::new(dec!(10), "AAPL"),
1098            cost.clone(),
1099        ));
1100
1101        // Sell 3 shares - kept as separate lot
1102        inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost));
1103        assert_eq!(inv.len(), 2); // Both lots kept
1104        assert_eq!(inv.units("AAPL"), dec!(7)); // Net units correct
1105    }
1106
1107    #[test]
1108    fn test_add_no_cancel_different_cost() {
1109        // Test that different costs don't cancel
1110        let mut inv = Inventory::new();
1111
1112        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1113        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
1114
1115        // Buy 10 shares at 150
1116        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1117
1118        // Sell 5 shares at 160 - should NOT cancel (different cost)
1119        inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2));
1120
1121        // Should have two separate lots
1122        assert_eq!(inv.len(), 2);
1123        assert_eq!(inv.units("AAPL"), dec!(5)); // 10 - 5 = 5 total
1124    }
1125
1126    #[test]
1127    fn test_add_no_cancel_same_sign() {
1128        // Test that same-sign positions don't merge even with same cost
1129        let mut inv = Inventory::new();
1130
1131        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1132
1133        // Buy 10 shares
1134        inv.add(Position::with_cost(
1135            Amount::new(dec!(10), "AAPL"),
1136            cost.clone(),
1137        ));
1138
1139        // Buy 5 more shares with same cost - should NOT merge
1140        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
1141
1142        // Should have two separate lots (different acquisitions)
1143        assert_eq!(inv.len(), 2);
1144        assert_eq!(inv.units("AAPL"), dec!(15));
1145    }
1146
1147    #[test]
1148    fn test_merge_keeps_lots_separate() {
1149        // Test that merge keeps costed lots separate (aggregation at display time)
1150        let mut inv1 = Inventory::new();
1151        let mut inv2 = Inventory::new();
1152
1153        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1154
1155        // inv1: buy 10 shares
1156        inv1.add(Position::with_cost(
1157            Amount::new(dec!(10), "AAPL"),
1158            cost.clone(),
1159        ));
1160
1161        // inv2: sell 10 shares
1162        inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1163
1164        // Merge keeps both lots, net units is zero
1165        inv1.merge(&inv2);
1166        assert_eq!(inv1.len(), 2); // Both lots preserved
1167        assert_eq!(inv1.units("AAPL"), dec!(0)); // Net units correct
1168    }
1169
1170    // ====================================================================
1171    // Phase 2: Additional Coverage Tests for Booking Methods
1172    // ====================================================================
1173
1174    #[test]
1175    fn test_hifo_with_tie_breaking() {
1176        // When multiple lots have the same cost, HIFO should use insertion order
1177        let mut inv = Inventory::new();
1178
1179        // Three lots with same cost but different dates
1180        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1181        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1182        let cost3 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 3, 1));
1183
1184        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1185        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1186        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1187
1188        // HIFO with tied costs should reduce in some deterministic order
1189        let result = inv
1190            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
1191            .unwrap();
1192
1193        assert_eq!(inv.units("AAPL"), dec!(15));
1194        // All at same cost, so 15 * 100 = 1500
1195        assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1196    }
1197
1198    #[test]
1199    fn test_hifo_with_different_costs() {
1200        // HIFO should reduce highest cost lots first
1201        let mut inv = Inventory::new();
1202
1203        let cost_low = Cost::new(dec!(50.00), "USD").with_date(date(2024, 1, 1));
1204        let cost_mid = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1205        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1206
1207        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
1208        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
1209        inv.add(Position::with_cost(
1210            Amount::new(dec!(10), "AAPL"),
1211            cost_high,
1212        ));
1213
1214        // Reduce 15 shares - should take from highest cost (200) first
1215        let result = inv
1216            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
1217            .unwrap();
1218
1219        assert_eq!(inv.units("AAPL"), dec!(15));
1220        // 10 * 200 + 5 * 100 = 2000 + 500 = 2500
1221        assert_eq!(result.cost_basis.unwrap().number, dec!(2500.00));
1222    }
1223
1224    #[test]
1225    fn test_average_booking_with_pre_existing_positions() {
1226        let mut inv = Inventory::new();
1227
1228        // Add two lots with different costs
1229        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1230        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1231
1232        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1233        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1234
1235        // Total: 20 shares, total cost = 10*100 + 10*200 = 3000, avg = 150/share
1236        // Reduce 5 shares using AVERAGE
1237        let result = inv
1238            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
1239            .unwrap();
1240
1241        assert_eq!(inv.units("AAPL"), dec!(15));
1242        // Cost basis for 5 shares at average 150 = 750
1243        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
1244    }
1245
1246    #[test]
1247    fn test_average_booking_reduces_all() {
1248        let mut inv = Inventory::new();
1249
1250        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1251        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1252
1253        // Reduce all shares
1254        let result = inv
1255            .reduce(
1256                &Amount::new(dec!(-10), "AAPL"),
1257                None,
1258                BookingMethod::Average,
1259            )
1260            .unwrap();
1261
1262        assert!(inv.is_empty() || inv.units("AAPL").is_zero());
1263        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1264    }
1265
1266    #[test]
1267    fn test_none_booking_augmentation() {
1268        // NONE booking with same-sign amounts should augment, not reduce
1269        let mut inv = Inventory::new();
1270        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1271
1272        // Adding more (same sign) - this is an augmentation
1273        let result = inv
1274            .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
1275            .unwrap();
1276
1277        assert_eq!(inv.units("USD"), dec!(150));
1278        assert!(result.matched.is_empty()); // No lots matched for augmentation
1279        assert!(result.cost_basis.is_none());
1280    }
1281
1282    #[test]
1283    fn test_none_booking_reduction() {
1284        // NONE booking with opposite-sign should reduce
1285        let mut inv = Inventory::new();
1286        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1287
1288        let result = inv
1289            .reduce(&Amount::new(dec!(-30), "USD"), None, BookingMethod::None)
1290            .unwrap();
1291
1292        assert_eq!(inv.units("USD"), dec!(70));
1293        assert!(!result.matched.is_empty());
1294    }
1295
1296    #[test]
1297    fn test_none_booking_shorts_past_zero() {
1298        let mut inv = Inventory::new();
1299        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1300
1301        // NONE performs no booking: reducing past the balance shorts instead
1302        // of erroring (#1686 — previously InsufficientUnits, inconsistent
1303        // with the zero-balance case, NONECorrect.tla, and beancount NONE).
1304        let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
1305
1306        assert!(result.is_ok(), "NONE must allow shorting: {result:?}");
1307        assert_eq!(inv.units("USD"), dec!(-50));
1308    }
1309
1310    #[test]
1311    fn test_booking_error_no_matching_lot() {
1312        let mut inv = Inventory::new();
1313
1314        // Add a lot with specific cost
1315        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1316        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1317
1318        // Try to reduce with a cost spec that doesn't match
1319        let wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
1320        let result = inv.reduce(
1321            &Amount::new(dec!(-5), "AAPL"),
1322            Some(&wrong_spec),
1323            BookingMethod::Strict,
1324        );
1325
1326        assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1327    }
1328
1329    #[test]
1330    fn test_booking_error_insufficient_units() {
1331        let mut inv = Inventory::new();
1332
1333        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1334        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1335
1336        // Try to reduce more than available
1337        let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
1338
1339        match result {
1340            Err(BookingError::InsufficientUnits {
1341                requested,
1342                available,
1343                ..
1344            }) => {
1345                assert_eq!(requested, dec!(20));
1346                assert_eq!(available, dec!(10));
1347            }
1348            _ => panic!("Expected InsufficientUnits error"),
1349        }
1350    }
1351
1352    #[test]
1353    fn test_strict_with_size_exact_match() {
1354        let mut inv = Inventory::new();
1355
1356        // Add two lots with same cost but different sizes
1357        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1358        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1359
1360        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1361        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1362
1363        // Reduce exactly 5 - should match the 5-share lot
1364        let result = inv
1365            .reduce(
1366                &Amount::new(dec!(-5), "AAPL"),
1367                None,
1368                BookingMethod::StrictWithSize,
1369            )
1370            .unwrap();
1371
1372        assert_eq!(inv.units("AAPL"), dec!(10));
1373        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1374    }
1375
1376    #[test]
1377    fn test_strict_with_size_total_match() {
1378        let mut inv = Inventory::new();
1379
1380        // Add two lots
1381        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1382        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1383
1384        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1385        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1386
1387        // Reduce exactly 15 (total) - should succeed via total match exception
1388        let result = inv
1389            .reduce(
1390                &Amount::new(dec!(-15), "AAPL"),
1391                None,
1392                BookingMethod::StrictWithSize,
1393            )
1394            .unwrap();
1395
1396        assert_eq!(inv.units("AAPL"), dec!(0));
1397        assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1398    }
1399
1400    #[test]
1401    fn test_strict_with_size_ambiguous() {
1402        let mut inv = Inventory::new();
1403
1404        // Add two lots of same size and cost
1405        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1406        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1407
1408        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1409        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1410
1411        // Reduce 7 shares - doesn't match either lot exactly, not total
1412        let result = inv.reduce(
1413            &Amount::new(dec!(-7), "AAPL"),
1414            None,
1415            BookingMethod::StrictWithSize,
1416        );
1417
1418        assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
1419    }
1420
1421    #[test]
1422    fn test_short_position() {
1423        // Test short selling (negative positions)
1424        let mut inv = Inventory::new();
1425
1426        // Short 10 shares
1427        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1428        inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1429
1430        assert_eq!(inv.units("AAPL"), dec!(-10));
1431        assert!(!inv.is_empty());
1432    }
1433
1434    #[test]
1435    fn test_at_cost() {
1436        let mut inv = Inventory::new();
1437
1438        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1439        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1440
1441        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1442        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1443        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1444
1445        let at_cost = inv.at_cost();
1446
1447        // AAPL converted: 10*100 + 5*150 = 1000 + 750 = 1750 USD
1448        // Plus 100 USD simple position = 1850 USD total
1449        assert_eq!(at_cost.units("USD"), dec!(1850));
1450        assert_eq!(at_cost.units("AAPL"), dec!(0)); // No AAPL in cost view
1451    }
1452
1453    #[test]
1454    fn test_at_units() {
1455        let mut inv = Inventory::new();
1456
1457        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1458        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1459
1460        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1461        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1462
1463        let at_units = inv.at_units();
1464
1465        // All AAPL lots merged
1466        assert_eq!(at_units.units("AAPL"), dec!(15));
1467        // Should only have one position after aggregation
1468        assert_eq!(at_units.len(), 1);
1469    }
1470
1471    #[test]
1472    fn test_add_empty_position() {
1473        let mut inv = Inventory::new();
1474        inv.add(Position::simple(Amount::new(dec!(0), "USD")));
1475
1476        assert!(inv.is_empty());
1477        assert_eq!(inv.len(), 0);
1478    }
1479
1480    #[test]
1481    fn test_compact() {
1482        let mut inv = Inventory::new();
1483
1484        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1485        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1486
1487        // Reduce all
1488        inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
1489            .unwrap();
1490
1491        // Compact to remove empty positions
1492        inv.compact();
1493        assert!(inv.is_empty());
1494        assert_eq!(inv.len(), 0);
1495    }
1496
1497    #[test]
1498    fn test_booking_method_from_str() {
1499        assert_eq!(
1500            BookingMethod::from_str("STRICT").unwrap(),
1501            BookingMethod::Strict
1502        );
1503        assert_eq!(
1504            BookingMethod::from_str("fifo").unwrap(),
1505            BookingMethod::Fifo
1506        );
1507        assert_eq!(
1508            BookingMethod::from_str("LIFO").unwrap(),
1509            BookingMethod::Lifo
1510        );
1511        assert_eq!(
1512            BookingMethod::from_str("Hifo").unwrap(),
1513            BookingMethod::Hifo
1514        );
1515        assert_eq!(
1516            BookingMethod::from_str("AVERAGE").unwrap(),
1517            BookingMethod::Average
1518        );
1519        assert_eq!(
1520            BookingMethod::from_str("NONE").unwrap(),
1521            BookingMethod::None
1522        );
1523        assert_eq!(
1524            BookingMethod::from_str("strict_with_size").unwrap(),
1525            BookingMethod::StrictWithSize
1526        );
1527        assert!(BookingMethod::from_str("INVALID").is_err());
1528    }
1529
1530    #[test]
1531    fn test_booking_method_display() {
1532        assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
1533        assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
1534        assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
1535        assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
1536        assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
1537        assert_eq!(format!("{}", BookingMethod::None), "NONE");
1538        assert_eq!(
1539            format!("{}", BookingMethod::StrictWithSize),
1540            "STRICT_WITH_SIZE"
1541        );
1542    }
1543
1544    #[test]
1545    fn test_booking_error_display() {
1546        let err = BookingError::AmbiguousMatch {
1547            num_matches: 3,
1548            currency: "AAPL".into(),
1549        };
1550        assert!(format!("{err}").contains("3 lots match"));
1551
1552        let err = BookingError::NoMatchingLot {
1553            currency: "AAPL".into(),
1554            cost_spec: CostSpec::empty(),
1555        };
1556        assert!(format!("{err}").contains("No matching lot"));
1557
1558        let err = BookingError::InsufficientUnits {
1559            currency: "AAPL".into(),
1560            requested: dec!(100),
1561            available: dec!(50),
1562        };
1563        assert!(format!("{err}").contains("requested 100"));
1564        assert!(format!("{err}").contains("available 50"));
1565
1566        let err = BookingError::CurrencyMismatch {
1567            expected: "USD".into(),
1568            got: "EUR".into(),
1569        };
1570        assert!(format!("{err}").contains("expected USD"));
1571        assert!(format!("{err}").contains("got EUR"));
1572    }
1573
1574    #[test]
1575    fn test_book_value_multiple_currencies() {
1576        let mut inv = Inventory::new();
1577
1578        // Cost in USD
1579        let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1580        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd));
1581
1582        // Cost in EUR
1583        let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
1584        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur));
1585
1586        let book = inv.book_value("AAPL");
1587        assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
1588        assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
1589    }
1590
1591    #[test]
1592    fn test_reduce_hifo_insufficient_units() {
1593        let mut inv = Inventory::new();
1594
1595        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1596        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1597
1598        let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
1599
1600        assert!(matches!(
1601            result,
1602            Err(BookingError::InsufficientUnits { .. })
1603        ));
1604    }
1605
1606    #[test]
1607    fn test_reduce_average_insufficient_units() {
1608        let mut inv = Inventory::new();
1609
1610        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1611        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1612
1613        let result = inv.reduce(
1614            &Amount::new(dec!(-20), "AAPL"),
1615            None,
1616            BookingMethod::Average,
1617        );
1618
1619        assert!(matches!(
1620            result,
1621            Err(BookingError::InsufficientUnits { .. })
1622        ));
1623    }
1624
1625    #[test]
1626    fn test_reduce_average_empty_inventory() {
1627        let mut inv = Inventory::new();
1628
1629        let result = inv.reduce(
1630            &Amount::new(dec!(-10), "AAPL"),
1631            None,
1632            BookingMethod::Average,
1633        );
1634
1635        assert!(matches!(
1636            result,
1637            Err(BookingError::InsufficientUnits { .. })
1638        ));
1639    }
1640
1641    #[test]
1642    fn test_reduce_merge_operator() {
1643        // {*} merge: two lots merged into weighted-average, then reduced
1644        let mut inv = Inventory::new();
1645        inv.add(Position::with_cost(
1646            Amount::new(dec!(10), "AAPL"),
1647            Cost::new(dec!(150), "USD"),
1648        ));
1649        inv.add(Position::with_cost(
1650            Amount::new(dec!(10), "AAPL"),
1651            Cost::new(dec!(160), "USD"),
1652        ));
1653
1654        let merge_spec = CostSpec::empty().with_merge();
1655        let result = inv
1656            .reduce(
1657                &Amount::new(dec!(-5), "AAPL"),
1658                Some(&merge_spec),
1659                BookingMethod::Strict,
1660            )
1661            .expect("merge reduction should succeed");
1662
1663        // Cost basis: 5 units * 155 USD average = 775 USD
1664        assert_eq!(result.cost_basis, Some(Amount::new(dec!(775), "USD")));
1665
1666        // Inventory should have a single merged lot with 15 remaining @ 155
1667        assert_eq!(inv.positions.len(), 1);
1668        assert_eq!(inv.positions[0].units.number, dec!(15));
1669        let cost = inv.positions[0].cost.as_ref().expect("should have cost");
1670        assert_eq!(cost.number, dec!(155));
1671    }
1672
1673    #[test]
1674    fn test_reduce_merge_insufficient_units() {
1675        let mut inv = Inventory::new();
1676        inv.add(Position::with_cost(
1677            Amount::new(dec!(10), "AAPL"),
1678            Cost::new(dec!(150), "USD"),
1679        ));
1680
1681        let merge_spec = CostSpec::empty().with_merge();
1682        let result = inv.reduce(
1683            &Amount::new(dec!(-20), "AAPL"),
1684            Some(&merge_spec),
1685            BookingMethod::Strict,
1686        );
1687
1688        assert!(matches!(
1689            result,
1690            Err(BookingError::InsufficientUnits { .. })
1691        ));
1692    }
1693
1694    #[test]
1695    fn test_reduce_merge_sells_all() {
1696        // Merge and sell entire position
1697        let mut inv = Inventory::new();
1698        inv.add(Position::with_cost(
1699            Amount::new(dec!(10), "AAPL"),
1700            Cost::new(dec!(150), "USD"),
1701        ));
1702        inv.add(Position::with_cost(
1703            Amount::new(dec!(10), "AAPL"),
1704            Cost::new(dec!(160), "USD"),
1705        ));
1706
1707        let merge_spec = CostSpec::empty().with_merge();
1708        let result = inv
1709            .reduce(
1710                &Amount::new(dec!(-20), "AAPL"),
1711                Some(&merge_spec),
1712                BookingMethod::Strict,
1713            )
1714            .expect("merge reduction should succeed");
1715
1716        // Cost basis: 20 * 155 = 3100 USD
1717        assert_eq!(result.cost_basis, Some(Amount::new(dec!(3100), "USD")));
1718
1719        // Inventory should be empty
1720        assert!(inv.positions.is_empty() || inv.positions.iter().all(Position::is_empty));
1721    }
1722
1723    #[test]
1724    fn test_reduce_merge_single_lot() {
1725        // {*} with a single lot should work trivially
1726        let mut inv = Inventory::new();
1727        inv.add(Position::with_cost(
1728            Amount::new(dec!(10), "AAPL"),
1729            Cost::new(dec!(150), "USD"),
1730        ));
1731
1732        let merge_spec = CostSpec::empty().with_merge();
1733        let result = inv
1734            .reduce(
1735                &Amount::new(dec!(-3), "AAPL"),
1736                Some(&merge_spec),
1737                BookingMethod::Strict,
1738            )
1739            .expect("single-lot merge should succeed");
1740
1741        assert_eq!(result.cost_basis, Some(Amount::new(dec!(450), "USD")));
1742        assert_eq!(inv.positions.len(), 1);
1743        assert_eq!(inv.positions[0].units.number, dec!(7));
1744    }
1745
1746    #[test]
1747    fn test_reduce_merge_three_lots() {
1748        // {*} with three lots at different costs
1749        let mut inv = Inventory::new();
1750        inv.add(Position::with_cost(
1751            Amount::new(dec!(10), "AAPL"),
1752            Cost::new(dec!(100), "USD"),
1753        ));
1754        inv.add(Position::with_cost(
1755            Amount::new(dec!(10), "AAPL"),
1756            Cost::new(dec!(150), "USD"),
1757        ));
1758        inv.add(Position::with_cost(
1759            Amount::new(dec!(10), "AAPL"),
1760            Cost::new(dec!(200), "USD"),
1761        ));
1762
1763        // Average cost: (1000 + 1500 + 2000) / 30 = 150 USD
1764        let merge_spec = CostSpec::empty().with_merge();
1765        let result = inv
1766            .reduce(
1767                &Amount::new(dec!(-6), "AAPL"),
1768                Some(&merge_spec),
1769                BookingMethod::Strict,
1770            )
1771            .expect("three-lot merge should succeed");
1772
1773        assert_eq!(result.cost_basis, Some(Amount::new(dec!(900), "USD")));
1774        assert_eq!(inv.positions.len(), 1);
1775        assert_eq!(inv.positions[0].units.number, dec!(24));
1776        let cost = inv.positions[0].cost.as_ref().expect("should have cost");
1777        assert_eq!(cost.number, dec!(150));
1778    }
1779
1780    #[test]
1781    fn test_reduce_merge_mixed_cost_currencies_errors() {
1782        // Lots with different cost currencies cannot be merged
1783        let mut inv = Inventory::new();
1784        inv.add(Position::with_cost(
1785            Amount::new(dec!(10), "AAPL"),
1786            Cost::new(dec!(150), "USD"),
1787        ));
1788        inv.add(Position::with_cost(
1789            Amount::new(dec!(10), "AAPL"),
1790            Cost::new(dec!(130), "EUR"),
1791        ));
1792
1793        let merge_spec = CostSpec::empty().with_merge();
1794        let result = inv.reduce(
1795            &Amount::new(dec!(-5), "AAPL"),
1796            Some(&merge_spec),
1797            BookingMethod::Strict,
1798        );
1799
1800        assert!(
1801            matches!(result, Err(BookingError::CurrencyMismatch { .. })),
1802            "expected CurrencyMismatch, got {result:?}"
1803        );
1804    }
1805
1806    #[test]
1807    fn test_reduce_merge_empty_inventory() {
1808        let mut inv = Inventory::new();
1809
1810        let merge_spec = CostSpec::empty().with_merge();
1811        let result = inv.reduce(
1812            &Amount::new(dec!(-5), "AAPL"),
1813            Some(&merge_spec),
1814            BookingMethod::Strict,
1815        );
1816
1817        assert!(matches!(
1818            result,
1819            Err(BookingError::InsufficientUnits { .. })
1820        ));
1821    }
1822
1823    #[test]
1824    fn test_inventory_display_sorted() {
1825        let mut inv = Inventory::new();
1826
1827        // Add in non-alphabetical order
1828        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1829        inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
1830        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
1831
1832        let display = format!("{inv}");
1833
1834        // Should be sorted alphabetically: AAPL, EUR, USD
1835        let aapl_pos = display.find("AAPL").unwrap();
1836        let eur_pos = display.find("EUR").unwrap();
1837        let usd_pos = display.find("USD").unwrap();
1838
1839        assert!(aapl_pos < eur_pos);
1840        assert!(eur_pos < usd_pos);
1841    }
1842
1843    #[test]
1844    fn test_inventory_with_cost_display_sorted() {
1845        let mut inv = Inventory::new();
1846
1847        // Add same currency with different costs
1848        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
1849        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1850
1851        inv.add(Position::with_cost(
1852            Amount::new(dec!(10), "AAPL"),
1853            cost_high,
1854        ));
1855        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1856
1857        let display = format!("{inv}");
1858
1859        // Both positions should be in the output
1860        assert!(display.contains("AAPL"));
1861        assert!(display.contains("100"));
1862        assert!(display.contains("200"));
1863    }
1864
1865    #[test]
1866    fn test_reduce_hifo_no_matching_lot() {
1867        let mut inv = Inventory::new();
1868
1869        // No AAPL positions
1870        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1871
1872        let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
1873
1874        assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1875    }
1876
1877    #[test]
1878    fn test_fifo_respects_dates() {
1879        // Ensure FIFO uses acquisition date, not insertion order
1880        let mut inv = Inventory::new();
1881
1882        // Add newer lot first (out of order)
1883        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1884        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1885
1886        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1887        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1888
1889        // FIFO should reduce from oldest (cost 100) first
1890        let result = inv
1891            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
1892            .unwrap();
1893
1894        // Should use cost from oldest lot (100)
1895        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1896    }
1897
1898    #[test]
1899    fn test_lifo_respects_dates() {
1900        // Ensure LIFO uses acquisition date, not insertion order
1901        let mut inv = Inventory::new();
1902
1903        // Add older lot first
1904        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1905        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1906
1907        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1908        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1909
1910        // LIFO should reduce from newest (cost 200) first
1911        let result = inv
1912            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
1913            .unwrap();
1914
1915        // Should use cost from newest lot (200)
1916        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1917    }
1918
1919    // =========================================================================
1920    // Booking method coverage tests
1921    //
1922    // These tests cover gaps identified during the spring 2026 audit:
1923    // - STRICT_WITH_SIZE: cost spec + exact-size, multiple exact-size matches
1924    // - HIFO: multi-lot ordering, partial reduction, cost spec filtering
1925    // - AVERAGE: weighted average with different costs, partial reduction preserves cost
1926    // - NONE: with cost positions, short position reduction
1927    // =========================================================================
1928
1929    // --- STRICT_WITH_SIZE ---
1930
1931    #[test]
1932    fn test_strict_with_size_different_costs_exact_match() {
1933        // When lots have different costs but one matches the reduction size exactly,
1934        // STRICT_WITH_SIZE should pick that lot instead of raising AmbiguousMatch
1935        let mut inv = Inventory::new();
1936
1937        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1938        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1939
1940        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1941        inv.add(Position::with_cost(Amount::new(dec!(7), "AAPL"), cost2));
1942
1943        // Reduce exactly 7 - should match the 7-share lot at cost 200
1944        let result = inv
1945            .reduce(
1946                &Amount::new(dec!(-7), "AAPL"),
1947                None,
1948                BookingMethod::StrictWithSize,
1949            )
1950            .unwrap();
1951
1952        assert_eq!(inv.units("AAPL"), dec!(10));
1953        assert_eq!(result.cost_basis.unwrap().number, dec!(1400.00)); // 7 * 200
1954    }
1955
1956    #[test]
1957    fn test_strict_with_size_multiple_exact_matches_picks_oldest() {
1958        // When multiple lots have the exact same size, STRICT_WITH_SIZE should
1959        // pick the oldest one (first in index order)
1960        let mut inv = Inventory::new();
1961
1962        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1963        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 6, 1));
1964
1965        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost1));
1966        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1967
1968        // Both lots are size 5 — should pick the first (oldest) one
1969        let result = inv
1970            .reduce(
1971                &Amount::new(dec!(-5), "AAPL"),
1972                None,
1973                BookingMethod::StrictWithSize,
1974            )
1975            .unwrap();
1976
1977        assert_eq!(inv.units("AAPL"), dec!(5));
1978        // Should use cost from the oldest lot (100)
1979        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1980    }
1981
1982    #[test]
1983    fn test_strict_with_size_with_cost_spec() {
1984        // Cost spec should filter lots before exact-size matching
1985        let mut inv = Inventory::new();
1986
1987        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1988        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1989
1990        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1991        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1992
1993        // With cost spec filtering to the 200 USD lot, should find unique match
1994        let spec = CostSpec::empty().with_number(crate::CostNumber::PerUnit {
1995            value: dec!(200.00),
1996        });
1997        let result = inv
1998            .reduce(
1999                &Amount::new(dec!(-5), "AAPL"),
2000                Some(&spec),
2001                BookingMethod::StrictWithSize,
2002            )
2003            .unwrap();
2004
2005        assert_eq!(inv.units("AAPL"), dec!(15));
2006        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
2007    }
2008
2009    // --- HIFO ---
2010
2011    #[test]
2012    fn test_hifo_reduces_highest_cost_first() {
2013        // HIFO should reduce the highest-cost lot first, regardless of date
2014        let mut inv = Inventory::new();
2015
2016        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2017        let cost_mid = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
2018        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2019
2020        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
2021        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
2022        inv.add(Position::with_cost(
2023            Amount::new(dec!(10), "AAPL"),
2024            cost_high,
2025        ));
2026
2027        // Reduce 5 — should come from highest cost lot (200)
2028        let result = inv
2029            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Hifo)
2030            .unwrap();
2031
2032        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
2033        assert_eq!(inv.units("AAPL"), dec!(25));
2034    }
2035
2036    #[test]
2037    fn test_hifo_spans_multiple_lots() {
2038        // When reducing more than the highest-cost lot holds, HIFO should
2039        // continue to the next highest
2040        let mut inv = Inventory::new();
2041
2042        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2043        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2044
2045        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
2046        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_high));
2047
2048        // Reduce 8: 5 from high (200) + 3 from low (100)
2049        let result = inv
2050            .reduce(&Amount::new(dec!(-8), "AAPL"), None, BookingMethod::Hifo)
2051            .unwrap();
2052
2053        // Cost basis: 5*200 + 3*100 = 1300
2054        assert_eq!(result.cost_basis.unwrap().number, dec!(1300.00));
2055        assert_eq!(inv.units("AAPL"), dec!(2));
2056    }
2057
2058    #[test]
2059    fn test_hifo_with_cost_spec_filter() {
2060        // Cost spec should filter lots before HIFO ordering
2061        let mut inv = Inventory::new();
2062
2063        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2064        let cost2 = Cost::new(dec!(200.00), "EUR").with_date(date(2024, 2, 1));
2065
2066        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2067        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2068
2069        // Filter to USD lots only
2070        let spec = CostSpec::empty().with_currency("USD");
2071        let result = inv
2072            .reduce(
2073                &Amount::new(dec!(-5), "AAPL"),
2074                Some(&spec),
2075                BookingMethod::Hifo,
2076            )
2077            .unwrap();
2078
2079        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); // 5 * 100 USD
2080    }
2081
2082    #[test]
2083    fn test_hifo_short_position() {
2084        // HIFO with short positions: covering shorts should work correctly
2085        let mut inv = Inventory::new();
2086
2087        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2088        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2089
2090        // Short positions (negative units)
2091        inv.add(Position::with_cost(
2092            Amount::new(dec!(-10), "AAPL"),
2093            cost_low,
2094        ));
2095        inv.add(Position::with_cost(
2096            Amount::new(dec!(-10), "AAPL"),
2097            cost_high,
2098        ));
2099
2100        // Cover 5 shares (positive = reduce short position)
2101        // HIFO should pick the highest-cost short lot (200)
2102        let result = inv
2103            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Hifo)
2104            .unwrap();
2105
2106        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
2107        assert_eq!(inv.units("AAPL"), dec!(-15));
2108    }
2109
2110    // --- AVERAGE ---
2111
2112    #[test]
2113    fn test_average_weighted_cost() {
2114        // AVERAGE should compute weighted average across lots with different costs
2115        let mut inv = Inventory::new();
2116
2117        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2118        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2119
2120        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2121        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2122
2123        // Average cost = (10*100 + 10*200) / 20 = 150
2124        let result = inv
2125            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
2126            .unwrap();
2127
2128        // Cost basis: 5 * 150 = 750
2129        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
2130        assert_eq!(inv.units("AAPL"), dec!(15));
2131    }
2132
2133    #[test]
2134    fn test_average_merges_into_single_position() {
2135        // After AVERAGE reduction, inventory should have a single simple position
2136        let mut inv = Inventory::new();
2137
2138        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2139        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2140
2141        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2142        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2143
2144        inv.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
2145            .unwrap();
2146
2147        // Should have exactly one AAPL position remaining
2148        let aapl_positions: Vec<_> = inv
2149            .positions
2150            .iter()
2151            .filter(|p| p.units.currency.as_ref() == "AAPL")
2152            .collect();
2153        assert_eq!(aapl_positions.len(), 1);
2154        assert_eq!(aapl_positions[0].units.number, dec!(15));
2155    }
2156
2157    #[test]
2158    fn test_average_uneven_lots() {
2159        // Weighted average with unequal lot sizes
2160        let mut inv = Inventory::new();
2161
2162        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2163        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2164
2165        inv.add(Position::with_cost(Amount::new(dec!(30), "AAPL"), cost1));
2166        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2167
2168        // Average cost = (30*100 + 10*200) / 40 = 5000/40 = 125
2169        let result = inv
2170            .reduce(
2171                &Amount::new(dec!(-10), "AAPL"),
2172                None,
2173                BookingMethod::Average,
2174            )
2175            .unwrap();
2176
2177        assert_eq!(result.cost_basis.unwrap().number, dec!(1250.00)); // 10 * 125
2178    }
2179
2180    // --- NONE ---
2181
2182    #[test]
2183    fn test_none_booking_with_cost_positions() {
2184        // NONE booking should work even when positions have costs
2185        let mut inv = Inventory::new();
2186
2187        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2188        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
2189
2190        let result = inv
2191            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::None)
2192            .unwrap();
2193
2194        assert_eq!(inv.units("AAPL"), dec!(5));
2195        // NONE delegates to reduce_ordered (FIFO) internally, so cost basis is computed
2196        assert!(result.cost_basis.is_some());
2197        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
2198    }
2199
2200    #[test]
2201    fn test_none_booking_short_cover() {
2202        // Covering a short position with NONE booking
2203        let mut inv = Inventory::new();
2204        inv.add(Position::simple(Amount::new(dec!(-100), "USD")));
2205
2206        // Positive amount should reduce the negative position
2207        let result = inv
2208            .reduce(&Amount::new(dec!(30), "USD"), None, BookingMethod::None)
2209            .unwrap();
2210
2211        assert_eq!(inv.units("USD"), dec!(-70));
2212        assert!(!result.matched.is_empty());
2213    }
2214
2215    #[test]
2216    fn test_none_booking_empty_inventory_augments() {
2217        // NONE booking on empty inventory should augment
2218        let mut inv = Inventory::new();
2219
2220        let result = inv
2221            .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
2222            .unwrap();
2223
2224        assert_eq!(inv.units("USD"), dec!(50));
2225        assert!(result.matched.is_empty()); // Augmentation, not reduction
2226    }
2227
2228    // --- Cross-method: short positions ---
2229
2230    #[test]
2231    fn test_fifo_short_position_cover() {
2232        // FIFO: cover short positions (oldest short first)
2233        let mut inv = Inventory::new();
2234
2235        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2236        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2237
2238        inv.add(Position::with_cost(
2239            Amount::new(dec!(-10), "AAPL"),
2240            cost_old,
2241        ));
2242        inv.add(Position::with_cost(
2243            Amount::new(dec!(-10), "AAPL"),
2244            cost_new,
2245        ));
2246
2247        // Cover 5 shares — FIFO should pick oldest short (cost 100)
2248        let result = inv
2249            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Fifo)
2250            .unwrap();
2251
2252        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); // 5 * 100
2253        assert_eq!(inv.units("AAPL"), dec!(-15));
2254    }
2255
2256    #[test]
2257    fn test_lifo_short_position_cover() {
2258        // LIFO: cover short positions (newest short first)
2259        let mut inv = Inventory::new();
2260
2261        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2262        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2263
2264        inv.add(Position::with_cost(
2265            Amount::new(dec!(-10), "AAPL"),
2266            cost_old,
2267        ));
2268        inv.add(Position::with_cost(
2269            Amount::new(dec!(-10), "AAPL"),
2270            cost_new,
2271        ));
2272
2273        // Cover 5 shares — LIFO should pick newest short (cost 200)
2274        let result = inv
2275            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Lifo)
2276            .unwrap();
2277
2278        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
2279        assert_eq!(inv.units("AAPL"), dec!(-15));
2280    }
2281
2282    // === AccountedBookingError Display tests ===
2283    //
2284    // These tests pin the canonical user-facing wording for every variant
2285    // of `AccountedBookingError`. The whole point of unifying booking-error
2286    // Display into `rustledger-core` (#750) is that there's a single source
2287    // of truth — and a single source of truth with no tests is one refactor
2288    // away from drifting again, which is exactly the failure mode that
2289    // produced #748. Any change to the Display strings below will break
2290    // these tests, forcing the author to consciously re-check pta-standards
2291    // conformance assertions and downstream user tooling.
2292
2293    // =========================================================================
2294    // Regression test for issue #875 / beancount#889
2295    //
2296    // When a sell-without-cost-spec leaves a negative simple position in the
2297    // inventory, a subsequent augmentation WITH a cost spec should NOT be
2298    // misclassified as a reduction. `is_reduced_by` must only consider
2299    // cost-bearing positions when the incoming posting has a cost spec.
2300    // =========================================================================
2301
2302    #[test]
2303    fn test_is_reduced_by_ignores_simple_positions_when_has_cost_spec() {
2304        // Regression test for issue #875 / beancount#889.
2305        //
2306        // Scenario:
2307        //   1. Buy 100 HOOG {1.50 EUR}  -> inventory: [100 HOOG {1.50 EUR}]
2308        //   2. Sell 25 HOOG @ 1.60 EUR   -> inventory: [100 HOOG {1.50 EUR}, -25 HOOG (simple)]
2309        //   3. Buy 50 HOOG {1.70 EUR}    -> should be augmentation, NOT reduction
2310        //
2311        // Before fix: is_reduced_by saw the -25 HOOG simple position and
2312        // incorrectly reported that +50 HOOG would reduce the inventory.
2313        let mut inv = Inventory::new();
2314
2315        // Step 1: buy 100 HOOG with cost
2316        let cost = Cost::new(dec!(1.50), "EUR").with_date(date(2024, 1, 10));
2317        inv.add(Position::with_cost(Amount::new(dec!(100), "HOOG"), cost));
2318
2319        // Step 2: sell 25 HOOG without cost spec (simple position)
2320        inv.add(Position::simple(Amount::new(dec!(-25), "HOOG")));
2321
2322        // Step 3: check if buying 50 HOOG with cost spec would be a reduction
2323        let buy_units = Amount::new(dec!(50), "HOOG");
2324
2325        // With has_cost_spec=true, only cost-bearing positions should be
2326        // considered. The 100 HOOG {1.50 EUR} is positive and so is the
2327        // incoming 50 HOOG -> same sign -> NOT a reduction.
2328        assert!(
2329            !inv.is_reduced_by(&buy_units, ReductionScope::CostBearingOnly),
2330            "augmentation with cost spec should NOT be treated as reduction \
2331             when only a simple (no-cost) position has opposite sign"
2332        );
2333
2334        // With AllPositions, all positions are considered,
2335        // including the -25 HOOG simple position -> IS a reduction.
2336        assert!(
2337            inv.is_reduced_by(&buy_units, ReductionScope::AllPositions),
2338            "without cost spec filter, the -25 HOOG simple position \
2339             should cause is_reduced_by to return true"
2340        );
2341    }
2342
2343    #[test]
2344    fn is_booking_reduction_gates_on_method_cost_and_sign() {
2345        // A cost-bearing long position.
2346        let mut inv = Inventory::new();
2347        inv.add(Position::with_cost(
2348            Amount::new(dec!(10), "AAPL"),
2349            Cost::new(dec!(150), "USD").with_date(date(2024, 1, 1)),
2350        ));
2351
2352        let sell = Amount::new(dec!(-5), "AAPL"); // opposite sign of the held lot
2353        let buy = Amount::new(dec!(5), "AAPL"); // same sign
2354        let spec = CostSpec::empty(); // only spec *presence* (is_some) matters here
2355
2356        // Opposite-sign units carrying a cost spec under a lot-matching method
2357        // is the one combination that reduces.
2358        assert!(inv.is_booking_reduction(&sell, Some(&spec), BookingMethod::Strict));
2359        // NONE never reduces — every posting accumulates (#1182).
2360        assert!(!inv.is_booking_reduction(&sell, Some(&spec), BookingMethod::None));
2361        // No cost spec -> augmentation.
2362        assert!(!inv.is_booking_reduction(&sell, None, BookingMethod::Strict));
2363        // Same sign as the held lot -> augmentation.
2364        assert!(!inv.is_booking_reduction(&buy, Some(&spec), BookingMethod::Strict));
2365    }
2366
2367    #[test]
2368    fn sum_account_and_subaccounts_sums_children_not_prefix_siblings() {
2369        let mut bank = Inventory::new();
2370        bank.add(Position::simple(Amount::new(dec!(10), "USD")));
2371        let mut checking = Inventory::new(); // sub-account: included
2372        checking.add(Position::simple(Amount::new(dec!(40), "USD")));
2373        let mut alias = Inventory::new(); // prefix sibling: excluded
2374        alias.add(Position::simple(Amount::new(dec!(99), "USD")));
2375
2376        let mut map: FxHashMap<Account, Inventory> = FxHashMap::default();
2377        map.insert(Account::from("Assets:Bank"), bank);
2378        map.insert(Account::from("Assets:Bank:Checking"), checking);
2379        map.insert(Account::from("Assets:BankAlias"), alias);
2380
2381        let total = sum_account_and_subaccounts(map.iter(), "Assets:Bank", &Currency::from("USD"));
2382        assert_eq!(
2383            total,
2384            dec!(50),
2385            "parent (10) + sub-account (40), excluding the Assets:BankAlias prefix sibling"
2386        );
2387    }
2388
2389    #[test]
2390    fn test_accounted_error_display_insufficient_units() {
2391        let err = BookingError::InsufficientUnits {
2392            currency: "AAPL".into(),
2393            requested: dec!(15),
2394            available: dec!(10),
2395        }
2396        .with_account("Assets:Stock".into());
2397        let rendered = format!("{err}");
2398
2399        // Pinned by pta-standards `reduction-exceeds-inventory`
2400        // (`error_contains: ["not enough"]`). See #748 / #749.
2401        assert!(
2402            rendered.contains("not enough"),
2403            "must contain 'not enough' (pta-standards): {rendered}"
2404        );
2405        assert!(
2406            rendered.contains("Assets:Stock"),
2407            "must contain account name: {rendered}"
2408        );
2409        assert!(
2410            rendered.contains("15") && rendered.contains("10"),
2411            "must contain requested and available amounts: {rendered}"
2412        );
2413    }
2414
2415    #[test]
2416    fn test_accounted_error_display_no_matching_lot() {
2417        let err = BookingError::NoMatchingLot {
2418            currency: "AAPL".into(),
2419            cost_spec: CostSpec::empty(),
2420        }
2421        .with_account("Assets:Stock".into());
2422        let rendered = format!("{err}");
2423
2424        assert!(
2425            rendered.contains("No matching lot"),
2426            "must contain 'No matching lot': {rendered}"
2427        );
2428        assert!(
2429            rendered.contains("AAPL"),
2430            "must contain currency: {rendered}"
2431        );
2432        assert!(
2433            rendered.contains("Assets:Stock"),
2434            "must contain account name: {rendered}"
2435        );
2436    }
2437
2438    #[test]
2439    fn test_accounted_error_display_ambiguous_match() {
2440        let err = BookingError::AmbiguousMatch {
2441            num_matches: 3,
2442            currency: "AAPL".into(),
2443        }
2444        .with_account("Assets:Stock".into());
2445        let rendered = format!("{err}");
2446
2447        assert!(
2448            rendered.contains("Ambiguous"),
2449            "must contain 'Ambiguous': {rendered}"
2450        );
2451        assert!(
2452            rendered.contains("AAPL"),
2453            "must contain currency: {rendered}"
2454        );
2455        assert!(
2456            rendered.contains("Assets:Stock"),
2457            "must contain account name: {rendered}"
2458        );
2459        assert!(
2460            rendered.contains('3'),
2461            "must contain match count: {rendered}"
2462        );
2463    }
2464
2465    #[test]
2466    fn test_accounted_error_display_currency_mismatch_renders_as_no_matching_lot() {
2467        // CurrencyMismatch is semantically a specialization of NoMatchingLot
2468        // (there is no lot for the given currency in this inventory) and the
2469        // canonical Display collapses them into the same user-facing phrasing
2470        // so that consumers filtering on E4001 don't need to special-case it.
2471        // This variant is defensive — no `Inventory::reduce` path currently
2472        // emits it — but we still pin its rendering in case a future emission
2473        // site is added.
2474        let err = BookingError::CurrencyMismatch {
2475            expected: "USD".into(),
2476            got: "EUR".into(),
2477        }
2478        .with_account("Assets:Cash".into());
2479        let rendered = format!("{err}");
2480
2481        assert!(
2482            rendered.contains("No matching lot"),
2483            "CurrencyMismatch must render as 'No matching lot' for E4001 \
2484             consistency: {rendered}"
2485        );
2486        assert!(
2487            rendered.contains("EUR"),
2488            "must contain the mismatched (got) currency: {rendered}"
2489        );
2490        assert!(
2491            rendered.contains("Assets:Cash"),
2492            "must contain account name: {rendered}"
2493        );
2494    }
2495}