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_insufficient() {
1298        let mut inv = Inventory::new();
1299        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1300
1301        let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
1302
1303        assert!(matches!(
1304            result,
1305            Err(BookingError::InsufficientUnits { .. })
1306        ));
1307    }
1308
1309    #[test]
1310    fn test_booking_error_no_matching_lot() {
1311        let mut inv = Inventory::new();
1312
1313        // Add a lot with specific cost
1314        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1315        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1316
1317        // Try to reduce with a cost spec that doesn't match
1318        let wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
1319        let result = inv.reduce(
1320            &Amount::new(dec!(-5), "AAPL"),
1321            Some(&wrong_spec),
1322            BookingMethod::Strict,
1323        );
1324
1325        assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1326    }
1327
1328    #[test]
1329    fn test_booking_error_insufficient_units() {
1330        let mut inv = Inventory::new();
1331
1332        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1333        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1334
1335        // Try to reduce more than available
1336        let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
1337
1338        match result {
1339            Err(BookingError::InsufficientUnits {
1340                requested,
1341                available,
1342                ..
1343            }) => {
1344                assert_eq!(requested, dec!(20));
1345                assert_eq!(available, dec!(10));
1346            }
1347            _ => panic!("Expected InsufficientUnits error"),
1348        }
1349    }
1350
1351    #[test]
1352    fn test_strict_with_size_exact_match() {
1353        let mut inv = Inventory::new();
1354
1355        // Add two lots with same cost but different sizes
1356        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1357        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1358
1359        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1360        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1361
1362        // Reduce exactly 5 - should match the 5-share lot
1363        let result = inv
1364            .reduce(
1365                &Amount::new(dec!(-5), "AAPL"),
1366                None,
1367                BookingMethod::StrictWithSize,
1368            )
1369            .unwrap();
1370
1371        assert_eq!(inv.units("AAPL"), dec!(10));
1372        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1373    }
1374
1375    #[test]
1376    fn test_strict_with_size_total_match() {
1377        let mut inv = Inventory::new();
1378
1379        // Add two lots
1380        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1381        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1382
1383        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1384        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1385
1386        // Reduce exactly 15 (total) - should succeed via total match exception
1387        let result = inv
1388            .reduce(
1389                &Amount::new(dec!(-15), "AAPL"),
1390                None,
1391                BookingMethod::StrictWithSize,
1392            )
1393            .unwrap();
1394
1395        assert_eq!(inv.units("AAPL"), dec!(0));
1396        assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1397    }
1398
1399    #[test]
1400    fn test_strict_with_size_ambiguous() {
1401        let mut inv = Inventory::new();
1402
1403        // Add two lots of same size and cost
1404        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1405        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1406
1407        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1408        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1409
1410        // Reduce 7 shares - doesn't match either lot exactly, not total
1411        let result = inv.reduce(
1412            &Amount::new(dec!(-7), "AAPL"),
1413            None,
1414            BookingMethod::StrictWithSize,
1415        );
1416
1417        assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
1418    }
1419
1420    #[test]
1421    fn test_short_position() {
1422        // Test short selling (negative positions)
1423        let mut inv = Inventory::new();
1424
1425        // Short 10 shares
1426        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1427        inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1428
1429        assert_eq!(inv.units("AAPL"), dec!(-10));
1430        assert!(!inv.is_empty());
1431    }
1432
1433    #[test]
1434    fn test_at_cost() {
1435        let mut inv = Inventory::new();
1436
1437        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1438        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1439
1440        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1441        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1442        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1443
1444        let at_cost = inv.at_cost();
1445
1446        // AAPL converted: 10*100 + 5*150 = 1000 + 750 = 1750 USD
1447        // Plus 100 USD simple position = 1850 USD total
1448        assert_eq!(at_cost.units("USD"), dec!(1850));
1449        assert_eq!(at_cost.units("AAPL"), dec!(0)); // No AAPL in cost view
1450    }
1451
1452    #[test]
1453    fn test_at_units() {
1454        let mut inv = Inventory::new();
1455
1456        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1457        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1458
1459        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1460        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1461
1462        let at_units = inv.at_units();
1463
1464        // All AAPL lots merged
1465        assert_eq!(at_units.units("AAPL"), dec!(15));
1466        // Should only have one position after aggregation
1467        assert_eq!(at_units.len(), 1);
1468    }
1469
1470    #[test]
1471    fn test_add_empty_position() {
1472        let mut inv = Inventory::new();
1473        inv.add(Position::simple(Amount::new(dec!(0), "USD")));
1474
1475        assert!(inv.is_empty());
1476        assert_eq!(inv.len(), 0);
1477    }
1478
1479    #[test]
1480    fn test_compact() {
1481        let mut inv = Inventory::new();
1482
1483        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1484        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1485
1486        // Reduce all
1487        inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
1488            .unwrap();
1489
1490        // Compact to remove empty positions
1491        inv.compact();
1492        assert!(inv.is_empty());
1493        assert_eq!(inv.len(), 0);
1494    }
1495
1496    #[test]
1497    fn test_booking_method_from_str() {
1498        assert_eq!(
1499            BookingMethod::from_str("STRICT").unwrap(),
1500            BookingMethod::Strict
1501        );
1502        assert_eq!(
1503            BookingMethod::from_str("fifo").unwrap(),
1504            BookingMethod::Fifo
1505        );
1506        assert_eq!(
1507            BookingMethod::from_str("LIFO").unwrap(),
1508            BookingMethod::Lifo
1509        );
1510        assert_eq!(
1511            BookingMethod::from_str("Hifo").unwrap(),
1512            BookingMethod::Hifo
1513        );
1514        assert_eq!(
1515            BookingMethod::from_str("AVERAGE").unwrap(),
1516            BookingMethod::Average
1517        );
1518        assert_eq!(
1519            BookingMethod::from_str("NONE").unwrap(),
1520            BookingMethod::None
1521        );
1522        assert_eq!(
1523            BookingMethod::from_str("strict_with_size").unwrap(),
1524            BookingMethod::StrictWithSize
1525        );
1526        assert!(BookingMethod::from_str("INVALID").is_err());
1527    }
1528
1529    #[test]
1530    fn test_booking_method_display() {
1531        assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
1532        assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
1533        assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
1534        assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
1535        assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
1536        assert_eq!(format!("{}", BookingMethod::None), "NONE");
1537        assert_eq!(
1538            format!("{}", BookingMethod::StrictWithSize),
1539            "STRICT_WITH_SIZE"
1540        );
1541    }
1542
1543    #[test]
1544    fn test_booking_error_display() {
1545        let err = BookingError::AmbiguousMatch {
1546            num_matches: 3,
1547            currency: "AAPL".into(),
1548        };
1549        assert!(format!("{err}").contains("3 lots match"));
1550
1551        let err = BookingError::NoMatchingLot {
1552            currency: "AAPL".into(),
1553            cost_spec: CostSpec::empty(),
1554        };
1555        assert!(format!("{err}").contains("No matching lot"));
1556
1557        let err = BookingError::InsufficientUnits {
1558            currency: "AAPL".into(),
1559            requested: dec!(100),
1560            available: dec!(50),
1561        };
1562        assert!(format!("{err}").contains("requested 100"));
1563        assert!(format!("{err}").contains("available 50"));
1564
1565        let err = BookingError::CurrencyMismatch {
1566            expected: "USD".into(),
1567            got: "EUR".into(),
1568        };
1569        assert!(format!("{err}").contains("expected USD"));
1570        assert!(format!("{err}").contains("got EUR"));
1571    }
1572
1573    #[test]
1574    fn test_book_value_multiple_currencies() {
1575        let mut inv = Inventory::new();
1576
1577        // Cost in USD
1578        let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1579        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd));
1580
1581        // Cost in EUR
1582        let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
1583        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur));
1584
1585        let book = inv.book_value("AAPL");
1586        assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
1587        assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
1588    }
1589
1590    #[test]
1591    fn test_reduce_hifo_insufficient_units() {
1592        let mut inv = Inventory::new();
1593
1594        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1595        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1596
1597        let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
1598
1599        assert!(matches!(
1600            result,
1601            Err(BookingError::InsufficientUnits { .. })
1602        ));
1603    }
1604
1605    #[test]
1606    fn test_reduce_average_insufficient_units() {
1607        let mut inv = Inventory::new();
1608
1609        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1610        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1611
1612        let result = inv.reduce(
1613            &Amount::new(dec!(-20), "AAPL"),
1614            None,
1615            BookingMethod::Average,
1616        );
1617
1618        assert!(matches!(
1619            result,
1620            Err(BookingError::InsufficientUnits { .. })
1621        ));
1622    }
1623
1624    #[test]
1625    fn test_reduce_average_empty_inventory() {
1626        let mut inv = Inventory::new();
1627
1628        let result = inv.reduce(
1629            &Amount::new(dec!(-10), "AAPL"),
1630            None,
1631            BookingMethod::Average,
1632        );
1633
1634        assert!(matches!(
1635            result,
1636            Err(BookingError::InsufficientUnits { .. })
1637        ));
1638    }
1639
1640    #[test]
1641    fn test_reduce_merge_operator() {
1642        // {*} merge: two lots merged into weighted-average, then reduced
1643        let mut inv = Inventory::new();
1644        inv.add(Position::with_cost(
1645            Amount::new(dec!(10), "AAPL"),
1646            Cost::new(dec!(150), "USD"),
1647        ));
1648        inv.add(Position::with_cost(
1649            Amount::new(dec!(10), "AAPL"),
1650            Cost::new(dec!(160), "USD"),
1651        ));
1652
1653        let merge_spec = CostSpec::empty().with_merge();
1654        let result = inv
1655            .reduce(
1656                &Amount::new(dec!(-5), "AAPL"),
1657                Some(&merge_spec),
1658                BookingMethod::Strict,
1659            )
1660            .expect("merge reduction should succeed");
1661
1662        // Cost basis: 5 units * 155 USD average = 775 USD
1663        assert_eq!(result.cost_basis, Some(Amount::new(dec!(775), "USD")));
1664
1665        // Inventory should have a single merged lot with 15 remaining @ 155
1666        assert_eq!(inv.positions.len(), 1);
1667        assert_eq!(inv.positions[0].units.number, dec!(15));
1668        let cost = inv.positions[0].cost.as_ref().expect("should have cost");
1669        assert_eq!(cost.number, dec!(155));
1670    }
1671
1672    #[test]
1673    fn test_reduce_merge_insufficient_units() {
1674        let mut inv = Inventory::new();
1675        inv.add(Position::with_cost(
1676            Amount::new(dec!(10), "AAPL"),
1677            Cost::new(dec!(150), "USD"),
1678        ));
1679
1680        let merge_spec = CostSpec::empty().with_merge();
1681        let result = inv.reduce(
1682            &Amount::new(dec!(-20), "AAPL"),
1683            Some(&merge_spec),
1684            BookingMethod::Strict,
1685        );
1686
1687        assert!(matches!(
1688            result,
1689            Err(BookingError::InsufficientUnits { .. })
1690        ));
1691    }
1692
1693    #[test]
1694    fn test_reduce_merge_sells_all() {
1695        // Merge and sell entire position
1696        let mut inv = Inventory::new();
1697        inv.add(Position::with_cost(
1698            Amount::new(dec!(10), "AAPL"),
1699            Cost::new(dec!(150), "USD"),
1700        ));
1701        inv.add(Position::with_cost(
1702            Amount::new(dec!(10), "AAPL"),
1703            Cost::new(dec!(160), "USD"),
1704        ));
1705
1706        let merge_spec = CostSpec::empty().with_merge();
1707        let result = inv
1708            .reduce(
1709                &Amount::new(dec!(-20), "AAPL"),
1710                Some(&merge_spec),
1711                BookingMethod::Strict,
1712            )
1713            .expect("merge reduction should succeed");
1714
1715        // Cost basis: 20 * 155 = 3100 USD
1716        assert_eq!(result.cost_basis, Some(Amount::new(dec!(3100), "USD")));
1717
1718        // Inventory should be empty
1719        assert!(inv.positions.is_empty() || inv.positions.iter().all(Position::is_empty));
1720    }
1721
1722    #[test]
1723    fn test_reduce_merge_single_lot() {
1724        // {*} with a single lot should work trivially
1725        let mut inv = Inventory::new();
1726        inv.add(Position::with_cost(
1727            Amount::new(dec!(10), "AAPL"),
1728            Cost::new(dec!(150), "USD"),
1729        ));
1730
1731        let merge_spec = CostSpec::empty().with_merge();
1732        let result = inv
1733            .reduce(
1734                &Amount::new(dec!(-3), "AAPL"),
1735                Some(&merge_spec),
1736                BookingMethod::Strict,
1737            )
1738            .expect("single-lot merge should succeed");
1739
1740        assert_eq!(result.cost_basis, Some(Amount::new(dec!(450), "USD")));
1741        assert_eq!(inv.positions.len(), 1);
1742        assert_eq!(inv.positions[0].units.number, dec!(7));
1743    }
1744
1745    #[test]
1746    fn test_reduce_merge_three_lots() {
1747        // {*} with three lots at different costs
1748        let mut inv = Inventory::new();
1749        inv.add(Position::with_cost(
1750            Amount::new(dec!(10), "AAPL"),
1751            Cost::new(dec!(100), "USD"),
1752        ));
1753        inv.add(Position::with_cost(
1754            Amount::new(dec!(10), "AAPL"),
1755            Cost::new(dec!(150), "USD"),
1756        ));
1757        inv.add(Position::with_cost(
1758            Amount::new(dec!(10), "AAPL"),
1759            Cost::new(dec!(200), "USD"),
1760        ));
1761
1762        // Average cost: (1000 + 1500 + 2000) / 30 = 150 USD
1763        let merge_spec = CostSpec::empty().with_merge();
1764        let result = inv
1765            .reduce(
1766                &Amount::new(dec!(-6), "AAPL"),
1767                Some(&merge_spec),
1768                BookingMethod::Strict,
1769            )
1770            .expect("three-lot merge should succeed");
1771
1772        assert_eq!(result.cost_basis, Some(Amount::new(dec!(900), "USD")));
1773        assert_eq!(inv.positions.len(), 1);
1774        assert_eq!(inv.positions[0].units.number, dec!(24));
1775        let cost = inv.positions[0].cost.as_ref().expect("should have cost");
1776        assert_eq!(cost.number, dec!(150));
1777    }
1778
1779    #[test]
1780    fn test_reduce_merge_mixed_cost_currencies_errors() {
1781        // Lots with different cost currencies cannot be merged
1782        let mut inv = Inventory::new();
1783        inv.add(Position::with_cost(
1784            Amount::new(dec!(10), "AAPL"),
1785            Cost::new(dec!(150), "USD"),
1786        ));
1787        inv.add(Position::with_cost(
1788            Amount::new(dec!(10), "AAPL"),
1789            Cost::new(dec!(130), "EUR"),
1790        ));
1791
1792        let merge_spec = CostSpec::empty().with_merge();
1793        let result = inv.reduce(
1794            &Amount::new(dec!(-5), "AAPL"),
1795            Some(&merge_spec),
1796            BookingMethod::Strict,
1797        );
1798
1799        assert!(
1800            matches!(result, Err(BookingError::CurrencyMismatch { .. })),
1801            "expected CurrencyMismatch, got {result:?}"
1802        );
1803    }
1804
1805    #[test]
1806    fn test_reduce_merge_empty_inventory() {
1807        let mut inv = Inventory::new();
1808
1809        let merge_spec = CostSpec::empty().with_merge();
1810        let result = inv.reduce(
1811            &Amount::new(dec!(-5), "AAPL"),
1812            Some(&merge_spec),
1813            BookingMethod::Strict,
1814        );
1815
1816        assert!(matches!(
1817            result,
1818            Err(BookingError::InsufficientUnits { .. })
1819        ));
1820    }
1821
1822    #[test]
1823    fn test_inventory_display_sorted() {
1824        let mut inv = Inventory::new();
1825
1826        // Add in non-alphabetical order
1827        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1828        inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
1829        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
1830
1831        let display = format!("{inv}");
1832
1833        // Should be sorted alphabetically: AAPL, EUR, USD
1834        let aapl_pos = display.find("AAPL").unwrap();
1835        let eur_pos = display.find("EUR").unwrap();
1836        let usd_pos = display.find("USD").unwrap();
1837
1838        assert!(aapl_pos < eur_pos);
1839        assert!(eur_pos < usd_pos);
1840    }
1841
1842    #[test]
1843    fn test_inventory_with_cost_display_sorted() {
1844        let mut inv = Inventory::new();
1845
1846        // Add same currency with different costs
1847        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
1848        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1849
1850        inv.add(Position::with_cost(
1851            Amount::new(dec!(10), "AAPL"),
1852            cost_high,
1853        ));
1854        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1855
1856        let display = format!("{inv}");
1857
1858        // Both positions should be in the output
1859        assert!(display.contains("AAPL"));
1860        assert!(display.contains("100"));
1861        assert!(display.contains("200"));
1862    }
1863
1864    #[test]
1865    fn test_reduce_hifo_no_matching_lot() {
1866        let mut inv = Inventory::new();
1867
1868        // No AAPL positions
1869        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1870
1871        let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
1872
1873        assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1874    }
1875
1876    #[test]
1877    fn test_fifo_respects_dates() {
1878        // Ensure FIFO uses acquisition date, not insertion order
1879        let mut inv = Inventory::new();
1880
1881        // Add newer lot first (out of order)
1882        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1883        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1884
1885        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1886        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1887
1888        // FIFO should reduce from oldest (cost 100) first
1889        let result = inv
1890            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
1891            .unwrap();
1892
1893        // Should use cost from oldest lot (100)
1894        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1895    }
1896
1897    #[test]
1898    fn test_lifo_respects_dates() {
1899        // Ensure LIFO uses acquisition date, not insertion order
1900        let mut inv = Inventory::new();
1901
1902        // Add older lot first
1903        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1904        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1905
1906        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1907        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1908
1909        // LIFO should reduce from newest (cost 200) first
1910        let result = inv
1911            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
1912            .unwrap();
1913
1914        // Should use cost from newest lot (200)
1915        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1916    }
1917
1918    // =========================================================================
1919    // Booking method coverage tests
1920    //
1921    // These tests cover gaps identified during the spring 2026 audit:
1922    // - STRICT_WITH_SIZE: cost spec + exact-size, multiple exact-size matches
1923    // - HIFO: multi-lot ordering, partial reduction, cost spec filtering
1924    // - AVERAGE: weighted average with different costs, partial reduction preserves cost
1925    // - NONE: with cost positions, short position reduction
1926    // =========================================================================
1927
1928    // --- STRICT_WITH_SIZE ---
1929
1930    #[test]
1931    fn test_strict_with_size_different_costs_exact_match() {
1932        // When lots have different costs but one matches the reduction size exactly,
1933        // STRICT_WITH_SIZE should pick that lot instead of raising AmbiguousMatch
1934        let mut inv = Inventory::new();
1935
1936        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1937        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1938
1939        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1940        inv.add(Position::with_cost(Amount::new(dec!(7), "AAPL"), cost2));
1941
1942        // Reduce exactly 7 - should match the 7-share lot at cost 200
1943        let result = inv
1944            .reduce(
1945                &Amount::new(dec!(-7), "AAPL"),
1946                None,
1947                BookingMethod::StrictWithSize,
1948            )
1949            .unwrap();
1950
1951        assert_eq!(inv.units("AAPL"), dec!(10));
1952        assert_eq!(result.cost_basis.unwrap().number, dec!(1400.00)); // 7 * 200
1953    }
1954
1955    #[test]
1956    fn test_strict_with_size_multiple_exact_matches_picks_oldest() {
1957        // When multiple lots have the exact same size, STRICT_WITH_SIZE should
1958        // pick the oldest one (first in index order)
1959        let mut inv = Inventory::new();
1960
1961        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1962        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 6, 1));
1963
1964        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost1));
1965        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1966
1967        // Both lots are size 5 — should pick the first (oldest) one
1968        let result = inv
1969            .reduce(
1970                &Amount::new(dec!(-5), "AAPL"),
1971                None,
1972                BookingMethod::StrictWithSize,
1973            )
1974            .unwrap();
1975
1976        assert_eq!(inv.units("AAPL"), dec!(5));
1977        // Should use cost from the oldest lot (100)
1978        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1979    }
1980
1981    #[test]
1982    fn test_strict_with_size_with_cost_spec() {
1983        // Cost spec should filter lots before exact-size matching
1984        let mut inv = Inventory::new();
1985
1986        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1987        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1988
1989        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1990        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1991
1992        // With cost spec filtering to the 200 USD lot, should find unique match
1993        let spec = CostSpec::empty().with_number(crate::CostNumber::PerUnit {
1994            value: dec!(200.00),
1995        });
1996        let result = inv
1997            .reduce(
1998                &Amount::new(dec!(-5), "AAPL"),
1999                Some(&spec),
2000                BookingMethod::StrictWithSize,
2001            )
2002            .unwrap();
2003
2004        assert_eq!(inv.units("AAPL"), dec!(15));
2005        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
2006    }
2007
2008    // --- HIFO ---
2009
2010    #[test]
2011    fn test_hifo_reduces_highest_cost_first() {
2012        // HIFO should reduce the highest-cost lot first, regardless of date
2013        let mut inv = Inventory::new();
2014
2015        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2016        let cost_mid = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
2017        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2018
2019        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
2020        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
2021        inv.add(Position::with_cost(
2022            Amount::new(dec!(10), "AAPL"),
2023            cost_high,
2024        ));
2025
2026        // Reduce 5 — should come from highest cost lot (200)
2027        let result = inv
2028            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Hifo)
2029            .unwrap();
2030
2031        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
2032        assert_eq!(inv.units("AAPL"), dec!(25));
2033    }
2034
2035    #[test]
2036    fn test_hifo_spans_multiple_lots() {
2037        // When reducing more than the highest-cost lot holds, HIFO should
2038        // continue to the next highest
2039        let mut inv = Inventory::new();
2040
2041        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2042        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2043
2044        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
2045        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_high));
2046
2047        // Reduce 8: 5 from high (200) + 3 from low (100)
2048        let result = inv
2049            .reduce(&Amount::new(dec!(-8), "AAPL"), None, BookingMethod::Hifo)
2050            .unwrap();
2051
2052        // Cost basis: 5*200 + 3*100 = 1300
2053        assert_eq!(result.cost_basis.unwrap().number, dec!(1300.00));
2054        assert_eq!(inv.units("AAPL"), dec!(2));
2055    }
2056
2057    #[test]
2058    fn test_hifo_with_cost_spec_filter() {
2059        // Cost spec should filter lots before HIFO ordering
2060        let mut inv = Inventory::new();
2061
2062        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2063        let cost2 = Cost::new(dec!(200.00), "EUR").with_date(date(2024, 2, 1));
2064
2065        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2066        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2067
2068        // Filter to USD lots only
2069        let spec = CostSpec::empty().with_currency("USD");
2070        let result = inv
2071            .reduce(
2072                &Amount::new(dec!(-5), "AAPL"),
2073                Some(&spec),
2074                BookingMethod::Hifo,
2075            )
2076            .unwrap();
2077
2078        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); // 5 * 100 USD
2079    }
2080
2081    #[test]
2082    fn test_hifo_short_position() {
2083        // HIFO with short positions: covering shorts should work correctly
2084        let mut inv = Inventory::new();
2085
2086        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2087        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2088
2089        // Short positions (negative units)
2090        inv.add(Position::with_cost(
2091            Amount::new(dec!(-10), "AAPL"),
2092            cost_low,
2093        ));
2094        inv.add(Position::with_cost(
2095            Amount::new(dec!(-10), "AAPL"),
2096            cost_high,
2097        ));
2098
2099        // Cover 5 shares (positive = reduce short position)
2100        // HIFO should pick the highest-cost short lot (200)
2101        let result = inv
2102            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Hifo)
2103            .unwrap();
2104
2105        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
2106        assert_eq!(inv.units("AAPL"), dec!(-15));
2107    }
2108
2109    // --- AVERAGE ---
2110
2111    #[test]
2112    fn test_average_weighted_cost() {
2113        // AVERAGE should compute weighted average across lots with different costs
2114        let mut inv = Inventory::new();
2115
2116        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2117        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2118
2119        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2120        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2121
2122        // Average cost = (10*100 + 10*200) / 20 = 150
2123        let result = inv
2124            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
2125            .unwrap();
2126
2127        // Cost basis: 5 * 150 = 750
2128        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
2129        assert_eq!(inv.units("AAPL"), dec!(15));
2130    }
2131
2132    #[test]
2133    fn test_average_merges_into_single_position() {
2134        // After AVERAGE reduction, inventory should have a single simple position
2135        let mut inv = Inventory::new();
2136
2137        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2138        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2139
2140        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2141        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2142
2143        inv.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
2144            .unwrap();
2145
2146        // Should have exactly one AAPL position remaining
2147        let aapl_positions: Vec<_> = inv
2148            .positions
2149            .iter()
2150            .filter(|p| p.units.currency.as_ref() == "AAPL")
2151            .collect();
2152        assert_eq!(aapl_positions.len(), 1);
2153        assert_eq!(aapl_positions[0].units.number, dec!(15));
2154    }
2155
2156    #[test]
2157    fn test_average_uneven_lots() {
2158        // Weighted average with unequal lot sizes
2159        let mut inv = Inventory::new();
2160
2161        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2162        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2163
2164        inv.add(Position::with_cost(Amount::new(dec!(30), "AAPL"), cost1));
2165        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2166
2167        // Average cost = (30*100 + 10*200) / 40 = 5000/40 = 125
2168        let result = inv
2169            .reduce(
2170                &Amount::new(dec!(-10), "AAPL"),
2171                None,
2172                BookingMethod::Average,
2173            )
2174            .unwrap();
2175
2176        assert_eq!(result.cost_basis.unwrap().number, dec!(1250.00)); // 10 * 125
2177    }
2178
2179    // --- NONE ---
2180
2181    #[test]
2182    fn test_none_booking_with_cost_positions() {
2183        // NONE booking should work even when positions have costs
2184        let mut inv = Inventory::new();
2185
2186        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2187        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
2188
2189        let result = inv
2190            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::None)
2191            .unwrap();
2192
2193        assert_eq!(inv.units("AAPL"), dec!(5));
2194        // NONE delegates to reduce_ordered (FIFO) internally, so cost basis is computed
2195        assert!(result.cost_basis.is_some());
2196        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
2197    }
2198
2199    #[test]
2200    fn test_none_booking_short_cover() {
2201        // Covering a short position with NONE booking
2202        let mut inv = Inventory::new();
2203        inv.add(Position::simple(Amount::new(dec!(-100), "USD")));
2204
2205        // Positive amount should reduce the negative position
2206        let result = inv
2207            .reduce(&Amount::new(dec!(30), "USD"), None, BookingMethod::None)
2208            .unwrap();
2209
2210        assert_eq!(inv.units("USD"), dec!(-70));
2211        assert!(!result.matched.is_empty());
2212    }
2213
2214    #[test]
2215    fn test_none_booking_empty_inventory_augments() {
2216        // NONE booking on empty inventory should augment
2217        let mut inv = Inventory::new();
2218
2219        let result = inv
2220            .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
2221            .unwrap();
2222
2223        assert_eq!(inv.units("USD"), dec!(50));
2224        assert!(result.matched.is_empty()); // Augmentation, not reduction
2225    }
2226
2227    // --- Cross-method: short positions ---
2228
2229    #[test]
2230    fn test_fifo_short_position_cover() {
2231        // FIFO: cover short positions (oldest short first)
2232        let mut inv = Inventory::new();
2233
2234        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2235        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2236
2237        inv.add(Position::with_cost(
2238            Amount::new(dec!(-10), "AAPL"),
2239            cost_old,
2240        ));
2241        inv.add(Position::with_cost(
2242            Amount::new(dec!(-10), "AAPL"),
2243            cost_new,
2244        ));
2245
2246        // Cover 5 shares — FIFO should pick oldest short (cost 100)
2247        let result = inv
2248            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Fifo)
2249            .unwrap();
2250
2251        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); // 5 * 100
2252        assert_eq!(inv.units("AAPL"), dec!(-15));
2253    }
2254
2255    #[test]
2256    fn test_lifo_short_position_cover() {
2257        // LIFO: cover short positions (newest short first)
2258        let mut inv = Inventory::new();
2259
2260        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2261        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2262
2263        inv.add(Position::with_cost(
2264            Amount::new(dec!(-10), "AAPL"),
2265            cost_old,
2266        ));
2267        inv.add(Position::with_cost(
2268            Amount::new(dec!(-10), "AAPL"),
2269            cost_new,
2270        ));
2271
2272        // Cover 5 shares — LIFO should pick newest short (cost 200)
2273        let result = inv
2274            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Lifo)
2275            .unwrap();
2276
2277        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
2278        assert_eq!(inv.units("AAPL"), dec!(-15));
2279    }
2280
2281    // === AccountedBookingError Display tests ===
2282    //
2283    // These tests pin the canonical user-facing wording for every variant
2284    // of `AccountedBookingError`. The whole point of unifying booking-error
2285    // Display into `rustledger-core` (#750) is that there's a single source
2286    // of truth — and a single source of truth with no tests is one refactor
2287    // away from drifting again, which is exactly the failure mode that
2288    // produced #748. Any change to the Display strings below will break
2289    // these tests, forcing the author to consciously re-check pta-standards
2290    // conformance assertions and downstream user tooling.
2291
2292    // =========================================================================
2293    // Regression test for issue #875 / beancount#889
2294    //
2295    // When a sell-without-cost-spec leaves a negative simple position in the
2296    // inventory, a subsequent augmentation WITH a cost spec should NOT be
2297    // misclassified as a reduction. `is_reduced_by` must only consider
2298    // cost-bearing positions when the incoming posting has a cost spec.
2299    // =========================================================================
2300
2301    #[test]
2302    fn test_is_reduced_by_ignores_simple_positions_when_has_cost_spec() {
2303        // Regression test for issue #875 / beancount#889.
2304        //
2305        // Scenario:
2306        //   1. Buy 100 HOOG {1.50 EUR}  -> inventory: [100 HOOG {1.50 EUR}]
2307        //   2. Sell 25 HOOG @ 1.60 EUR   -> inventory: [100 HOOG {1.50 EUR}, -25 HOOG (simple)]
2308        //   3. Buy 50 HOOG {1.70 EUR}    -> should be augmentation, NOT reduction
2309        //
2310        // Before fix: is_reduced_by saw the -25 HOOG simple position and
2311        // incorrectly reported that +50 HOOG would reduce the inventory.
2312        let mut inv = Inventory::new();
2313
2314        // Step 1: buy 100 HOOG with cost
2315        let cost = Cost::new(dec!(1.50), "EUR").with_date(date(2024, 1, 10));
2316        inv.add(Position::with_cost(Amount::new(dec!(100), "HOOG"), cost));
2317
2318        // Step 2: sell 25 HOOG without cost spec (simple position)
2319        inv.add(Position::simple(Amount::new(dec!(-25), "HOOG")));
2320
2321        // Step 3: check if buying 50 HOOG with cost spec would be a reduction
2322        let buy_units = Amount::new(dec!(50), "HOOG");
2323
2324        // With has_cost_spec=true, only cost-bearing positions should be
2325        // considered. The 100 HOOG {1.50 EUR} is positive and so is the
2326        // incoming 50 HOOG -> same sign -> NOT a reduction.
2327        assert!(
2328            !inv.is_reduced_by(&buy_units, ReductionScope::CostBearingOnly),
2329            "augmentation with cost spec should NOT be treated as reduction \
2330             when only a simple (no-cost) position has opposite sign"
2331        );
2332
2333        // With AllPositions, all positions are considered,
2334        // including the -25 HOOG simple position -> IS a reduction.
2335        assert!(
2336            inv.is_reduced_by(&buy_units, ReductionScope::AllPositions),
2337            "without cost spec filter, the -25 HOOG simple position \
2338             should cause is_reduced_by to return true"
2339        );
2340    }
2341
2342    #[test]
2343    fn is_booking_reduction_gates_on_method_cost_and_sign() {
2344        // A cost-bearing long position.
2345        let mut inv = Inventory::new();
2346        inv.add(Position::with_cost(
2347            Amount::new(dec!(10), "AAPL"),
2348            Cost::new(dec!(150), "USD").with_date(date(2024, 1, 1)),
2349        ));
2350
2351        let sell = Amount::new(dec!(-5), "AAPL"); // opposite sign of the held lot
2352        let buy = Amount::new(dec!(5), "AAPL"); // same sign
2353        let spec = CostSpec::empty(); // only spec *presence* (is_some) matters here
2354
2355        // Opposite-sign units carrying a cost spec under a lot-matching method
2356        // is the one combination that reduces.
2357        assert!(inv.is_booking_reduction(&sell, Some(&spec), BookingMethod::Strict));
2358        // NONE never reduces — every posting accumulates (#1182).
2359        assert!(!inv.is_booking_reduction(&sell, Some(&spec), BookingMethod::None));
2360        // No cost spec -> augmentation.
2361        assert!(!inv.is_booking_reduction(&sell, None, BookingMethod::Strict));
2362        // Same sign as the held lot -> augmentation.
2363        assert!(!inv.is_booking_reduction(&buy, Some(&spec), BookingMethod::Strict));
2364    }
2365
2366    #[test]
2367    fn sum_account_and_subaccounts_sums_children_not_prefix_siblings() {
2368        let mut bank = Inventory::new();
2369        bank.add(Position::simple(Amount::new(dec!(10), "USD")));
2370        let mut checking = Inventory::new(); // sub-account: included
2371        checking.add(Position::simple(Amount::new(dec!(40), "USD")));
2372        let mut alias = Inventory::new(); // prefix sibling: excluded
2373        alias.add(Position::simple(Amount::new(dec!(99), "USD")));
2374
2375        let mut map: FxHashMap<Account, Inventory> = FxHashMap::default();
2376        map.insert(Account::from("Assets:Bank"), bank);
2377        map.insert(Account::from("Assets:Bank:Checking"), checking);
2378        map.insert(Account::from("Assets:BankAlias"), alias);
2379
2380        let total = sum_account_and_subaccounts(map.iter(), "Assets:Bank", &Currency::from("USD"));
2381        assert_eq!(
2382            total,
2383            dec!(50),
2384            "parent (10) + sub-account (40), excluding the Assets:BankAlias prefix sibling"
2385        );
2386    }
2387
2388    #[test]
2389    fn test_accounted_error_display_insufficient_units() {
2390        let err = BookingError::InsufficientUnits {
2391            currency: "AAPL".into(),
2392            requested: dec!(15),
2393            available: dec!(10),
2394        }
2395        .with_account("Assets:Stock".into());
2396        let rendered = format!("{err}");
2397
2398        // Pinned by pta-standards `reduction-exceeds-inventory`
2399        // (`error_contains: ["not enough"]`). See #748 / #749.
2400        assert!(
2401            rendered.contains("not enough"),
2402            "must contain 'not enough' (pta-standards): {rendered}"
2403        );
2404        assert!(
2405            rendered.contains("Assets:Stock"),
2406            "must contain account name: {rendered}"
2407        );
2408        assert!(
2409            rendered.contains("15") && rendered.contains("10"),
2410            "must contain requested and available amounts: {rendered}"
2411        );
2412    }
2413
2414    #[test]
2415    fn test_accounted_error_display_no_matching_lot() {
2416        let err = BookingError::NoMatchingLot {
2417            currency: "AAPL".into(),
2418            cost_spec: CostSpec::empty(),
2419        }
2420        .with_account("Assets:Stock".into());
2421        let rendered = format!("{err}");
2422
2423        assert!(
2424            rendered.contains("No matching lot"),
2425            "must contain 'No matching lot': {rendered}"
2426        );
2427        assert!(
2428            rendered.contains("AAPL"),
2429            "must contain currency: {rendered}"
2430        );
2431        assert!(
2432            rendered.contains("Assets:Stock"),
2433            "must contain account name: {rendered}"
2434        );
2435    }
2436
2437    #[test]
2438    fn test_accounted_error_display_ambiguous_match() {
2439        let err = BookingError::AmbiguousMatch {
2440            num_matches: 3,
2441            currency: "AAPL".into(),
2442        }
2443        .with_account("Assets:Stock".into());
2444        let rendered = format!("{err}");
2445
2446        assert!(
2447            rendered.contains("Ambiguous"),
2448            "must contain 'Ambiguous': {rendered}"
2449        );
2450        assert!(
2451            rendered.contains("AAPL"),
2452            "must contain currency: {rendered}"
2453        );
2454        assert!(
2455            rendered.contains("Assets:Stock"),
2456            "must contain account name: {rendered}"
2457        );
2458        assert!(
2459            rendered.contains('3'),
2460            "must contain match count: {rendered}"
2461        );
2462    }
2463
2464    #[test]
2465    fn test_accounted_error_display_currency_mismatch_renders_as_no_matching_lot() {
2466        // CurrencyMismatch is semantically a specialization of NoMatchingLot
2467        // (there is no lot for the given currency in this inventory) and the
2468        // canonical Display collapses them into the same user-facing phrasing
2469        // so that consumers filtering on E4001 don't need to special-case it.
2470        // This variant is defensive — no `Inventory::reduce` path currently
2471        // emits it — but we still pin its rendering in case a future emission
2472        // site is added.
2473        let err = BookingError::CurrencyMismatch {
2474            expected: "USD".into(),
2475            got: "EUR".into(),
2476        }
2477        .with_account("Assets:Cash".into());
2478        let rendered = format!("{err}");
2479
2480        assert!(
2481            rendered.contains("No matching lot"),
2482            "CurrencyMismatch must render as 'No matching lot' for E4001 \
2483             consistency: {rendered}"
2484        );
2485        assert!(
2486            rendered.contains("EUR"),
2487            "must contain the mismatched (got) currency: {rendered}"
2488        );
2489        assert!(
2490            rendered.contains("Assets:Cash"),
2491            "must contain account name: {rendered}"
2492        );
2493    }
2494}