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::{Amount, CostSpec, Position};
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    /// Get the total book value (cost basis) for a currency.
462    ///
463    /// Returns the sum of all cost bases for positions of the given currency.
464    #[must_use]
465    pub fn book_value(&self, units_currency: &str) -> FxHashMap<crate::Currency, Decimal> {
466        let mut totals: FxHashMap<crate::Currency, Decimal> = FxHashMap::default();
467
468        for pos in &self.positions {
469            if pos.units.currency == units_currency
470                && let Some(book) = pos.book_value()
471            {
472                *totals.entry(book.currency.clone()).or_default() += book.number;
473            }
474        }
475
476        totals
477    }
478
479    /// Add a position to the inventory.
480    ///
481    /// For positions without cost, this merges with existing positions
482    /// of the same currency using O(1) `HashMap` lookup.
483    ///
484    /// For positions with cost, this adds as a new lot (O(1)).
485    /// Lot aggregation for display purposes is handled separately at output time
486    /// (e.g., in the query result formatter).
487    ///
488    /// # TLA+ Specification
489    ///
490    /// Implements `AddAmount` action from `Conservation.tla`:
491    /// - Invariant: `inventory + totalReduced = totalAdded`
492    /// - After add: `totalAdded' = totalAdded + amount`
493    ///
494    /// See: `spec/tla/Conservation.tla`
495    pub fn add(&mut self, position: Position) {
496        if position.is_empty() {
497            return;
498        }
499
500        // Update units cache
501        *self
502            .units_cache
503            .entry(position.units.currency.clone())
504            .or_default() += position.units.number;
505
506        // For positions without cost, use index for O(1) lookup
507        if position.cost.is_none() {
508            if let Some(&idx) = self.simple_index.get(&position.units.currency) {
509                // Merge with existing position
510                debug_assert!(self.positions[idx].cost.is_none());
511                self.positions[idx].units += &position.units;
512                return;
513            }
514            // No existing position - add new one and index it
515            let idx = self.positions.len();
516            self.simple_index
517                .insert(position.units.currency.clone(), idx);
518            self.positions.push_back(position);
519            return;
520        }
521
522        // For positions with cost, just add as a new lot.
523        // This is O(1) and keeps all lots separate, matching Python beancount behavior.
524        // Lot aggregation for display purposes is handled separately in query output.
525        self.positions.push_back(position);
526    }
527
528    /// Reduce positions from the inventory using the specified booking method.
529    ///
530    /// # Arguments
531    ///
532    /// * `units` - The units to reduce (negative for selling)
533    /// * `cost_spec` - Optional cost specification for matching lots
534    /// * `method` - The booking method to use
535    ///
536    /// # Returns
537    ///
538    /// Returns a `BookingResult` with the matched positions and cost basis,
539    /// or a `BookingError` if the reduction cannot be performed.
540    ///
541    /// # TLA+ Specification
542    ///
543    /// Implements `ReduceAmount` action from `Conservation.tla`:
544    /// - Invariant: `inventory + totalReduced = totalAdded`
545    /// - After reduce: `totalReduced' = totalReduced + amount`
546    /// - Precondition: `amount <= inventory` (else `InsufficientUnits` error)
547    ///
548    /// Lot selection follows these TLA+ specs based on `method`:
549    /// - `Fifo`: `FIFOCorrect.tla` - Oldest lots first (`selected_date <= all other dates`)
550    /// - `Lifo`: `LIFOCorrect.tla` - Newest lots first (`selected_date >= all other dates`)
551    /// - `Hifo`: `HIFOCorrect.tla` - Highest cost first (`selected_cost >= all other costs`)
552    ///
553    /// See: `spec/tla/Conservation.tla`, `spec/tla/FIFOCorrect.tla`, etc.
554    pub fn reduce(
555        &mut self,
556        units: &Amount,
557        cost_spec: Option<&CostSpec>,
558        method: BookingMethod,
559    ) -> Result<BookingResult, BookingError> {
560        let spec = cost_spec.cloned().unwrap_or_default();
561
562        // {*} merge operator: merge all lots into a single weighted-average-cost
563        // lot before reducing, regardless of the account's booking method.
564        if spec.merge {
565            return self.reduce_merge(units);
566        }
567
568        match method {
569            BookingMethod::Strict => self.reduce_strict(units, &spec),
570            BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
571            BookingMethod::Fifo => self.reduce_fifo(units, &spec),
572            BookingMethod::Lifo => self.reduce_lifo(units, &spec),
573            BookingMethod::Hifo => self.reduce_hifo(units, &spec),
574            BookingMethod::Average => self.reduce_average(units),
575            BookingMethod::None => self.reduce_none(units),
576        }
577    }
578
579    /// Remove all empty positions.
580    pub fn compact(&mut self) {
581        self.positions.retain(|p| !p.is_empty());
582        self.rebuild_index();
583    }
584
585    /// Rebuild all caches (`simple_index` and `units_cache`) from positions.
586    /// Called after operations that may invalidate caches (like retain or deserialization).
587    fn rebuild_index(&mut self) {
588        self.simple_index.clear();
589        self.units_cache.clear();
590
591        for (idx, pos) in self.positions.iter().enumerate() {
592            // Update units cache for all positions
593            *self
594                .units_cache
595                .entry(pos.units.currency.clone())
596                .or_default() += pos.units.number;
597
598            // Update simple_index only for positions without cost
599            if pos.cost.is_none() {
600                debug_assert!(
601                    !self.simple_index.contains_key(&pos.units.currency),
602                    "Invariant violated: multiple simple positions for currency {}",
603                    pos.units.currency
604                );
605                self.simple_index.insert(pos.units.currency.clone(), idx);
606            }
607        }
608    }
609
610    /// Merge this inventory with another.
611    pub fn merge(&mut self, other: &Self) {
612        for pos in &other.positions {
613            self.add(pos.clone());
614        }
615    }
616
617    /// Convert inventory to cost basis.
618    ///
619    /// Returns a new inventory where all positions are converted to their
620    /// cost basis. Positions without cost are returned as-is.
621    #[must_use]
622    pub fn at_cost(&self) -> Self {
623        let mut result = Self::new();
624
625        for pos in &self.positions {
626            if pos.is_empty() {
627                continue;
628            }
629
630            if let Some(cost) = &pos.cost {
631                // Convert to cost basis
632                let total = pos.units.number * cost.number;
633                result.add(Position::simple(Amount::new(total, &cost.currency)));
634            } else {
635                // No cost, keep as-is
636                result.add(pos.clone());
637            }
638        }
639
640        result
641    }
642
643    /// Convert inventory to units only.
644    ///
645    /// Returns a new inventory where all positions have their cost removed,
646    /// effectively aggregating by currency only.
647    #[must_use]
648    pub fn at_units(&self) -> Self {
649        let mut result = Self::new();
650
651        for pos in &self.positions {
652            if pos.is_empty() {
653                continue;
654            }
655
656            // Strip cost, keep only units
657            result.add(Position::simple(pos.units.clone()));
658        }
659
660        result
661    }
662}
663
664impl fmt::Display for Inventory {
665    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
666        if self.is_empty() {
667            return write!(f, "(empty)");
668        }
669
670        // Sort positions alphabetically by currency, then by cost for consistency
671        let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
672        non_empty.sort_by(|a, b| {
673            // First by currency
674            let cmp = a.units.currency.cmp(&b.units.currency);
675            if cmp != std::cmp::Ordering::Equal {
676                return cmp;
677            }
678            // Then by cost (if present)
679            match (&a.cost, &b.cost) {
680                (Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
681                (Some(_), None) => std::cmp::Ordering::Greater,
682                (None, Some(_)) => std::cmp::Ordering::Less,
683                (None, None) => std::cmp::Ordering::Equal,
684            }
685        });
686
687        for (i, pos) in non_empty.iter().enumerate() {
688            if i > 0 {
689                write!(f, ", ")?;
690            }
691            write!(f, "{pos}")?;
692        }
693        Ok(())
694    }
695}
696
697impl FromIterator<Position> for Inventory {
698    fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
699        let mut inv = Self::new();
700        for pos in iter {
701            inv.add(pos);
702        }
703        inv
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    use super::*;
710    use crate::Cost;
711    use crate::NaiveDate;
712    use rust_decimal_macros::dec;
713
714    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
715        crate::naive_date(year, month, day).unwrap()
716    }
717
718    #[test]
719    fn test_empty_inventory() {
720        let inv = Inventory::new();
721        assert!(inv.is_empty());
722        assert_eq!(inv.len(), 0);
723    }
724
725    #[test]
726    fn test_add_simple() {
727        let mut inv = Inventory::new();
728        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
729
730        assert!(!inv.is_empty());
731        assert_eq!(inv.units("USD"), dec!(100));
732    }
733
734    #[test]
735    fn test_add_merge_simple() {
736        let mut inv = Inventory::new();
737        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
738        inv.add(Position::simple(Amount::new(dec!(50), "USD")));
739
740        // Should merge into one position
741        assert_eq!(inv.len(), 1);
742        assert_eq!(inv.units("USD"), dec!(150));
743    }
744
745    #[test]
746    fn test_add_with_cost_no_merge() {
747        let mut inv = Inventory::new();
748
749        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
750        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
751
752        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
753        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
754
755        // Should NOT merge - different costs
756        assert_eq!(inv.len(), 2);
757        assert_eq!(inv.units("AAPL"), dec!(15));
758    }
759
760    #[test]
761    fn test_currencies() {
762        let mut inv = Inventory::new();
763        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
764        inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
765        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
766
767        let currencies = inv.currencies();
768        assert_eq!(currencies.len(), 3);
769        assert!(currencies.contains(&"USD"));
770        assert!(currencies.contains(&"EUR"));
771        assert!(currencies.contains(&"AAPL"));
772    }
773
774    #[test]
775    fn test_reduce_strict_unique() {
776        let mut inv = Inventory::new();
777        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
778        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
779
780        let result = inv
781            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
782            .unwrap();
783
784        assert_eq!(inv.units("AAPL"), dec!(5));
785        assert!(result.cost_basis.is_some());
786        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); // 5 * 150
787    }
788
789    #[test]
790    fn test_reduce_strict_multiple_match_with_different_costs_is_ambiguous() {
791        let mut inv = Inventory::new();
792
793        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
794        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
795
796        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
797        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
798
799        // Per Python beancount: a wildcard reduction (`-3 AAPL` with no cost
800        // spec) against an inventory with lots at different costs is
801        // genuinely ambiguous and must error. Issue #737.
802        let result = inv.reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict);
803
804        assert!(
805            matches!(result, Err(BookingError::AmbiguousMatch { .. })),
806            "expected AmbiguousMatch, got {result:?}"
807        );
808        // Inventory unchanged after a failed reduction
809        assert_eq!(inv.units("AAPL"), dec!(15));
810    }
811
812    #[test]
813    fn test_reduce_strict_multiple_match_with_identical_costs_uses_fifo() {
814        let mut inv = Inventory::new();
815
816        // Two lots with identical cost — interchangeable, so FIFO is fine.
817        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
818
819        inv.add(Position::with_cost(
820            Amount::new(dec!(10), "AAPL"),
821            cost.clone(),
822        ));
823        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
824
825        let result = inv
826            .reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
827            .expect("identical lots should fall back to FIFO without error");
828
829        assert_eq!(inv.units("AAPL"), dec!(12));
830        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00));
831    }
832
833    #[test]
834    fn test_reduce_strict_multiple_match_different_dates_same_cost_uses_fifo() {
835        let mut inv = Inventory::new();
836
837        // Two lots at the same cost number but different acquisition dates.
838        // The user's cost spec could not have constrained the date without
839        // naming it, so the lots are interchangeable for the spec — FIFO.
840        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
841        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 15));
842
843        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
844        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
845
846        let result = inv
847            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
848            .expect("same cost number, different dates should fall back to FIFO");
849
850        assert_eq!(inv.units("AAPL"), dec!(15));
851        // Reduced from the first (oldest) lot at 150.00 USD: 5 * 150 = 750.
852        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
853    }
854
855    #[test]
856    fn test_reduce_strict_multiple_match_total_match_exception() {
857        let mut inv = Inventory::new();
858
859        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
860        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
861
862        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
863        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
864
865        // Selling exactly the entire inventory (10 + 5 = 15) is unambiguous
866        // even with mixed costs — the user is liquidating the position.
867        let result = inv
868            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Strict)
869            .expect("total-match exception should accept a full liquidation");
870
871        assert_eq!(inv.units("AAPL"), dec!(0));
872        // Cost basis = 10*150 + 5*160 = 1500 + 800 = 2300
873        assert_eq!(result.cost_basis.unwrap().number, dec!(2300.00));
874    }
875
876    #[test]
877    fn test_reduce_strict_with_spec() {
878        let mut inv = Inventory::new();
879
880        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
881        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
882
883        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
884        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
885
886        // Reducing with cost spec should work
887        let spec = CostSpec::empty().with_date(date(2024, 1, 1));
888        let result = inv
889            .reduce(
890                &Amount::new(dec!(-3), "AAPL"),
891                Some(&spec),
892                BookingMethod::Strict,
893            )
894            .unwrap();
895
896        assert_eq!(inv.units("AAPL"), dec!(12)); // 7 + 5
897        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); // 3 * 150
898    }
899
900    #[test]
901    fn test_reduce_fifo() {
902        let mut inv = Inventory::new();
903
904        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
905        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
906        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
907
908        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
909        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
910        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
911
912        // FIFO should reduce from oldest (cost 100) first
913        let result = inv
914            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
915            .unwrap();
916
917        assert_eq!(inv.units("AAPL"), dec!(15));
918        // Cost basis: 10 * 100 + 5 * 150 = 1000 + 750 = 1750
919        assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
920    }
921
922    #[test]
923    fn test_reduce_lifo() {
924        let mut inv = Inventory::new();
925
926        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
927        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
928        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
929
930        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
931        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
932        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
933
934        // LIFO should reduce from newest (cost 200) first
935        let result = inv
936            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
937            .unwrap();
938
939        assert_eq!(inv.units("AAPL"), dec!(15));
940        // Cost basis: 10 * 200 + 5 * 150 = 2000 + 750 = 2750
941        assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
942    }
943
944    #[test]
945    fn test_reduce_insufficient() {
946        let mut inv = Inventory::new();
947        let cost = Cost::new(dec!(150.00), "USD");
948        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
949
950        let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
951
952        assert!(matches!(
953            result,
954            Err(BookingError::InsufficientUnits { .. })
955        ));
956    }
957
958    #[test]
959    fn test_book_value() {
960        let mut inv = Inventory::new();
961
962        let cost1 = Cost::new(dec!(100.00), "USD");
963        let cost2 = Cost::new(dec!(150.00), "USD");
964
965        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
966        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
967
968        let book = inv.book_value("AAPL");
969        assert_eq!(book.get("USD"), Some(&dec!(1750.00))); // 10*100 + 5*150
970    }
971
972    #[test]
973    fn test_display() {
974        let mut inv = Inventory::new();
975        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
976
977        let s = format!("{inv}");
978        assert!(s.contains("100 USD"));
979    }
980
981    #[test]
982    fn test_display_empty() {
983        let inv = Inventory::new();
984        assert_eq!(format!("{inv}"), "(empty)");
985    }
986
987    #[test]
988    fn test_from_iterator() {
989        let positions = vec![
990            Position::simple(Amount::new(dec!(100), "USD")),
991            Position::simple(Amount::new(dec!(50), "USD")),
992        ];
993
994        let inv: Inventory = positions.into_iter().collect();
995        assert_eq!(inv.units("USD"), dec!(150));
996    }
997
998    #[test]
999    fn test_add_costed_positions_kept_separate() {
1000        // Costed positions are kept as separate lots for O(1) add performance.
1001        // Aggregation happens at display time (in query output).
1002        let mut inv = Inventory::new();
1003
1004        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1005
1006        // Buy 10 shares
1007        inv.add(Position::with_cost(
1008            Amount::new(dec!(10), "AAPL"),
1009            cost.clone(),
1010        ));
1011        assert_eq!(inv.len(), 1);
1012        assert_eq!(inv.units("AAPL"), dec!(10));
1013
1014        // Sell 10 shares - kept as separate lot for tracking
1015        inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1016        assert_eq!(inv.len(), 2); // Both lots kept
1017        assert_eq!(inv.units("AAPL"), dec!(0)); // Net units still zero
1018    }
1019
1020    #[test]
1021    fn test_add_costed_positions_net_units() {
1022        // Verify that units() correctly sums across all lots
1023        let mut inv = Inventory::new();
1024
1025        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1026
1027        // Buy 10 shares
1028        inv.add(Position::with_cost(
1029            Amount::new(dec!(10), "AAPL"),
1030            cost.clone(),
1031        ));
1032
1033        // Sell 3 shares - kept as separate lot
1034        inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost));
1035        assert_eq!(inv.len(), 2); // Both lots kept
1036        assert_eq!(inv.units("AAPL"), dec!(7)); // Net units correct
1037    }
1038
1039    #[test]
1040    fn test_add_no_cancel_different_cost() {
1041        // Test that different costs don't cancel
1042        let mut inv = Inventory::new();
1043
1044        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1045        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
1046
1047        // Buy 10 shares at 150
1048        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1049
1050        // Sell 5 shares at 160 - should NOT cancel (different cost)
1051        inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2));
1052
1053        // Should have two separate lots
1054        assert_eq!(inv.len(), 2);
1055        assert_eq!(inv.units("AAPL"), dec!(5)); // 10 - 5 = 5 total
1056    }
1057
1058    #[test]
1059    fn test_add_no_cancel_same_sign() {
1060        // Test that same-sign positions don't merge even with same cost
1061        let mut inv = Inventory::new();
1062
1063        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1064
1065        // Buy 10 shares
1066        inv.add(Position::with_cost(
1067            Amount::new(dec!(10), "AAPL"),
1068            cost.clone(),
1069        ));
1070
1071        // Buy 5 more shares with same cost - should NOT merge
1072        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
1073
1074        // Should have two separate lots (different acquisitions)
1075        assert_eq!(inv.len(), 2);
1076        assert_eq!(inv.units("AAPL"), dec!(15));
1077    }
1078
1079    #[test]
1080    fn test_merge_keeps_lots_separate() {
1081        // Test that merge keeps costed lots separate (aggregation at display time)
1082        let mut inv1 = Inventory::new();
1083        let mut inv2 = Inventory::new();
1084
1085        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1086
1087        // inv1: buy 10 shares
1088        inv1.add(Position::with_cost(
1089            Amount::new(dec!(10), "AAPL"),
1090            cost.clone(),
1091        ));
1092
1093        // inv2: sell 10 shares
1094        inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1095
1096        // Merge keeps both lots, net units is zero
1097        inv1.merge(&inv2);
1098        assert_eq!(inv1.len(), 2); // Both lots preserved
1099        assert_eq!(inv1.units("AAPL"), dec!(0)); // Net units correct
1100    }
1101
1102    // ====================================================================
1103    // Phase 2: Additional Coverage Tests for Booking Methods
1104    // ====================================================================
1105
1106    #[test]
1107    fn test_hifo_with_tie_breaking() {
1108        // When multiple lots have the same cost, HIFO should use insertion order
1109        let mut inv = Inventory::new();
1110
1111        // Three lots with same cost but different dates
1112        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1113        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1114        let cost3 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 3, 1));
1115
1116        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1117        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1118        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1119
1120        // HIFO with tied costs should reduce in some deterministic order
1121        let result = inv
1122            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
1123            .unwrap();
1124
1125        assert_eq!(inv.units("AAPL"), dec!(15));
1126        // All at same cost, so 15 * 100 = 1500
1127        assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1128    }
1129
1130    #[test]
1131    fn test_hifo_with_different_costs() {
1132        // HIFO should reduce highest cost lots first
1133        let mut inv = Inventory::new();
1134
1135        let cost_low = Cost::new(dec!(50.00), "USD").with_date(date(2024, 1, 1));
1136        let cost_mid = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1137        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1138
1139        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
1140        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
1141        inv.add(Position::with_cost(
1142            Amount::new(dec!(10), "AAPL"),
1143            cost_high,
1144        ));
1145
1146        // Reduce 15 shares - should take from highest cost (200) first
1147        let result = inv
1148            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
1149            .unwrap();
1150
1151        assert_eq!(inv.units("AAPL"), dec!(15));
1152        // 10 * 200 + 5 * 100 = 2000 + 500 = 2500
1153        assert_eq!(result.cost_basis.unwrap().number, dec!(2500.00));
1154    }
1155
1156    #[test]
1157    fn test_average_booking_with_pre_existing_positions() {
1158        let mut inv = Inventory::new();
1159
1160        // Add two lots with different costs
1161        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1162        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1163
1164        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1165        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1166
1167        // Total: 20 shares, total cost = 10*100 + 10*200 = 3000, avg = 150/share
1168        // Reduce 5 shares using AVERAGE
1169        let result = inv
1170            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
1171            .unwrap();
1172
1173        assert_eq!(inv.units("AAPL"), dec!(15));
1174        // Cost basis for 5 shares at average 150 = 750
1175        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
1176    }
1177
1178    #[test]
1179    fn test_average_booking_reduces_all() {
1180        let mut inv = Inventory::new();
1181
1182        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1183        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1184
1185        // Reduce all shares
1186        let result = inv
1187            .reduce(
1188                &Amount::new(dec!(-10), "AAPL"),
1189                None,
1190                BookingMethod::Average,
1191            )
1192            .unwrap();
1193
1194        assert!(inv.is_empty() || inv.units("AAPL").is_zero());
1195        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1196    }
1197
1198    #[test]
1199    fn test_none_booking_augmentation() {
1200        // NONE booking with same-sign amounts should augment, not reduce
1201        let mut inv = Inventory::new();
1202        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1203
1204        // Adding more (same sign) - this is an augmentation
1205        let result = inv
1206            .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
1207            .unwrap();
1208
1209        assert_eq!(inv.units("USD"), dec!(150));
1210        assert!(result.matched.is_empty()); // No lots matched for augmentation
1211        assert!(result.cost_basis.is_none());
1212    }
1213
1214    #[test]
1215    fn test_none_booking_reduction() {
1216        // NONE booking with opposite-sign should reduce
1217        let mut inv = Inventory::new();
1218        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1219
1220        let result = inv
1221            .reduce(&Amount::new(dec!(-30), "USD"), None, BookingMethod::None)
1222            .unwrap();
1223
1224        assert_eq!(inv.units("USD"), dec!(70));
1225        assert!(!result.matched.is_empty());
1226    }
1227
1228    #[test]
1229    fn test_none_booking_insufficient() {
1230        let mut inv = Inventory::new();
1231        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1232
1233        let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
1234
1235        assert!(matches!(
1236            result,
1237            Err(BookingError::InsufficientUnits { .. })
1238        ));
1239    }
1240
1241    #[test]
1242    fn test_booking_error_no_matching_lot() {
1243        let mut inv = Inventory::new();
1244
1245        // Add a lot with specific cost
1246        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1247        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1248
1249        // Try to reduce with a cost spec that doesn't match
1250        let wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
1251        let result = inv.reduce(
1252            &Amount::new(dec!(-5), "AAPL"),
1253            Some(&wrong_spec),
1254            BookingMethod::Strict,
1255        );
1256
1257        assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1258    }
1259
1260    #[test]
1261    fn test_booking_error_insufficient_units() {
1262        let mut inv = Inventory::new();
1263
1264        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1265        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1266
1267        // Try to reduce more than available
1268        let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
1269
1270        match result {
1271            Err(BookingError::InsufficientUnits {
1272                requested,
1273                available,
1274                ..
1275            }) => {
1276                assert_eq!(requested, dec!(20));
1277                assert_eq!(available, dec!(10));
1278            }
1279            _ => panic!("Expected InsufficientUnits error"),
1280        }
1281    }
1282
1283    #[test]
1284    fn test_strict_with_size_exact_match() {
1285        let mut inv = Inventory::new();
1286
1287        // Add two lots with same cost but different sizes
1288        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1289        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1290
1291        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1292        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1293
1294        // Reduce exactly 5 - should match the 5-share lot
1295        let result = inv
1296            .reduce(
1297                &Amount::new(dec!(-5), "AAPL"),
1298                None,
1299                BookingMethod::StrictWithSize,
1300            )
1301            .unwrap();
1302
1303        assert_eq!(inv.units("AAPL"), dec!(10));
1304        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1305    }
1306
1307    #[test]
1308    fn test_strict_with_size_total_match() {
1309        let mut inv = Inventory::new();
1310
1311        // Add two lots
1312        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1313        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1314
1315        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1316        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1317
1318        // Reduce exactly 15 (total) - should succeed via total match exception
1319        let result = inv
1320            .reduce(
1321                &Amount::new(dec!(-15), "AAPL"),
1322                None,
1323                BookingMethod::StrictWithSize,
1324            )
1325            .unwrap();
1326
1327        assert_eq!(inv.units("AAPL"), dec!(0));
1328        assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1329    }
1330
1331    #[test]
1332    fn test_strict_with_size_ambiguous() {
1333        let mut inv = Inventory::new();
1334
1335        // Add two lots of same size and cost
1336        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1337        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1338
1339        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1340        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1341
1342        // Reduce 7 shares - doesn't match either lot exactly, not total
1343        let result = inv.reduce(
1344            &Amount::new(dec!(-7), "AAPL"),
1345            None,
1346            BookingMethod::StrictWithSize,
1347        );
1348
1349        assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
1350    }
1351
1352    #[test]
1353    fn test_short_position() {
1354        // Test short selling (negative positions)
1355        let mut inv = Inventory::new();
1356
1357        // Short 10 shares
1358        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1359        inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1360
1361        assert_eq!(inv.units("AAPL"), dec!(-10));
1362        assert!(!inv.is_empty());
1363    }
1364
1365    #[test]
1366    fn test_at_cost() {
1367        let mut inv = Inventory::new();
1368
1369        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1370        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1371
1372        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1373        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1374        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1375
1376        let at_cost = inv.at_cost();
1377
1378        // AAPL converted: 10*100 + 5*150 = 1000 + 750 = 1750 USD
1379        // Plus 100 USD simple position = 1850 USD total
1380        assert_eq!(at_cost.units("USD"), dec!(1850));
1381        assert_eq!(at_cost.units("AAPL"), dec!(0)); // No AAPL in cost view
1382    }
1383
1384    #[test]
1385    fn test_at_units() {
1386        let mut inv = Inventory::new();
1387
1388        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1389        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1390
1391        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1392        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1393
1394        let at_units = inv.at_units();
1395
1396        // All AAPL lots merged
1397        assert_eq!(at_units.units("AAPL"), dec!(15));
1398        // Should only have one position after aggregation
1399        assert_eq!(at_units.len(), 1);
1400    }
1401
1402    #[test]
1403    fn test_add_empty_position() {
1404        let mut inv = Inventory::new();
1405        inv.add(Position::simple(Amount::new(dec!(0), "USD")));
1406
1407        assert!(inv.is_empty());
1408        assert_eq!(inv.len(), 0);
1409    }
1410
1411    #[test]
1412    fn test_compact() {
1413        let mut inv = Inventory::new();
1414
1415        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1416        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1417
1418        // Reduce all
1419        inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
1420            .unwrap();
1421
1422        // Compact to remove empty positions
1423        inv.compact();
1424        assert!(inv.is_empty());
1425        assert_eq!(inv.len(), 0);
1426    }
1427
1428    #[test]
1429    fn test_booking_method_from_str() {
1430        assert_eq!(
1431            BookingMethod::from_str("STRICT").unwrap(),
1432            BookingMethod::Strict
1433        );
1434        assert_eq!(
1435            BookingMethod::from_str("fifo").unwrap(),
1436            BookingMethod::Fifo
1437        );
1438        assert_eq!(
1439            BookingMethod::from_str("LIFO").unwrap(),
1440            BookingMethod::Lifo
1441        );
1442        assert_eq!(
1443            BookingMethod::from_str("Hifo").unwrap(),
1444            BookingMethod::Hifo
1445        );
1446        assert_eq!(
1447            BookingMethod::from_str("AVERAGE").unwrap(),
1448            BookingMethod::Average
1449        );
1450        assert_eq!(
1451            BookingMethod::from_str("NONE").unwrap(),
1452            BookingMethod::None
1453        );
1454        assert_eq!(
1455            BookingMethod::from_str("strict_with_size").unwrap(),
1456            BookingMethod::StrictWithSize
1457        );
1458        assert!(BookingMethod::from_str("INVALID").is_err());
1459    }
1460
1461    #[test]
1462    fn test_booking_method_display() {
1463        assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
1464        assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
1465        assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
1466        assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
1467        assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
1468        assert_eq!(format!("{}", BookingMethod::None), "NONE");
1469        assert_eq!(
1470            format!("{}", BookingMethod::StrictWithSize),
1471            "STRICT_WITH_SIZE"
1472        );
1473    }
1474
1475    #[test]
1476    fn test_booking_error_display() {
1477        let err = BookingError::AmbiguousMatch {
1478            num_matches: 3,
1479            currency: "AAPL".into(),
1480        };
1481        assert!(format!("{err}").contains("3 lots match"));
1482
1483        let err = BookingError::NoMatchingLot {
1484            currency: "AAPL".into(),
1485            cost_spec: CostSpec::empty(),
1486        };
1487        assert!(format!("{err}").contains("No matching lot"));
1488
1489        let err = BookingError::InsufficientUnits {
1490            currency: "AAPL".into(),
1491            requested: dec!(100),
1492            available: dec!(50),
1493        };
1494        assert!(format!("{err}").contains("requested 100"));
1495        assert!(format!("{err}").contains("available 50"));
1496
1497        let err = BookingError::CurrencyMismatch {
1498            expected: "USD".into(),
1499            got: "EUR".into(),
1500        };
1501        assert!(format!("{err}").contains("expected USD"));
1502        assert!(format!("{err}").contains("got EUR"));
1503    }
1504
1505    #[test]
1506    fn test_book_value_multiple_currencies() {
1507        let mut inv = Inventory::new();
1508
1509        // Cost in USD
1510        let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1511        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd));
1512
1513        // Cost in EUR
1514        let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
1515        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur));
1516
1517        let book = inv.book_value("AAPL");
1518        assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
1519        assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
1520    }
1521
1522    #[test]
1523    fn test_reduce_hifo_insufficient_units() {
1524        let mut inv = Inventory::new();
1525
1526        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1527        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1528
1529        let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
1530
1531        assert!(matches!(
1532            result,
1533            Err(BookingError::InsufficientUnits { .. })
1534        ));
1535    }
1536
1537    #[test]
1538    fn test_reduce_average_insufficient_units() {
1539        let mut inv = Inventory::new();
1540
1541        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1542        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1543
1544        let result = inv.reduce(
1545            &Amount::new(dec!(-20), "AAPL"),
1546            None,
1547            BookingMethod::Average,
1548        );
1549
1550        assert!(matches!(
1551            result,
1552            Err(BookingError::InsufficientUnits { .. })
1553        ));
1554    }
1555
1556    #[test]
1557    fn test_reduce_average_empty_inventory() {
1558        let mut inv = Inventory::new();
1559
1560        let result = inv.reduce(
1561            &Amount::new(dec!(-10), "AAPL"),
1562            None,
1563            BookingMethod::Average,
1564        );
1565
1566        assert!(matches!(
1567            result,
1568            Err(BookingError::InsufficientUnits { .. })
1569        ));
1570    }
1571
1572    #[test]
1573    fn test_reduce_merge_operator() {
1574        // {*} merge: two lots merged into weighted-average, then reduced
1575        let mut inv = Inventory::new();
1576        inv.add(Position::with_cost(
1577            Amount::new(dec!(10), "AAPL"),
1578            Cost::new(dec!(150), "USD"),
1579        ));
1580        inv.add(Position::with_cost(
1581            Amount::new(dec!(10), "AAPL"),
1582            Cost::new(dec!(160), "USD"),
1583        ));
1584
1585        let merge_spec = CostSpec::empty().with_merge();
1586        let result = inv
1587            .reduce(
1588                &Amount::new(dec!(-5), "AAPL"),
1589                Some(&merge_spec),
1590                BookingMethod::Strict,
1591            )
1592            .expect("merge reduction should succeed");
1593
1594        // Cost basis: 5 units * 155 USD average = 775 USD
1595        assert_eq!(result.cost_basis, Some(Amount::new(dec!(775), "USD")));
1596
1597        // Inventory should have a single merged lot with 15 remaining @ 155
1598        assert_eq!(inv.positions.len(), 1);
1599        assert_eq!(inv.positions[0].units.number, dec!(15));
1600        let cost = inv.positions[0].cost.as_ref().expect("should have cost");
1601        assert_eq!(cost.number, dec!(155));
1602    }
1603
1604    #[test]
1605    fn test_reduce_merge_insufficient_units() {
1606        let mut inv = Inventory::new();
1607        inv.add(Position::with_cost(
1608            Amount::new(dec!(10), "AAPL"),
1609            Cost::new(dec!(150), "USD"),
1610        ));
1611
1612        let merge_spec = CostSpec::empty().with_merge();
1613        let result = inv.reduce(
1614            &Amount::new(dec!(-20), "AAPL"),
1615            Some(&merge_spec),
1616            BookingMethod::Strict,
1617        );
1618
1619        assert!(matches!(
1620            result,
1621            Err(BookingError::InsufficientUnits { .. })
1622        ));
1623    }
1624
1625    #[test]
1626    fn test_reduce_merge_sells_all() {
1627        // Merge and sell entire position
1628        let mut inv = Inventory::new();
1629        inv.add(Position::with_cost(
1630            Amount::new(dec!(10), "AAPL"),
1631            Cost::new(dec!(150), "USD"),
1632        ));
1633        inv.add(Position::with_cost(
1634            Amount::new(dec!(10), "AAPL"),
1635            Cost::new(dec!(160), "USD"),
1636        ));
1637
1638        let merge_spec = CostSpec::empty().with_merge();
1639        let result = inv
1640            .reduce(
1641                &Amount::new(dec!(-20), "AAPL"),
1642                Some(&merge_spec),
1643                BookingMethod::Strict,
1644            )
1645            .expect("merge reduction should succeed");
1646
1647        // Cost basis: 20 * 155 = 3100 USD
1648        assert_eq!(result.cost_basis, Some(Amount::new(dec!(3100), "USD")));
1649
1650        // Inventory should be empty
1651        assert!(inv.positions.is_empty() || inv.positions.iter().all(Position::is_empty));
1652    }
1653
1654    #[test]
1655    fn test_reduce_merge_single_lot() {
1656        // {*} with a single lot should work trivially
1657        let mut inv = Inventory::new();
1658        inv.add(Position::with_cost(
1659            Amount::new(dec!(10), "AAPL"),
1660            Cost::new(dec!(150), "USD"),
1661        ));
1662
1663        let merge_spec = CostSpec::empty().with_merge();
1664        let result = inv
1665            .reduce(
1666                &Amount::new(dec!(-3), "AAPL"),
1667                Some(&merge_spec),
1668                BookingMethod::Strict,
1669            )
1670            .expect("single-lot merge should succeed");
1671
1672        assert_eq!(result.cost_basis, Some(Amount::new(dec!(450), "USD")));
1673        assert_eq!(inv.positions.len(), 1);
1674        assert_eq!(inv.positions[0].units.number, dec!(7));
1675    }
1676
1677    #[test]
1678    fn test_reduce_merge_three_lots() {
1679        // {*} with three lots at different costs
1680        let mut inv = Inventory::new();
1681        inv.add(Position::with_cost(
1682            Amount::new(dec!(10), "AAPL"),
1683            Cost::new(dec!(100), "USD"),
1684        ));
1685        inv.add(Position::with_cost(
1686            Amount::new(dec!(10), "AAPL"),
1687            Cost::new(dec!(150), "USD"),
1688        ));
1689        inv.add(Position::with_cost(
1690            Amount::new(dec!(10), "AAPL"),
1691            Cost::new(dec!(200), "USD"),
1692        ));
1693
1694        // Average cost: (1000 + 1500 + 2000) / 30 = 150 USD
1695        let merge_spec = CostSpec::empty().with_merge();
1696        let result = inv
1697            .reduce(
1698                &Amount::new(dec!(-6), "AAPL"),
1699                Some(&merge_spec),
1700                BookingMethod::Strict,
1701            )
1702            .expect("three-lot merge should succeed");
1703
1704        assert_eq!(result.cost_basis, Some(Amount::new(dec!(900), "USD")));
1705        assert_eq!(inv.positions.len(), 1);
1706        assert_eq!(inv.positions[0].units.number, dec!(24));
1707        let cost = inv.positions[0].cost.as_ref().expect("should have cost");
1708        assert_eq!(cost.number, dec!(150));
1709    }
1710
1711    #[test]
1712    fn test_reduce_merge_mixed_cost_currencies_errors() {
1713        // Lots with different cost currencies cannot be merged
1714        let mut inv = Inventory::new();
1715        inv.add(Position::with_cost(
1716            Amount::new(dec!(10), "AAPL"),
1717            Cost::new(dec!(150), "USD"),
1718        ));
1719        inv.add(Position::with_cost(
1720            Amount::new(dec!(10), "AAPL"),
1721            Cost::new(dec!(130), "EUR"),
1722        ));
1723
1724        let merge_spec = CostSpec::empty().with_merge();
1725        let result = inv.reduce(
1726            &Amount::new(dec!(-5), "AAPL"),
1727            Some(&merge_spec),
1728            BookingMethod::Strict,
1729        );
1730
1731        assert!(
1732            matches!(result, Err(BookingError::CurrencyMismatch { .. })),
1733            "expected CurrencyMismatch, got {result:?}"
1734        );
1735    }
1736
1737    #[test]
1738    fn test_reduce_merge_empty_inventory() {
1739        let mut inv = Inventory::new();
1740
1741        let merge_spec = CostSpec::empty().with_merge();
1742        let result = inv.reduce(
1743            &Amount::new(dec!(-5), "AAPL"),
1744            Some(&merge_spec),
1745            BookingMethod::Strict,
1746        );
1747
1748        assert!(matches!(
1749            result,
1750            Err(BookingError::InsufficientUnits { .. })
1751        ));
1752    }
1753
1754    #[test]
1755    fn test_inventory_display_sorted() {
1756        let mut inv = Inventory::new();
1757
1758        // Add in non-alphabetical order
1759        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1760        inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
1761        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
1762
1763        let display = format!("{inv}");
1764
1765        // Should be sorted alphabetically: AAPL, EUR, USD
1766        let aapl_pos = display.find("AAPL").unwrap();
1767        let eur_pos = display.find("EUR").unwrap();
1768        let usd_pos = display.find("USD").unwrap();
1769
1770        assert!(aapl_pos < eur_pos);
1771        assert!(eur_pos < usd_pos);
1772    }
1773
1774    #[test]
1775    fn test_inventory_with_cost_display_sorted() {
1776        let mut inv = Inventory::new();
1777
1778        // Add same currency with different costs
1779        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
1780        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1781
1782        inv.add(Position::with_cost(
1783            Amount::new(dec!(10), "AAPL"),
1784            cost_high,
1785        ));
1786        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1787
1788        let display = format!("{inv}");
1789
1790        // Both positions should be in the output
1791        assert!(display.contains("AAPL"));
1792        assert!(display.contains("100"));
1793        assert!(display.contains("200"));
1794    }
1795
1796    #[test]
1797    fn test_reduce_hifo_no_matching_lot() {
1798        let mut inv = Inventory::new();
1799
1800        // No AAPL positions
1801        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1802
1803        let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
1804
1805        assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1806    }
1807
1808    #[test]
1809    fn test_fifo_respects_dates() {
1810        // Ensure FIFO uses acquisition date, not insertion order
1811        let mut inv = Inventory::new();
1812
1813        // Add newer lot first (out of order)
1814        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1815        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1816
1817        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1818        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1819
1820        // FIFO should reduce from oldest (cost 100) first
1821        let result = inv
1822            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
1823            .unwrap();
1824
1825        // Should use cost from oldest lot (100)
1826        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1827    }
1828
1829    #[test]
1830    fn test_lifo_respects_dates() {
1831        // Ensure LIFO uses acquisition date, not insertion order
1832        let mut inv = Inventory::new();
1833
1834        // Add older lot first
1835        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1836        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1837
1838        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1839        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1840
1841        // LIFO should reduce from newest (cost 200) first
1842        let result = inv
1843            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
1844            .unwrap();
1845
1846        // Should use cost from newest lot (200)
1847        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1848    }
1849
1850    // =========================================================================
1851    // Booking method coverage tests
1852    //
1853    // These tests cover gaps identified during the spring 2026 audit:
1854    // - STRICT_WITH_SIZE: cost spec + exact-size, multiple exact-size matches
1855    // - HIFO: multi-lot ordering, partial reduction, cost spec filtering
1856    // - AVERAGE: weighted average with different costs, partial reduction preserves cost
1857    // - NONE: with cost positions, short position reduction
1858    // =========================================================================
1859
1860    // --- STRICT_WITH_SIZE ---
1861
1862    #[test]
1863    fn test_strict_with_size_different_costs_exact_match() {
1864        // When lots have different costs but one matches the reduction size exactly,
1865        // STRICT_WITH_SIZE should pick that lot instead of raising AmbiguousMatch
1866        let mut inv = Inventory::new();
1867
1868        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1869        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1870
1871        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1872        inv.add(Position::with_cost(Amount::new(dec!(7), "AAPL"), cost2));
1873
1874        // Reduce exactly 7 - should match the 7-share lot at cost 200
1875        let result = inv
1876            .reduce(
1877                &Amount::new(dec!(-7), "AAPL"),
1878                None,
1879                BookingMethod::StrictWithSize,
1880            )
1881            .unwrap();
1882
1883        assert_eq!(inv.units("AAPL"), dec!(10));
1884        assert_eq!(result.cost_basis.unwrap().number, dec!(1400.00)); // 7 * 200
1885    }
1886
1887    #[test]
1888    fn test_strict_with_size_multiple_exact_matches_picks_oldest() {
1889        // When multiple lots have the exact same size, STRICT_WITH_SIZE should
1890        // pick the oldest one (first in index order)
1891        let mut inv = Inventory::new();
1892
1893        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1894        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 6, 1));
1895
1896        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost1));
1897        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1898
1899        // Both lots are size 5 — should pick the first (oldest) one
1900        let result = inv
1901            .reduce(
1902                &Amount::new(dec!(-5), "AAPL"),
1903                None,
1904                BookingMethod::StrictWithSize,
1905            )
1906            .unwrap();
1907
1908        assert_eq!(inv.units("AAPL"), dec!(5));
1909        // Should use cost from the oldest lot (100)
1910        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1911    }
1912
1913    #[test]
1914    fn test_strict_with_size_with_cost_spec() {
1915        // Cost spec should filter lots before exact-size matching
1916        let mut inv = Inventory::new();
1917
1918        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1919        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1920
1921        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1922        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1923
1924        // With cost spec filtering to the 200 USD lot, should find unique match
1925        let spec = CostSpec::empty().with_number(crate::CostNumber::PerUnit {
1926            value: dec!(200.00),
1927        });
1928        let result = inv
1929            .reduce(
1930                &Amount::new(dec!(-5), "AAPL"),
1931                Some(&spec),
1932                BookingMethod::StrictWithSize,
1933            )
1934            .unwrap();
1935
1936        assert_eq!(inv.units("AAPL"), dec!(15));
1937        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
1938    }
1939
1940    // --- HIFO ---
1941
1942    #[test]
1943    fn test_hifo_reduces_highest_cost_first() {
1944        // HIFO should reduce the highest-cost lot first, regardless of date
1945        let mut inv = Inventory::new();
1946
1947        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1948        let cost_mid = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1949        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1950
1951        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
1952        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
1953        inv.add(Position::with_cost(
1954            Amount::new(dec!(10), "AAPL"),
1955            cost_high,
1956        ));
1957
1958        // Reduce 5 — should come from highest cost lot (200)
1959        let result = inv
1960            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Hifo)
1961            .unwrap();
1962
1963        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
1964        assert_eq!(inv.units("AAPL"), dec!(25));
1965    }
1966
1967    #[test]
1968    fn test_hifo_spans_multiple_lots() {
1969        // When reducing more than the highest-cost lot holds, HIFO should
1970        // continue to the next highest
1971        let mut inv = Inventory::new();
1972
1973        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1974        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1975
1976        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1977        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_high));
1978
1979        // Reduce 8: 5 from high (200) + 3 from low (100)
1980        let result = inv
1981            .reduce(&Amount::new(dec!(-8), "AAPL"), None, BookingMethod::Hifo)
1982            .unwrap();
1983
1984        // Cost basis: 5*200 + 3*100 = 1300
1985        assert_eq!(result.cost_basis.unwrap().number, dec!(1300.00));
1986        assert_eq!(inv.units("AAPL"), dec!(2));
1987    }
1988
1989    #[test]
1990    fn test_hifo_with_cost_spec_filter() {
1991        // Cost spec should filter lots before HIFO ordering
1992        let mut inv = Inventory::new();
1993
1994        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1995        let cost2 = Cost::new(dec!(200.00), "EUR").with_date(date(2024, 2, 1));
1996
1997        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1998        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1999
2000        // Filter to USD lots only
2001        let spec = CostSpec::empty().with_currency("USD");
2002        let result = inv
2003            .reduce(
2004                &Amount::new(dec!(-5), "AAPL"),
2005                Some(&spec),
2006                BookingMethod::Hifo,
2007            )
2008            .unwrap();
2009
2010        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); // 5 * 100 USD
2011    }
2012
2013    #[test]
2014    fn test_hifo_short_position() {
2015        // HIFO with short positions: covering shorts should work correctly
2016        let mut inv = Inventory::new();
2017
2018        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2019        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2020
2021        // Short positions (negative units)
2022        inv.add(Position::with_cost(
2023            Amount::new(dec!(-10), "AAPL"),
2024            cost_low,
2025        ));
2026        inv.add(Position::with_cost(
2027            Amount::new(dec!(-10), "AAPL"),
2028            cost_high,
2029        ));
2030
2031        // Cover 5 shares (positive = reduce short position)
2032        // HIFO should pick the highest-cost short lot (200)
2033        let result = inv
2034            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Hifo)
2035            .unwrap();
2036
2037        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
2038        assert_eq!(inv.units("AAPL"), dec!(-15));
2039    }
2040
2041    // --- AVERAGE ---
2042
2043    #[test]
2044    fn test_average_weighted_cost() {
2045        // AVERAGE should compute weighted average across lots with different costs
2046        let mut inv = Inventory::new();
2047
2048        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2049        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2050
2051        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2052        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2053
2054        // Average cost = (10*100 + 10*200) / 20 = 150
2055        let result = inv
2056            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
2057            .unwrap();
2058
2059        // Cost basis: 5 * 150 = 750
2060        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
2061        assert_eq!(inv.units("AAPL"), dec!(15));
2062    }
2063
2064    #[test]
2065    fn test_average_merges_into_single_position() {
2066        // After AVERAGE reduction, inventory should have a single simple position
2067        let mut inv = Inventory::new();
2068
2069        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2070        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2071
2072        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2073        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2074
2075        inv.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
2076            .unwrap();
2077
2078        // Should have exactly one AAPL position remaining
2079        let aapl_positions: Vec<_> = inv
2080            .positions
2081            .iter()
2082            .filter(|p| p.units.currency.as_ref() == "AAPL")
2083            .collect();
2084        assert_eq!(aapl_positions.len(), 1);
2085        assert_eq!(aapl_positions[0].units.number, dec!(15));
2086    }
2087
2088    #[test]
2089    fn test_average_uneven_lots() {
2090        // Weighted average with unequal lot sizes
2091        let mut inv = Inventory::new();
2092
2093        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2094        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2095
2096        inv.add(Position::with_cost(Amount::new(dec!(30), "AAPL"), cost1));
2097        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2098
2099        // Average cost = (30*100 + 10*200) / 40 = 5000/40 = 125
2100        let result = inv
2101            .reduce(
2102                &Amount::new(dec!(-10), "AAPL"),
2103                None,
2104                BookingMethod::Average,
2105            )
2106            .unwrap();
2107
2108        assert_eq!(result.cost_basis.unwrap().number, dec!(1250.00)); // 10 * 125
2109    }
2110
2111    // --- NONE ---
2112
2113    #[test]
2114    fn test_none_booking_with_cost_positions() {
2115        // NONE booking should work even when positions have costs
2116        let mut inv = Inventory::new();
2117
2118        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2119        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
2120
2121        let result = inv
2122            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::None)
2123            .unwrap();
2124
2125        assert_eq!(inv.units("AAPL"), dec!(5));
2126        // NONE delegates to reduce_ordered (FIFO) internally, so cost basis is computed
2127        assert!(result.cost_basis.is_some());
2128        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
2129    }
2130
2131    #[test]
2132    fn test_none_booking_short_cover() {
2133        // Covering a short position with NONE booking
2134        let mut inv = Inventory::new();
2135        inv.add(Position::simple(Amount::new(dec!(-100), "USD")));
2136
2137        // Positive amount should reduce the negative position
2138        let result = inv
2139            .reduce(&Amount::new(dec!(30), "USD"), None, BookingMethod::None)
2140            .unwrap();
2141
2142        assert_eq!(inv.units("USD"), dec!(-70));
2143        assert!(!result.matched.is_empty());
2144    }
2145
2146    #[test]
2147    fn test_none_booking_empty_inventory_augments() {
2148        // NONE booking on empty inventory should augment
2149        let mut inv = Inventory::new();
2150
2151        let result = inv
2152            .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
2153            .unwrap();
2154
2155        assert_eq!(inv.units("USD"), dec!(50));
2156        assert!(result.matched.is_empty()); // Augmentation, not reduction
2157    }
2158
2159    // --- Cross-method: short positions ---
2160
2161    #[test]
2162    fn test_fifo_short_position_cover() {
2163        // FIFO: cover short positions (oldest short first)
2164        let mut inv = Inventory::new();
2165
2166        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2167        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2168
2169        inv.add(Position::with_cost(
2170            Amount::new(dec!(-10), "AAPL"),
2171            cost_old,
2172        ));
2173        inv.add(Position::with_cost(
2174            Amount::new(dec!(-10), "AAPL"),
2175            cost_new,
2176        ));
2177
2178        // Cover 5 shares — FIFO should pick oldest short (cost 100)
2179        let result = inv
2180            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Fifo)
2181            .unwrap();
2182
2183        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); // 5 * 100
2184        assert_eq!(inv.units("AAPL"), dec!(-15));
2185    }
2186
2187    #[test]
2188    fn test_lifo_short_position_cover() {
2189        // LIFO: cover short positions (newest short first)
2190        let mut inv = Inventory::new();
2191
2192        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2193        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2194
2195        inv.add(Position::with_cost(
2196            Amount::new(dec!(-10), "AAPL"),
2197            cost_old,
2198        ));
2199        inv.add(Position::with_cost(
2200            Amount::new(dec!(-10), "AAPL"),
2201            cost_new,
2202        ));
2203
2204        // Cover 5 shares — LIFO should pick newest short (cost 200)
2205        let result = inv
2206            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Lifo)
2207            .unwrap();
2208
2209        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
2210        assert_eq!(inv.units("AAPL"), dec!(-15));
2211    }
2212
2213    // === AccountedBookingError Display tests ===
2214    //
2215    // These tests pin the canonical user-facing wording for every variant
2216    // of `AccountedBookingError`. The whole point of unifying booking-error
2217    // Display into `rustledger-core` (#750) is that there's a single source
2218    // of truth — and a single source of truth with no tests is one refactor
2219    // away from drifting again, which is exactly the failure mode that
2220    // produced #748. Any change to the Display strings below will break
2221    // these tests, forcing the author to consciously re-check pta-standards
2222    // conformance assertions and downstream user tooling.
2223
2224    // =========================================================================
2225    // Regression test for issue #875 / beancount#889
2226    //
2227    // When a sell-without-cost-spec leaves a negative simple position in the
2228    // inventory, a subsequent augmentation WITH a cost spec should NOT be
2229    // misclassified as a reduction. `is_reduced_by` must only consider
2230    // cost-bearing positions when the incoming posting has a cost spec.
2231    // =========================================================================
2232
2233    #[test]
2234    fn test_is_reduced_by_ignores_simple_positions_when_has_cost_spec() {
2235        // Regression test for issue #875 / beancount#889.
2236        //
2237        // Scenario:
2238        //   1. Buy 100 HOOG {1.50 EUR}  -> inventory: [100 HOOG {1.50 EUR}]
2239        //   2. Sell 25 HOOG @ 1.60 EUR   -> inventory: [100 HOOG {1.50 EUR}, -25 HOOG (simple)]
2240        //   3. Buy 50 HOOG {1.70 EUR}    -> should be augmentation, NOT reduction
2241        //
2242        // Before fix: is_reduced_by saw the -25 HOOG simple position and
2243        // incorrectly reported that +50 HOOG would reduce the inventory.
2244        let mut inv = Inventory::new();
2245
2246        // Step 1: buy 100 HOOG with cost
2247        let cost = Cost::new(dec!(1.50), "EUR").with_date(date(2024, 1, 10));
2248        inv.add(Position::with_cost(Amount::new(dec!(100), "HOOG"), cost));
2249
2250        // Step 2: sell 25 HOOG without cost spec (simple position)
2251        inv.add(Position::simple(Amount::new(dec!(-25), "HOOG")));
2252
2253        // Step 3: check if buying 50 HOOG with cost spec would be a reduction
2254        let buy_units = Amount::new(dec!(50), "HOOG");
2255
2256        // With has_cost_spec=true, only cost-bearing positions should be
2257        // considered. The 100 HOOG {1.50 EUR} is positive and so is the
2258        // incoming 50 HOOG -> same sign -> NOT a reduction.
2259        assert!(
2260            !inv.is_reduced_by(&buy_units, ReductionScope::CostBearingOnly),
2261            "augmentation with cost spec should NOT be treated as reduction \
2262             when only a simple (no-cost) position has opposite sign"
2263        );
2264
2265        // With AllPositions, all positions are considered,
2266        // including the -25 HOOG simple position -> IS a reduction.
2267        assert!(
2268            inv.is_reduced_by(&buy_units, ReductionScope::AllPositions),
2269            "without cost spec filter, the -25 HOOG simple position \
2270             should cause is_reduced_by to return true"
2271        );
2272    }
2273
2274    #[test]
2275    fn test_accounted_error_display_insufficient_units() {
2276        let err = BookingError::InsufficientUnits {
2277            currency: "AAPL".into(),
2278            requested: dec!(15),
2279            available: dec!(10),
2280        }
2281        .with_account("Assets:Stock".into());
2282        let rendered = format!("{err}");
2283
2284        // Pinned by pta-standards `reduction-exceeds-inventory`
2285        // (`error_contains: ["not enough"]`). See #748 / #749.
2286        assert!(
2287            rendered.contains("not enough"),
2288            "must contain 'not enough' (pta-standards): {rendered}"
2289        );
2290        assert!(
2291            rendered.contains("Assets:Stock"),
2292            "must contain account name: {rendered}"
2293        );
2294        assert!(
2295            rendered.contains("15") && rendered.contains("10"),
2296            "must contain requested and available amounts: {rendered}"
2297        );
2298    }
2299
2300    #[test]
2301    fn test_accounted_error_display_no_matching_lot() {
2302        let err = BookingError::NoMatchingLot {
2303            currency: "AAPL".into(),
2304            cost_spec: CostSpec::empty(),
2305        }
2306        .with_account("Assets:Stock".into());
2307        let rendered = format!("{err}");
2308
2309        assert!(
2310            rendered.contains("No matching lot"),
2311            "must contain 'No matching lot': {rendered}"
2312        );
2313        assert!(
2314            rendered.contains("AAPL"),
2315            "must contain currency: {rendered}"
2316        );
2317        assert!(
2318            rendered.contains("Assets:Stock"),
2319            "must contain account name: {rendered}"
2320        );
2321    }
2322
2323    #[test]
2324    fn test_accounted_error_display_ambiguous_match() {
2325        let err = BookingError::AmbiguousMatch {
2326            num_matches: 3,
2327            currency: "AAPL".into(),
2328        }
2329        .with_account("Assets:Stock".into());
2330        let rendered = format!("{err}");
2331
2332        assert!(
2333            rendered.contains("Ambiguous"),
2334            "must contain 'Ambiguous': {rendered}"
2335        );
2336        assert!(
2337            rendered.contains("AAPL"),
2338            "must contain currency: {rendered}"
2339        );
2340        assert!(
2341            rendered.contains("Assets:Stock"),
2342            "must contain account name: {rendered}"
2343        );
2344        assert!(
2345            rendered.contains('3'),
2346            "must contain match count: {rendered}"
2347        );
2348    }
2349
2350    #[test]
2351    fn test_accounted_error_display_currency_mismatch_renders_as_no_matching_lot() {
2352        // CurrencyMismatch is semantically a specialization of NoMatchingLot
2353        // (there is no lot for the given currency in this inventory) and the
2354        // canonical Display collapses them into the same user-facing phrasing
2355        // so that consumers filtering on E4001 don't need to special-case it.
2356        // This variant is defensive — no `Inventory::reduce` path currently
2357        // emits it — but we still pin its rendering in case a future emission
2358        // site is added.
2359        let err = BookingError::CurrencyMismatch {
2360            expected: "USD".into(),
2361            got: "EUR".into(),
2362        }
2363        .with_account("Assets:Cash".into());
2364        let rendered = format!("{err}");
2365
2366        assert!(
2367            rendered.contains("No matching lot"),
2368            "CurrencyMismatch must render as 'No matching lot' for E4001 \
2369             consistency: {rendered}"
2370        );
2371        assert!(
2372            rendered.contains("EUR"),
2373            "must contain the mismatched (got) currency: {rendered}"
2374        );
2375        assert!(
2376            rendered.contains("Assets:Cash"),
2377            "must contain account name: {rendered}"
2378        );
2379    }
2380}