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 rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::fmt;
11use std::str::FromStr;
12
13use crate::intern::InternedStr;
14use crate::{Amount, CostSpec, Position};
15
16mod booking;
17
18/// Booking method determines how lots are matched when reducing positions.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
20#[cfg_attr(
21    feature = "rkyv",
22    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
23)]
24pub enum BookingMethod {
25    /// Lots must match exactly (unambiguous).
26    /// If multiple lots match the cost spec, an error is raised.
27    #[default]
28    Strict,
29    /// Like STRICT, but exact-size matches accept oldest lot.
30    /// If reduction amount equals total inventory, it's considered unambiguous.
31    StrictWithSize,
32    /// First In, First Out. Oldest lots are reduced first.
33    Fifo,
34    /// Last In, First Out. Newest lots are reduced first.
35    Lifo,
36    /// Highest In, First Out. Highest-cost lots are reduced first.
37    Hifo,
38    /// Average cost booking. All lots of a currency are merged.
39    Average,
40    /// No cost tracking. Units are reduced without matching lots.
41    None,
42}
43
44impl FromStr for BookingMethod {
45    type Err = String;
46
47    fn from_str(s: &str) -> Result<Self, Self::Err> {
48        match s.to_uppercase().as_str() {
49            "STRICT" => Ok(Self::Strict),
50            "STRICT_WITH_SIZE" => Ok(Self::StrictWithSize),
51            "FIFO" => Ok(Self::Fifo),
52            "LIFO" => Ok(Self::Lifo),
53            "HIFO" => Ok(Self::Hifo),
54            "AVERAGE" => Ok(Self::Average),
55            "NONE" => Ok(Self::None),
56            _ => Err(format!("unknown booking method: {s}")),
57        }
58    }
59}
60
61impl fmt::Display for BookingMethod {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            Self::Strict => write!(f, "STRICT"),
65            Self::StrictWithSize => write!(f, "STRICT_WITH_SIZE"),
66            Self::Fifo => write!(f, "FIFO"),
67            Self::Lifo => write!(f, "LIFO"),
68            Self::Hifo => write!(f, "HIFO"),
69            Self::Average => write!(f, "AVERAGE"),
70            Self::None => write!(f, "NONE"),
71        }
72    }
73}
74
75/// Result of a booking operation.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct BookingResult {
78    /// Positions that were matched/reduced.
79    pub matched: Vec<Position>,
80    /// The cost basis of the matched positions (for capital gains).
81    pub cost_basis: Option<Amount>,
82}
83
84/// Error that can occur during booking.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum BookingError {
87    /// Multiple lots match but booking method requires unambiguous match.
88    AmbiguousMatch {
89        /// Number of lots that matched.
90        num_matches: usize,
91        /// The currency being reduced.
92        currency: InternedStr,
93    },
94    /// No lots match the cost specification.
95    NoMatchingLot {
96        /// The currency being reduced.
97        currency: InternedStr,
98        /// The cost spec that didn't match.
99        cost_spec: CostSpec,
100    },
101    /// Not enough units in matching lots.
102    InsufficientUnits {
103        /// The currency being reduced.
104        currency: InternedStr,
105        /// Units requested.
106        requested: Decimal,
107        /// Units available.
108        available: Decimal,
109    },
110    /// Currency mismatch between reduction and inventory.
111    CurrencyMismatch {
112        /// Expected currency.
113        expected: InternedStr,
114        /// Got currency.
115        got: InternedStr,
116    },
117}
118
119impl fmt::Display for BookingError {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        match self {
122            Self::AmbiguousMatch {
123                num_matches,
124                currency,
125            } => write!(
126                f,
127                "Ambiguous match: {num_matches} lots match for {currency}"
128            ),
129            Self::NoMatchingLot {
130                currency,
131                cost_spec,
132            } => {
133                write!(f, "No matching lot for {currency} with cost {cost_spec}")
134            }
135            Self::InsufficientUnits {
136                currency,
137                requested,
138                available,
139            } => write!(
140                f,
141                "Insufficient units of {currency}: requested {requested}, available {available}"
142            ),
143            Self::CurrencyMismatch { expected, got } => {
144                write!(f, "Currency mismatch: expected {expected}, got {got}")
145            }
146        }
147    }
148}
149
150impl std::error::Error for BookingError {}
151
152/// An inventory is a collection of positions.
153///
154/// It tracks all positions for an account and supports booking operations
155/// for adding and reducing positions.
156///
157/// # Examples
158///
159/// ```
160/// use rustledger_core::{Inventory, Position, Amount, Cost, BookingMethod};
161/// use rust_decimal_macros::dec;
162///
163/// let mut inv = Inventory::new();
164///
165/// // Add a simple position
166/// inv.add(Position::simple(Amount::new(dec!(100), "USD")));
167/// assert_eq!(inv.units("USD"), dec!(100));
168///
169/// // Add a position with cost
170/// let cost = Cost::new(dec!(150.00), "USD");
171/// inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
172/// assert_eq!(inv.units("AAPL"), dec!(10));
173/// ```
174#[derive(Debug, Clone, Default, Serialize, Deserialize)]
175#[cfg_attr(
176    feature = "rkyv",
177    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
178)]
179pub struct Inventory {
180    positions: Vec<Position>,
181    /// Index for O(1) lookup of simple positions (no cost) by currency.
182    /// Maps currency to position index in the `positions` Vec.
183    /// Not serialized - rebuilt on demand.
184    #[serde(skip)]
185    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Skip))]
186    simple_index: HashMap<InternedStr, usize>,
187    /// Cache of total units per currency for O(1) `units()` lookups.
188    /// Updated incrementally on `add()` and `reduce()`.
189    /// Not serialized - rebuilt on demand.
190    #[serde(skip)]
191    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Skip))]
192    units_cache: HashMap<InternedStr, Decimal>,
193}
194
195impl PartialEq for Inventory {
196    fn eq(&self, other: &Self) -> bool {
197        // Only compare positions, not the index (which is derived data)
198        self.positions == other.positions
199    }
200}
201
202impl Eq for Inventory {}
203
204impl Inventory {
205    /// Create an empty inventory.
206    #[must_use]
207    pub fn new() -> Self {
208        Self::default()
209    }
210
211    /// Get all positions.
212    #[must_use]
213    pub fn positions(&self) -> &[Position] {
214        &self.positions
215    }
216
217    /// Get mutable access to all positions.
218    pub const fn positions_mut(&mut self) -> &mut Vec<Position> {
219        &mut self.positions
220    }
221
222    /// Check if inventory is empty.
223    #[must_use]
224    pub fn is_empty(&self) -> bool {
225        self.positions.is_empty()
226            || self
227                .positions
228                .iter()
229                .all(super::position::Position::is_empty)
230    }
231
232    /// Get the number of positions (including empty ones).
233    #[must_use]
234    pub const fn len(&self) -> usize {
235        self.positions.len()
236    }
237
238    /// Get total units of a currency (ignoring cost lots).
239    ///
240    /// This sums all positions of the given currency regardless of cost basis.
241    /// Uses an internal cache for O(1) lookups.
242    #[must_use]
243    pub fn units(&self, currency: &str) -> Decimal {
244        // Use cache if available, otherwise compute and the caller should
245        // ensure cache is built via rebuild_caches() after deserialization
246        self.units_cache.get(currency).copied().unwrap_or_else(|| {
247            // Fallback to computation if cache miss (e.g., after deserialization)
248            self.positions
249                .iter()
250                .filter(|p| p.units.currency == currency)
251                .map(|p| p.units.number)
252                .sum()
253        })
254    }
255
256    /// Get all currencies in this inventory.
257    #[must_use]
258    pub fn currencies(&self) -> Vec<&str> {
259        let mut currencies: Vec<&str> = self
260            .positions
261            .iter()
262            .filter(|p| !p.is_empty())
263            .map(|p| p.units.currency.as_str())
264            .collect();
265        currencies.sort_unstable();
266        currencies.dedup();
267        currencies
268    }
269
270    /// Check if the given units would reduce (not augment) this inventory.
271    ///
272    /// Returns `true` if there's a position with the same currency but opposite
273    /// sign, meaning these units would reduce the inventory rather than add to it.
274    ///
275    /// This is used to determine whether a posting is a sale/reduction or a
276    /// purchase/augmentation.
277    #[must_use]
278    pub fn is_reduced_by(&self, units: &Amount) -> bool {
279        self.positions.iter().any(|pos| {
280            pos.units.currency == units.currency
281                && pos.units.number.is_sign_positive() != units.number.is_sign_positive()
282        })
283    }
284
285    /// Get the total book value (cost basis) for a currency.
286    ///
287    /// Returns the sum of all cost bases for positions of the given currency.
288    #[must_use]
289    pub fn book_value(&self, units_currency: &str) -> HashMap<InternedStr, Decimal> {
290        let mut totals: HashMap<InternedStr, Decimal> = HashMap::new();
291
292        for pos in &self.positions {
293            if pos.units.currency == units_currency
294                && let Some(book) = pos.book_value()
295            {
296                *totals.entry(book.currency.clone()).or_default() += book.number;
297            }
298        }
299
300        totals
301    }
302
303    /// Add a position to the inventory.
304    ///
305    /// For positions without cost, this merges with existing positions
306    /// of the same currency using O(1) `HashMap` lookup.
307    ///
308    /// For positions with cost, this adds as a new lot (O(1)).
309    /// Lot aggregation for display purposes is handled separately at output time
310    /// (e.g., in the query result formatter).
311    ///
312    /// # TLA+ Specification
313    ///
314    /// Implements `AddAmount` action from `Conservation.tla`:
315    /// - Invariant: `inventory + totalReduced = totalAdded`
316    /// - After add: `totalAdded' = totalAdded + amount`
317    ///
318    /// See: `spec/tla/Conservation.tla`
319    pub fn add(&mut self, position: Position) {
320        if position.is_empty() {
321            return;
322        }
323
324        // Update units cache
325        *self
326            .units_cache
327            .entry(position.units.currency.clone())
328            .or_default() += position.units.number;
329
330        // For positions without cost, use index for O(1) lookup
331        if position.cost.is_none() {
332            if let Some(&idx) = self.simple_index.get(&position.units.currency) {
333                // Merge with existing position
334                debug_assert!(self.positions[idx].cost.is_none());
335                self.positions[idx].units += &position.units;
336                return;
337            }
338            // No existing position - add new one and index it
339            let idx = self.positions.len();
340            self.simple_index
341                .insert(position.units.currency.clone(), idx);
342            self.positions.push(position);
343            return;
344        }
345
346        // For positions with cost, just add as a new lot.
347        // This is O(1) and keeps all lots separate, matching Python beancount behavior.
348        // Lot aggregation for display purposes is handled separately in query output.
349        self.positions.push(position);
350    }
351
352    /// Reduce positions from the inventory using the specified booking method.
353    ///
354    /// # Arguments
355    ///
356    /// * `units` - The units to reduce (negative for selling)
357    /// * `cost_spec` - Optional cost specification for matching lots
358    /// * `method` - The booking method to use
359    ///
360    /// # Returns
361    ///
362    /// Returns a `BookingResult` with the matched positions and cost basis,
363    /// or a `BookingError` if the reduction cannot be performed.
364    ///
365    /// # TLA+ Specification
366    ///
367    /// Implements `ReduceAmount` action from `Conservation.tla`:
368    /// - Invariant: `inventory + totalReduced = totalAdded`
369    /// - After reduce: `totalReduced' = totalReduced + amount`
370    /// - Precondition: `amount <= inventory` (else `InsufficientUnits` error)
371    ///
372    /// Lot selection follows these TLA+ specs based on `method`:
373    /// - `Fifo`: `FIFOCorrect.tla` - Oldest lots first (`selected_date <= all other dates`)
374    /// - `Lifo`: `LIFOCorrect.tla` - Newest lots first (`selected_date >= all other dates`)
375    /// - `Hifo`: `HIFOCorrect.tla` - Highest cost first (`selected_cost >= all other costs`)
376    ///
377    /// See: `spec/tla/Conservation.tla`, `spec/tla/FIFOCorrect.tla`, etc.
378    pub fn reduce(
379        &mut self,
380        units: &Amount,
381        cost_spec: Option<&CostSpec>,
382        method: BookingMethod,
383    ) -> Result<BookingResult, BookingError> {
384        let spec = cost_spec.cloned().unwrap_or_default();
385
386        match method {
387            BookingMethod::Strict => self.reduce_strict(units, &spec),
388            BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
389            BookingMethod::Fifo => self.reduce_fifo(units, &spec),
390            BookingMethod::Lifo => self.reduce_lifo(units, &spec),
391            BookingMethod::Hifo => self.reduce_hifo(units, &spec),
392            BookingMethod::Average => self.reduce_average(units),
393            BookingMethod::None => self.reduce_none(units),
394        }
395    }
396
397    /// Remove all empty positions.
398    pub fn compact(&mut self) {
399        self.positions.retain(|p| !p.is_empty());
400        self.rebuild_index();
401    }
402
403    /// Rebuild all caches (`simple_index` and `units_cache`) from positions.
404    /// Called after operations that may invalidate caches (like retain or deserialization).
405    fn rebuild_index(&mut self) {
406        self.simple_index.clear();
407        self.units_cache.clear();
408
409        for (idx, pos) in self.positions.iter().enumerate() {
410            // Update units cache for all positions
411            *self
412                .units_cache
413                .entry(pos.units.currency.clone())
414                .or_default() += pos.units.number;
415
416            // Update simple_index only for positions without cost
417            if pos.cost.is_none() {
418                debug_assert!(
419                    !self.simple_index.contains_key(&pos.units.currency),
420                    "Invariant violated: multiple simple positions for currency {}",
421                    pos.units.currency
422                );
423                self.simple_index.insert(pos.units.currency.clone(), idx);
424            }
425        }
426    }
427
428    /// Merge this inventory with another.
429    pub fn merge(&mut self, other: &Self) {
430        for pos in &other.positions {
431            self.add(pos.clone());
432        }
433    }
434
435    /// Convert inventory to cost basis.
436    ///
437    /// Returns a new inventory where all positions are converted to their
438    /// cost basis. Positions without cost are returned as-is.
439    #[must_use]
440    pub fn at_cost(&self) -> Self {
441        let mut result = Self::new();
442
443        for pos in &self.positions {
444            if pos.is_empty() {
445                continue;
446            }
447
448            if let Some(cost) = &pos.cost {
449                // Convert to cost basis
450                let total = pos.units.number * cost.number;
451                result.add(Position::simple(Amount::new(total, &cost.currency)));
452            } else {
453                // No cost, keep as-is
454                result.add(pos.clone());
455            }
456        }
457
458        result
459    }
460
461    /// Convert inventory to units only.
462    ///
463    /// Returns a new inventory where all positions have their cost removed,
464    /// effectively aggregating by currency only.
465    #[must_use]
466    pub fn at_units(&self) -> Self {
467        let mut result = Self::new();
468
469        for pos in &self.positions {
470            if pos.is_empty() {
471                continue;
472            }
473
474            // Strip cost, keep only units
475            result.add(Position::simple(pos.units.clone()));
476        }
477
478        result
479    }
480}
481
482impl fmt::Display for Inventory {
483    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
484        if self.is_empty() {
485            return write!(f, "(empty)");
486        }
487
488        // Sort positions alphabetically by currency, then by cost for consistency
489        let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
490        non_empty.sort_by(|a, b| {
491            // First by currency
492            let cmp = a.units.currency.cmp(&b.units.currency);
493            if cmp != std::cmp::Ordering::Equal {
494                return cmp;
495            }
496            // Then by cost (if present)
497            match (&a.cost, &b.cost) {
498                (Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
499                (Some(_), None) => std::cmp::Ordering::Greater,
500                (None, Some(_)) => std::cmp::Ordering::Less,
501                (None, None) => std::cmp::Ordering::Equal,
502            }
503        });
504
505        for (i, pos) in non_empty.iter().enumerate() {
506            if i > 0 {
507                write!(f, ", ")?;
508            }
509            write!(f, "{pos}")?;
510        }
511        Ok(())
512    }
513}
514
515impl FromIterator<Position> for Inventory {
516    fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
517        let mut inv = Self::new();
518        for pos in iter {
519            inv.add(pos);
520        }
521        inv
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use crate::Cost;
529    use chrono::NaiveDate;
530    use rust_decimal_macros::dec;
531
532    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
533        NaiveDate::from_ymd_opt(year, month, day).unwrap()
534    }
535
536    #[test]
537    fn test_empty_inventory() {
538        let inv = Inventory::new();
539        assert!(inv.is_empty());
540        assert_eq!(inv.len(), 0);
541    }
542
543    #[test]
544    fn test_add_simple() {
545        let mut inv = Inventory::new();
546        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
547
548        assert!(!inv.is_empty());
549        assert_eq!(inv.units("USD"), dec!(100));
550    }
551
552    #[test]
553    fn test_add_merge_simple() {
554        let mut inv = Inventory::new();
555        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
556        inv.add(Position::simple(Amount::new(dec!(50), "USD")));
557
558        // Should merge into one position
559        assert_eq!(inv.len(), 1);
560        assert_eq!(inv.units("USD"), dec!(150));
561    }
562
563    #[test]
564    fn test_add_with_cost_no_merge() {
565        let mut inv = Inventory::new();
566
567        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
568        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
569
570        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
571        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
572
573        // Should NOT merge - different costs
574        assert_eq!(inv.len(), 2);
575        assert_eq!(inv.units("AAPL"), dec!(15));
576    }
577
578    #[test]
579    fn test_currencies() {
580        let mut inv = Inventory::new();
581        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
582        inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
583        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
584
585        let currencies = inv.currencies();
586        assert_eq!(currencies.len(), 3);
587        assert!(currencies.contains(&"USD"));
588        assert!(currencies.contains(&"EUR"));
589        assert!(currencies.contains(&"AAPL"));
590    }
591
592    #[test]
593    fn test_reduce_strict_unique() {
594        let mut inv = Inventory::new();
595        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
596        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
597
598        let result = inv
599            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
600            .unwrap();
601
602        assert_eq!(inv.units("AAPL"), dec!(5));
603        assert!(result.cost_basis.is_some());
604        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); // 5 * 150
605    }
606
607    #[test]
608    fn test_reduce_strict_multiple_match_uses_fifo() {
609        let mut inv = Inventory::new();
610
611        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
612        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
613
614        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
615        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
616
617        // Reducing without cost spec in STRICT mode falls back to FIFO
618        // (Python beancount behavior: when multiple lots match, use FIFO order)
619        let result = inv
620            .reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
621            .unwrap();
622
623        assert_eq!(inv.units("AAPL"), dec!(12)); // 7 + 5 remaining
624        // Should reduce from first lot (cost1) at 150.00 USD
625        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); // 3 * 150
626    }
627
628    #[test]
629    fn test_reduce_strict_with_spec() {
630        let mut inv = Inventory::new();
631
632        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
633        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
634
635        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
636        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
637
638        // Reducing with cost spec should work
639        let spec = CostSpec::empty().with_date(date(2024, 1, 1));
640        let result = inv
641            .reduce(
642                &Amount::new(dec!(-3), "AAPL"),
643                Some(&spec),
644                BookingMethod::Strict,
645            )
646            .unwrap();
647
648        assert_eq!(inv.units("AAPL"), dec!(12)); // 7 + 5
649        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); // 3 * 150
650    }
651
652    #[test]
653    fn test_reduce_fifo() {
654        let mut inv = Inventory::new();
655
656        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
657        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
658        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
659
660        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
661        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
662        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
663
664        // FIFO should reduce from oldest (cost 100) first
665        let result = inv
666            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
667            .unwrap();
668
669        assert_eq!(inv.units("AAPL"), dec!(15));
670        // Cost basis: 10 * 100 + 5 * 150 = 1000 + 750 = 1750
671        assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
672    }
673
674    #[test]
675    fn test_reduce_lifo() {
676        let mut inv = Inventory::new();
677
678        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
679        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
680        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
681
682        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
683        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
684        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
685
686        // LIFO should reduce from newest (cost 200) first
687        let result = inv
688            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
689            .unwrap();
690
691        assert_eq!(inv.units("AAPL"), dec!(15));
692        // Cost basis: 10 * 200 + 5 * 150 = 2000 + 750 = 2750
693        assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
694    }
695
696    #[test]
697    fn test_reduce_insufficient() {
698        let mut inv = Inventory::new();
699        let cost = Cost::new(dec!(150.00), "USD");
700        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
701
702        let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
703
704        assert!(matches!(
705            result,
706            Err(BookingError::InsufficientUnits { .. })
707        ));
708    }
709
710    #[test]
711    fn test_book_value() {
712        let mut inv = Inventory::new();
713
714        let cost1 = Cost::new(dec!(100.00), "USD");
715        let cost2 = Cost::new(dec!(150.00), "USD");
716
717        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
718        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
719
720        let book = inv.book_value("AAPL");
721        assert_eq!(book.get("USD"), Some(&dec!(1750.00))); // 10*100 + 5*150
722    }
723
724    #[test]
725    fn test_display() {
726        let mut inv = Inventory::new();
727        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
728
729        let s = format!("{inv}");
730        assert!(s.contains("100 USD"));
731    }
732
733    #[test]
734    fn test_display_empty() {
735        let inv = Inventory::new();
736        assert_eq!(format!("{inv}"), "(empty)");
737    }
738
739    #[test]
740    fn test_from_iterator() {
741        let positions = vec![
742            Position::simple(Amount::new(dec!(100), "USD")),
743            Position::simple(Amount::new(dec!(50), "USD")),
744        ];
745
746        let inv: Inventory = positions.into_iter().collect();
747        assert_eq!(inv.units("USD"), dec!(150));
748    }
749
750    #[test]
751    fn test_add_costed_positions_kept_separate() {
752        // Costed positions are kept as separate lots for O(1) add performance.
753        // Aggregation happens at display time (in query output).
754        let mut inv = Inventory::new();
755
756        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
757
758        // Buy 10 shares
759        inv.add(Position::with_cost(
760            Amount::new(dec!(10), "AAPL"),
761            cost.clone(),
762        ));
763        assert_eq!(inv.len(), 1);
764        assert_eq!(inv.units("AAPL"), dec!(10));
765
766        // Sell 10 shares - kept as separate lot for tracking
767        inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
768        assert_eq!(inv.len(), 2); // Both lots kept
769        assert_eq!(inv.units("AAPL"), dec!(0)); // Net units still zero
770    }
771
772    #[test]
773    fn test_add_costed_positions_net_units() {
774        // Verify that units() correctly sums across all lots
775        let mut inv = Inventory::new();
776
777        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
778
779        // Buy 10 shares
780        inv.add(Position::with_cost(
781            Amount::new(dec!(10), "AAPL"),
782            cost.clone(),
783        ));
784
785        // Sell 3 shares - kept as separate lot
786        inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost));
787        assert_eq!(inv.len(), 2); // Both lots kept
788        assert_eq!(inv.units("AAPL"), dec!(7)); // Net units correct
789    }
790
791    #[test]
792    fn test_add_no_cancel_different_cost() {
793        // Test that different costs don't cancel
794        let mut inv = Inventory::new();
795
796        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
797        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
798
799        // Buy 10 shares at 150
800        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
801
802        // Sell 5 shares at 160 - should NOT cancel (different cost)
803        inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2));
804
805        // Should have two separate lots
806        assert_eq!(inv.len(), 2);
807        assert_eq!(inv.units("AAPL"), dec!(5)); // 10 - 5 = 5 total
808    }
809
810    #[test]
811    fn test_add_no_cancel_same_sign() {
812        // Test that same-sign positions don't merge even with same cost
813        let mut inv = Inventory::new();
814
815        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
816
817        // Buy 10 shares
818        inv.add(Position::with_cost(
819            Amount::new(dec!(10), "AAPL"),
820            cost.clone(),
821        ));
822
823        // Buy 5 more shares with same cost - should NOT merge
824        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
825
826        // Should have two separate lots (different acquisitions)
827        assert_eq!(inv.len(), 2);
828        assert_eq!(inv.units("AAPL"), dec!(15));
829    }
830
831    #[test]
832    fn test_merge_keeps_lots_separate() {
833        // Test that merge keeps costed lots separate (aggregation at display time)
834        let mut inv1 = Inventory::new();
835        let mut inv2 = Inventory::new();
836
837        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
838
839        // inv1: buy 10 shares
840        inv1.add(Position::with_cost(
841            Amount::new(dec!(10), "AAPL"),
842            cost.clone(),
843        ));
844
845        // inv2: sell 10 shares
846        inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
847
848        // Merge keeps both lots, net units is zero
849        inv1.merge(&inv2);
850        assert_eq!(inv1.len(), 2); // Both lots preserved
851        assert_eq!(inv1.units("AAPL"), dec!(0)); // Net units correct
852    }
853
854    // ====================================================================
855    // Phase 2: Additional Coverage Tests for Booking Methods
856    // ====================================================================
857
858    #[test]
859    fn test_hifo_with_tie_breaking() {
860        // When multiple lots have the same cost, HIFO should use insertion order
861        let mut inv = Inventory::new();
862
863        // Three lots with same cost but different dates
864        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
865        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
866        let cost3 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 3, 1));
867
868        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
869        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
870        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
871
872        // HIFO with tied costs should reduce in some deterministic order
873        let result = inv
874            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
875            .unwrap();
876
877        assert_eq!(inv.units("AAPL"), dec!(15));
878        // All at same cost, so 15 * 100 = 1500
879        assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
880    }
881
882    #[test]
883    fn test_hifo_with_different_costs() {
884        // HIFO should reduce highest cost lots first
885        let mut inv = Inventory::new();
886
887        let cost_low = Cost::new(dec!(50.00), "USD").with_date(date(2024, 1, 1));
888        let cost_mid = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
889        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
890
891        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
892        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
893        inv.add(Position::with_cost(
894            Amount::new(dec!(10), "AAPL"),
895            cost_high,
896        ));
897
898        // Reduce 15 shares - should take from highest cost (200) first
899        let result = inv
900            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
901            .unwrap();
902
903        assert_eq!(inv.units("AAPL"), dec!(15));
904        // 10 * 200 + 5 * 100 = 2000 + 500 = 2500
905        assert_eq!(result.cost_basis.unwrap().number, dec!(2500.00));
906    }
907
908    #[test]
909    fn test_average_booking_with_pre_existing_positions() {
910        let mut inv = Inventory::new();
911
912        // Add two lots with different costs
913        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
914        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
915
916        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
917        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
918
919        // Total: 20 shares, total cost = 10*100 + 10*200 = 3000, avg = 150/share
920        // Reduce 5 shares using AVERAGE
921        let result = inv
922            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
923            .unwrap();
924
925        assert_eq!(inv.units("AAPL"), dec!(15));
926        // Cost basis for 5 shares at average 150 = 750
927        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
928    }
929
930    #[test]
931    fn test_average_booking_reduces_all() {
932        let mut inv = Inventory::new();
933
934        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
935        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
936
937        // Reduce all shares
938        let result = inv
939            .reduce(
940                &Amount::new(dec!(-10), "AAPL"),
941                None,
942                BookingMethod::Average,
943            )
944            .unwrap();
945
946        assert!(inv.is_empty() || inv.units("AAPL").is_zero());
947        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
948    }
949
950    #[test]
951    fn test_none_booking_augmentation() {
952        // NONE booking with same-sign amounts should augment, not reduce
953        let mut inv = Inventory::new();
954        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
955
956        // Adding more (same sign) - this is an augmentation
957        let result = inv
958            .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
959            .unwrap();
960
961        assert_eq!(inv.units("USD"), dec!(150));
962        assert!(result.matched.is_empty()); // No lots matched for augmentation
963        assert!(result.cost_basis.is_none());
964    }
965
966    #[test]
967    fn test_none_booking_reduction() {
968        // NONE booking with opposite-sign should reduce
969        let mut inv = Inventory::new();
970        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
971
972        let result = inv
973            .reduce(&Amount::new(dec!(-30), "USD"), None, BookingMethod::None)
974            .unwrap();
975
976        assert_eq!(inv.units("USD"), dec!(70));
977        assert!(!result.matched.is_empty());
978    }
979
980    #[test]
981    fn test_none_booking_insufficient() {
982        let mut inv = Inventory::new();
983        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
984
985        let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
986
987        assert!(matches!(
988            result,
989            Err(BookingError::InsufficientUnits { .. })
990        ));
991    }
992
993    #[test]
994    fn test_booking_error_no_matching_lot() {
995        let mut inv = Inventory::new();
996
997        // Add a lot with specific cost
998        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
999        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1000
1001        // Try to reduce with a cost spec that doesn't match
1002        let wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
1003        let result = inv.reduce(
1004            &Amount::new(dec!(-5), "AAPL"),
1005            Some(&wrong_spec),
1006            BookingMethod::Strict,
1007        );
1008
1009        assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1010    }
1011
1012    #[test]
1013    fn test_booking_error_insufficient_units() {
1014        let mut inv = Inventory::new();
1015
1016        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1017        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1018
1019        // Try to reduce more than available
1020        let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
1021
1022        match result {
1023            Err(BookingError::InsufficientUnits {
1024                requested,
1025                available,
1026                ..
1027            }) => {
1028                assert_eq!(requested, dec!(20));
1029                assert_eq!(available, dec!(10));
1030            }
1031            _ => panic!("Expected InsufficientUnits error"),
1032        }
1033    }
1034
1035    #[test]
1036    fn test_strict_with_size_exact_match() {
1037        let mut inv = Inventory::new();
1038
1039        // Add two lots with same cost but different sizes
1040        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1041        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1042
1043        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1044        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1045
1046        // Reduce exactly 5 - should match the 5-share lot
1047        let result = inv
1048            .reduce(
1049                &Amount::new(dec!(-5), "AAPL"),
1050                None,
1051                BookingMethod::StrictWithSize,
1052            )
1053            .unwrap();
1054
1055        assert_eq!(inv.units("AAPL"), dec!(10));
1056        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1057    }
1058
1059    #[test]
1060    fn test_strict_with_size_total_match() {
1061        let mut inv = Inventory::new();
1062
1063        // Add two lots
1064        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1065        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1066
1067        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1068        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1069
1070        // Reduce exactly 15 (total) - should succeed via total match exception
1071        let result = inv
1072            .reduce(
1073                &Amount::new(dec!(-15), "AAPL"),
1074                None,
1075                BookingMethod::StrictWithSize,
1076            )
1077            .unwrap();
1078
1079        assert_eq!(inv.units("AAPL"), dec!(0));
1080        assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1081    }
1082
1083    #[test]
1084    fn test_strict_with_size_ambiguous() {
1085        let mut inv = Inventory::new();
1086
1087        // Add two lots of same size and cost
1088        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1089        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1090
1091        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1092        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1093
1094        // Reduce 7 shares - doesn't match either lot exactly, not total
1095        let result = inv.reduce(
1096            &Amount::new(dec!(-7), "AAPL"),
1097            None,
1098            BookingMethod::StrictWithSize,
1099        );
1100
1101        assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
1102    }
1103
1104    #[test]
1105    fn test_short_position() {
1106        // Test short selling (negative positions)
1107        let mut inv = Inventory::new();
1108
1109        // Short 10 shares
1110        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1111        inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1112
1113        assert_eq!(inv.units("AAPL"), dec!(-10));
1114        assert!(!inv.is_empty());
1115    }
1116
1117    #[test]
1118    fn test_at_cost() {
1119        let mut inv = Inventory::new();
1120
1121        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1122        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1123
1124        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1125        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1126        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1127
1128        let at_cost = inv.at_cost();
1129
1130        // AAPL converted: 10*100 + 5*150 = 1000 + 750 = 1750 USD
1131        // Plus 100 USD simple position = 1850 USD total
1132        assert_eq!(at_cost.units("USD"), dec!(1850));
1133        assert_eq!(at_cost.units("AAPL"), dec!(0)); // No AAPL in cost view
1134    }
1135
1136    #[test]
1137    fn test_at_units() {
1138        let mut inv = Inventory::new();
1139
1140        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1141        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1142
1143        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1144        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1145
1146        let at_units = inv.at_units();
1147
1148        // All AAPL lots merged
1149        assert_eq!(at_units.units("AAPL"), dec!(15));
1150        // Should only have one position after aggregation
1151        assert_eq!(at_units.len(), 1);
1152    }
1153
1154    #[test]
1155    fn test_add_empty_position() {
1156        let mut inv = Inventory::new();
1157        inv.add(Position::simple(Amount::new(dec!(0), "USD")));
1158
1159        assert!(inv.is_empty());
1160        assert_eq!(inv.len(), 0);
1161    }
1162
1163    #[test]
1164    fn test_compact() {
1165        let mut inv = Inventory::new();
1166
1167        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1168        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1169
1170        // Reduce all
1171        inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
1172            .unwrap();
1173
1174        // Compact to remove empty positions
1175        inv.compact();
1176        assert!(inv.is_empty());
1177        assert_eq!(inv.len(), 0);
1178    }
1179
1180    #[test]
1181    fn test_booking_method_from_str() {
1182        assert_eq!(
1183            BookingMethod::from_str("STRICT").unwrap(),
1184            BookingMethod::Strict
1185        );
1186        assert_eq!(
1187            BookingMethod::from_str("fifo").unwrap(),
1188            BookingMethod::Fifo
1189        );
1190        assert_eq!(
1191            BookingMethod::from_str("LIFO").unwrap(),
1192            BookingMethod::Lifo
1193        );
1194        assert_eq!(
1195            BookingMethod::from_str("Hifo").unwrap(),
1196            BookingMethod::Hifo
1197        );
1198        assert_eq!(
1199            BookingMethod::from_str("AVERAGE").unwrap(),
1200            BookingMethod::Average
1201        );
1202        assert_eq!(
1203            BookingMethod::from_str("NONE").unwrap(),
1204            BookingMethod::None
1205        );
1206        assert_eq!(
1207            BookingMethod::from_str("strict_with_size").unwrap(),
1208            BookingMethod::StrictWithSize
1209        );
1210        assert!(BookingMethod::from_str("INVALID").is_err());
1211    }
1212
1213    #[test]
1214    fn test_booking_method_display() {
1215        assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
1216        assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
1217        assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
1218        assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
1219        assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
1220        assert_eq!(format!("{}", BookingMethod::None), "NONE");
1221        assert_eq!(
1222            format!("{}", BookingMethod::StrictWithSize),
1223            "STRICT_WITH_SIZE"
1224        );
1225    }
1226
1227    #[test]
1228    fn test_booking_error_display() {
1229        let err = BookingError::AmbiguousMatch {
1230            num_matches: 3,
1231            currency: "AAPL".into(),
1232        };
1233        assert!(format!("{err}").contains("3 lots match"));
1234
1235        let err = BookingError::NoMatchingLot {
1236            currency: "AAPL".into(),
1237            cost_spec: CostSpec::empty(),
1238        };
1239        assert!(format!("{err}").contains("No matching lot"));
1240
1241        let err = BookingError::InsufficientUnits {
1242            currency: "AAPL".into(),
1243            requested: dec!(100),
1244            available: dec!(50),
1245        };
1246        assert!(format!("{err}").contains("requested 100"));
1247        assert!(format!("{err}").contains("available 50"));
1248
1249        let err = BookingError::CurrencyMismatch {
1250            expected: "USD".into(),
1251            got: "EUR".into(),
1252        };
1253        assert!(format!("{err}").contains("expected USD"));
1254        assert!(format!("{err}").contains("got EUR"));
1255    }
1256
1257    #[test]
1258    fn test_book_value_multiple_currencies() {
1259        let mut inv = Inventory::new();
1260
1261        // Cost in USD
1262        let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1263        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd));
1264
1265        // Cost in EUR
1266        let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
1267        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur));
1268
1269        let book = inv.book_value("AAPL");
1270        assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
1271        assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
1272    }
1273
1274    #[test]
1275    fn test_reduce_hifo_insufficient_units() {
1276        let mut inv = Inventory::new();
1277
1278        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1279        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1280
1281        let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
1282
1283        assert!(matches!(
1284            result,
1285            Err(BookingError::InsufficientUnits { .. })
1286        ));
1287    }
1288
1289    #[test]
1290    fn test_reduce_average_insufficient_units() {
1291        let mut inv = Inventory::new();
1292
1293        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1294        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1295
1296        let result = inv.reduce(
1297            &Amount::new(dec!(-20), "AAPL"),
1298            None,
1299            BookingMethod::Average,
1300        );
1301
1302        assert!(matches!(
1303            result,
1304            Err(BookingError::InsufficientUnits { .. })
1305        ));
1306    }
1307
1308    #[test]
1309    fn test_reduce_average_empty_inventory() {
1310        let mut inv = Inventory::new();
1311
1312        let result = inv.reduce(
1313            &Amount::new(dec!(-10), "AAPL"),
1314            None,
1315            BookingMethod::Average,
1316        );
1317
1318        assert!(matches!(
1319            result,
1320            Err(BookingError::InsufficientUnits { .. })
1321        ));
1322    }
1323
1324    #[test]
1325    fn test_inventory_display_sorted() {
1326        let mut inv = Inventory::new();
1327
1328        // Add in non-alphabetical order
1329        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1330        inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
1331        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
1332
1333        let display = format!("{inv}");
1334
1335        // Should be sorted alphabetically: AAPL, EUR, USD
1336        let aapl_pos = display.find("AAPL").unwrap();
1337        let eur_pos = display.find("EUR").unwrap();
1338        let usd_pos = display.find("USD").unwrap();
1339
1340        assert!(aapl_pos < eur_pos);
1341        assert!(eur_pos < usd_pos);
1342    }
1343
1344    #[test]
1345    fn test_inventory_with_cost_display_sorted() {
1346        let mut inv = Inventory::new();
1347
1348        // Add same currency with different costs
1349        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
1350        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1351
1352        inv.add(Position::with_cost(
1353            Amount::new(dec!(10), "AAPL"),
1354            cost_high,
1355        ));
1356        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1357
1358        let display = format!("{inv}");
1359
1360        // Both positions should be in the output
1361        assert!(display.contains("AAPL"));
1362        assert!(display.contains("100"));
1363        assert!(display.contains("200"));
1364    }
1365
1366    #[test]
1367    fn test_reduce_hifo_no_matching_lot() {
1368        let mut inv = Inventory::new();
1369
1370        // No AAPL positions
1371        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1372
1373        let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
1374
1375        assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1376    }
1377
1378    #[test]
1379    fn test_fifo_respects_dates() {
1380        // Ensure FIFO uses acquisition date, not insertion order
1381        let mut inv = Inventory::new();
1382
1383        // Add newer lot first (out of order)
1384        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1385        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1386
1387        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1388        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1389
1390        // FIFO should reduce from oldest (cost 100) first
1391        let result = inv
1392            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
1393            .unwrap();
1394
1395        // Should use cost from oldest lot (100)
1396        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1397    }
1398
1399    #[test]
1400    fn test_lifo_respects_dates() {
1401        // Ensure LIFO uses acquisition date, not insertion order
1402        let mut inv = Inventory::new();
1403
1404        // Add older lot first
1405        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1406        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1407
1408        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1409        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1410
1411        // LIFO should reduce from newest (cost 200) first
1412        let result = inv
1413            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
1414            .unwrap();
1415
1416        // Should use cost from newest lot (200)
1417        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1418    }
1419}