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
7use im::Vector;
8use rust_decimal::Decimal;
9use rustc_hash::FxHashMap;
10use serde::{Deserialize, Serialize};
11use smallvec::SmallVec;
12use std::fmt;
13use std::str::FromStr;
14
15use crate::intern::InternedStr;
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: InternedStr,
140    },
141    /// No lots match the cost specification.
142    NoMatchingLot {
143        /// The currency being reduced.
144        currency: InternedStr,
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: InternedStr,
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: InternedStr,
161        /// Got currency.
162        got: InternedStr,
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: InternedStr) -> 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: InternedStr,
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) `im::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<InternedStr, 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<InternedStr, 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 (`im::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 im::Vector<Position>` (was `&mut Vec<Position>`
379    /// before issue #1086). `im::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<InternedStr, Decimal> {
466        let mut totals: FxHashMap<InternedStr, 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_per(dec!(200.00));
1926        let result = inv
1927            .reduce(
1928                &Amount::new(dec!(-5), "AAPL"),
1929                Some(&spec),
1930                BookingMethod::StrictWithSize,
1931            )
1932            .unwrap();
1933
1934        assert_eq!(inv.units("AAPL"), dec!(15));
1935        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
1936    }
1937
1938    // --- HIFO ---
1939
1940    #[test]
1941    fn test_hifo_reduces_highest_cost_first() {
1942        // HIFO should reduce the highest-cost lot first, regardless of date
1943        let mut inv = Inventory::new();
1944
1945        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1946        let cost_mid = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1947        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1948
1949        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
1950        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
1951        inv.add(Position::with_cost(
1952            Amount::new(dec!(10), "AAPL"),
1953            cost_high,
1954        ));
1955
1956        // Reduce 5 — should come from highest cost lot (200)
1957        let result = inv
1958            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Hifo)
1959            .unwrap();
1960
1961        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
1962        assert_eq!(inv.units("AAPL"), dec!(25));
1963    }
1964
1965    #[test]
1966    fn test_hifo_spans_multiple_lots() {
1967        // When reducing more than the highest-cost lot holds, HIFO should
1968        // continue to the next highest
1969        let mut inv = Inventory::new();
1970
1971        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1972        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1973
1974        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1975        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_high));
1976
1977        // Reduce 8: 5 from high (200) + 3 from low (100)
1978        let result = inv
1979            .reduce(&Amount::new(dec!(-8), "AAPL"), None, BookingMethod::Hifo)
1980            .unwrap();
1981
1982        // Cost basis: 5*200 + 3*100 = 1300
1983        assert_eq!(result.cost_basis.unwrap().number, dec!(1300.00));
1984        assert_eq!(inv.units("AAPL"), dec!(2));
1985    }
1986
1987    #[test]
1988    fn test_hifo_with_cost_spec_filter() {
1989        // Cost spec should filter lots before HIFO ordering
1990        let mut inv = Inventory::new();
1991
1992        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1993        let cost2 = Cost::new(dec!(200.00), "EUR").with_date(date(2024, 2, 1));
1994
1995        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1996        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1997
1998        // Filter to USD lots only
1999        let spec = CostSpec::empty().with_currency("USD");
2000        let result = inv
2001            .reduce(
2002                &Amount::new(dec!(-5), "AAPL"),
2003                Some(&spec),
2004                BookingMethod::Hifo,
2005            )
2006            .unwrap();
2007
2008        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); // 5 * 100 USD
2009    }
2010
2011    #[test]
2012    fn test_hifo_short_position() {
2013        // HIFO with short positions: covering shorts should work correctly
2014        let mut inv = Inventory::new();
2015
2016        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2017        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2018
2019        // Short positions (negative units)
2020        inv.add(Position::with_cost(
2021            Amount::new(dec!(-10), "AAPL"),
2022            cost_low,
2023        ));
2024        inv.add(Position::with_cost(
2025            Amount::new(dec!(-10), "AAPL"),
2026            cost_high,
2027        ));
2028
2029        // Cover 5 shares (positive = reduce short position)
2030        // HIFO should pick the highest-cost short lot (200)
2031        let result = inv
2032            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Hifo)
2033            .unwrap();
2034
2035        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
2036        assert_eq!(inv.units("AAPL"), dec!(-15));
2037    }
2038
2039    // --- AVERAGE ---
2040
2041    #[test]
2042    fn test_average_weighted_cost() {
2043        // AVERAGE should compute weighted average across lots with different costs
2044        let mut inv = Inventory::new();
2045
2046        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2047        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2048
2049        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2050        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2051
2052        // Average cost = (10*100 + 10*200) / 20 = 150
2053        let result = inv
2054            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
2055            .unwrap();
2056
2057        // Cost basis: 5 * 150 = 750
2058        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
2059        assert_eq!(inv.units("AAPL"), dec!(15));
2060    }
2061
2062    #[test]
2063    fn test_average_merges_into_single_position() {
2064        // After AVERAGE reduction, inventory should have a single simple position
2065        let mut inv = Inventory::new();
2066
2067        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2068        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2069
2070        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2071        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2072
2073        inv.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
2074            .unwrap();
2075
2076        // Should have exactly one AAPL position remaining
2077        let aapl_positions: Vec<_> = inv
2078            .positions
2079            .iter()
2080            .filter(|p| p.units.currency.as_ref() == "AAPL")
2081            .collect();
2082        assert_eq!(aapl_positions.len(), 1);
2083        assert_eq!(aapl_positions[0].units.number, dec!(15));
2084    }
2085
2086    #[test]
2087    fn test_average_uneven_lots() {
2088        // Weighted average with unequal lot sizes
2089        let mut inv = Inventory::new();
2090
2091        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2092        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2093
2094        inv.add(Position::with_cost(Amount::new(dec!(30), "AAPL"), cost1));
2095        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2096
2097        // Average cost = (30*100 + 10*200) / 40 = 5000/40 = 125
2098        let result = inv
2099            .reduce(
2100                &Amount::new(dec!(-10), "AAPL"),
2101                None,
2102                BookingMethod::Average,
2103            )
2104            .unwrap();
2105
2106        assert_eq!(result.cost_basis.unwrap().number, dec!(1250.00)); // 10 * 125
2107    }
2108
2109    // --- NONE ---
2110
2111    #[test]
2112    fn test_none_booking_with_cost_positions() {
2113        // NONE booking should work even when positions have costs
2114        let mut inv = Inventory::new();
2115
2116        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2117        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
2118
2119        let result = inv
2120            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::None)
2121            .unwrap();
2122
2123        assert_eq!(inv.units("AAPL"), dec!(5));
2124        // NONE delegates to reduce_ordered (FIFO) internally, so cost basis is computed
2125        assert!(result.cost_basis.is_some());
2126        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
2127    }
2128
2129    #[test]
2130    fn test_none_booking_short_cover() {
2131        // Covering a short position with NONE booking
2132        let mut inv = Inventory::new();
2133        inv.add(Position::simple(Amount::new(dec!(-100), "USD")));
2134
2135        // Positive amount should reduce the negative position
2136        let result = inv
2137            .reduce(&Amount::new(dec!(30), "USD"), None, BookingMethod::None)
2138            .unwrap();
2139
2140        assert_eq!(inv.units("USD"), dec!(-70));
2141        assert!(!result.matched.is_empty());
2142    }
2143
2144    #[test]
2145    fn test_none_booking_empty_inventory_augments() {
2146        // NONE booking on empty inventory should augment
2147        let mut inv = Inventory::new();
2148
2149        let result = inv
2150            .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
2151            .unwrap();
2152
2153        assert_eq!(inv.units("USD"), dec!(50));
2154        assert!(result.matched.is_empty()); // Augmentation, not reduction
2155    }
2156
2157    // --- Cross-method: short positions ---
2158
2159    #[test]
2160    fn test_fifo_short_position_cover() {
2161        // FIFO: cover short positions (oldest short first)
2162        let mut inv = Inventory::new();
2163
2164        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2165        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2166
2167        inv.add(Position::with_cost(
2168            Amount::new(dec!(-10), "AAPL"),
2169            cost_old,
2170        ));
2171        inv.add(Position::with_cost(
2172            Amount::new(dec!(-10), "AAPL"),
2173            cost_new,
2174        ));
2175
2176        // Cover 5 shares — FIFO should pick oldest short (cost 100)
2177        let result = inv
2178            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Fifo)
2179            .unwrap();
2180
2181        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); // 5 * 100
2182        assert_eq!(inv.units("AAPL"), dec!(-15));
2183    }
2184
2185    #[test]
2186    fn test_lifo_short_position_cover() {
2187        // LIFO: cover short positions (newest short first)
2188        let mut inv = Inventory::new();
2189
2190        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2191        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2192
2193        inv.add(Position::with_cost(
2194            Amount::new(dec!(-10), "AAPL"),
2195            cost_old,
2196        ));
2197        inv.add(Position::with_cost(
2198            Amount::new(dec!(-10), "AAPL"),
2199            cost_new,
2200        ));
2201
2202        // Cover 5 shares — LIFO should pick newest short (cost 200)
2203        let result = inv
2204            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Lifo)
2205            .unwrap();
2206
2207        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
2208        assert_eq!(inv.units("AAPL"), dec!(-15));
2209    }
2210
2211    // === AccountedBookingError Display tests ===
2212    //
2213    // These tests pin the canonical user-facing wording for every variant
2214    // of `AccountedBookingError`. The whole point of unifying booking-error
2215    // Display into `rustledger-core` (#750) is that there's a single source
2216    // of truth — and a single source of truth with no tests is one refactor
2217    // away from drifting again, which is exactly the failure mode that
2218    // produced #748. Any change to the Display strings below will break
2219    // these tests, forcing the author to consciously re-check pta-standards
2220    // conformance assertions and downstream user tooling.
2221
2222    // =========================================================================
2223    // Regression test for issue #875 / beancount#889
2224    //
2225    // When a sell-without-cost-spec leaves a negative simple position in the
2226    // inventory, a subsequent augmentation WITH a cost spec should NOT be
2227    // misclassified as a reduction. `is_reduced_by` must only consider
2228    // cost-bearing positions when the incoming posting has a cost spec.
2229    // =========================================================================
2230
2231    #[test]
2232    fn test_is_reduced_by_ignores_simple_positions_when_has_cost_spec() {
2233        // Regression test for issue #875 / beancount#889.
2234        //
2235        // Scenario:
2236        //   1. Buy 100 HOOG {1.50 EUR}  -> inventory: [100 HOOG {1.50 EUR}]
2237        //   2. Sell 25 HOOG @ 1.60 EUR   -> inventory: [100 HOOG {1.50 EUR}, -25 HOOG (simple)]
2238        //   3. Buy 50 HOOG {1.70 EUR}    -> should be augmentation, NOT reduction
2239        //
2240        // Before fix: is_reduced_by saw the -25 HOOG simple position and
2241        // incorrectly reported that +50 HOOG would reduce the inventory.
2242        let mut inv = Inventory::new();
2243
2244        // Step 1: buy 100 HOOG with cost
2245        let cost = Cost::new(dec!(1.50), "EUR").with_date(date(2024, 1, 10));
2246        inv.add(Position::with_cost(Amount::new(dec!(100), "HOOG"), cost));
2247
2248        // Step 2: sell 25 HOOG without cost spec (simple position)
2249        inv.add(Position::simple(Amount::new(dec!(-25), "HOOG")));
2250
2251        // Step 3: check if buying 50 HOOG with cost spec would be a reduction
2252        let buy_units = Amount::new(dec!(50), "HOOG");
2253
2254        // With has_cost_spec=true, only cost-bearing positions should be
2255        // considered. The 100 HOOG {1.50 EUR} is positive and so is the
2256        // incoming 50 HOOG -> same sign -> NOT a reduction.
2257        assert!(
2258            !inv.is_reduced_by(&buy_units, ReductionScope::CostBearingOnly),
2259            "augmentation with cost spec should NOT be treated as reduction \
2260             when only a simple (no-cost) position has opposite sign"
2261        );
2262
2263        // With AllPositions, all positions are considered,
2264        // including the -25 HOOG simple position -> IS a reduction.
2265        assert!(
2266            inv.is_reduced_by(&buy_units, ReductionScope::AllPositions),
2267            "without cost spec filter, the -25 HOOG simple position \
2268             should cause is_reduced_by to return true"
2269        );
2270    }
2271
2272    #[test]
2273    fn test_accounted_error_display_insufficient_units() {
2274        let err = BookingError::InsufficientUnits {
2275            currency: "AAPL".into(),
2276            requested: dec!(15),
2277            available: dec!(10),
2278        }
2279        .with_account("Assets:Stock".into());
2280        let rendered = format!("{err}");
2281
2282        // Pinned by pta-standards `reduction-exceeds-inventory`
2283        // (`error_contains: ["not enough"]`). See #748 / #749.
2284        assert!(
2285            rendered.contains("not enough"),
2286            "must contain 'not enough' (pta-standards): {rendered}"
2287        );
2288        assert!(
2289            rendered.contains("Assets:Stock"),
2290            "must contain account name: {rendered}"
2291        );
2292        assert!(
2293            rendered.contains("15") && rendered.contains("10"),
2294            "must contain requested and available amounts: {rendered}"
2295        );
2296    }
2297
2298    #[test]
2299    fn test_accounted_error_display_no_matching_lot() {
2300        let err = BookingError::NoMatchingLot {
2301            currency: "AAPL".into(),
2302            cost_spec: CostSpec::empty(),
2303        }
2304        .with_account("Assets:Stock".into());
2305        let rendered = format!("{err}");
2306
2307        assert!(
2308            rendered.contains("No matching lot"),
2309            "must contain 'No matching lot': {rendered}"
2310        );
2311        assert!(
2312            rendered.contains("AAPL"),
2313            "must contain currency: {rendered}"
2314        );
2315        assert!(
2316            rendered.contains("Assets:Stock"),
2317            "must contain account name: {rendered}"
2318        );
2319    }
2320
2321    #[test]
2322    fn test_accounted_error_display_ambiguous_match() {
2323        let err = BookingError::AmbiguousMatch {
2324            num_matches: 3,
2325            currency: "AAPL".into(),
2326        }
2327        .with_account("Assets:Stock".into());
2328        let rendered = format!("{err}");
2329
2330        assert!(
2331            rendered.contains("Ambiguous"),
2332            "must contain 'Ambiguous': {rendered}"
2333        );
2334        assert!(
2335            rendered.contains("AAPL"),
2336            "must contain currency: {rendered}"
2337        );
2338        assert!(
2339            rendered.contains("Assets:Stock"),
2340            "must contain account name: {rendered}"
2341        );
2342        assert!(
2343            rendered.contains('3'),
2344            "must contain match count: {rendered}"
2345        );
2346    }
2347
2348    #[test]
2349    fn test_accounted_error_display_currency_mismatch_renders_as_no_matching_lot() {
2350        // CurrencyMismatch is semantically a specialization of NoMatchingLot
2351        // (there is no lot for the given currency in this inventory) and the
2352        // canonical Display collapses them into the same user-facing phrasing
2353        // so that consumers filtering on E4001 don't need to special-case it.
2354        // This variant is defensive — no `Inventory::reduce` path currently
2355        // emits it — but we still pin its rendering in case a future emission
2356        // site is added.
2357        let err = BookingError::CurrencyMismatch {
2358            expected: "USD".into(),
2359            got: "EUR".into(),
2360        }
2361        .with_account("Assets:Cash".into());
2362        let rendered = format!("{err}");
2363
2364        assert!(
2365            rendered.contains("No matching lot"),
2366            "CurrencyMismatch must render as 'No matching lot' for E4001 \
2367             consistency: {rendered}"
2368        );
2369        assert!(
2370            rendered.contains("EUR"),
2371            "must contain the mismatched (got) currency: {rendered}"
2372        );
2373        assert!(
2374            rendered.contains("Assets:Cash"),
2375            "must contain account name: {rendered}"
2376        );
2377    }
2378}