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 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                if let Some(book) = pos.book_value() {
295                    *totals.entry(book.currency.clone()).or_default() += book.number;
296                }
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    pub fn add(&mut self, position: Position) {
312        if position.is_empty() {
313            return;
314        }
315
316        // Update units cache
317        *self
318            .units_cache
319            .entry(position.units.currency.clone())
320            .or_default() += position.units.number;
321
322        // For positions without cost, use index for O(1) lookup
323        if position.cost.is_none() {
324            if let Some(&idx) = self.simple_index.get(&position.units.currency) {
325                // Merge with existing position
326                debug_assert!(self.positions[idx].cost.is_none());
327                self.positions[idx].units += &position.units;
328                return;
329            }
330            // No existing position - add new one and index it
331            let idx = self.positions.len();
332            self.simple_index
333                .insert(position.units.currency.clone(), idx);
334            self.positions.push(position);
335            return;
336        }
337
338        // For positions with cost, just add as a new lot.
339        // This is O(1) and keeps all lots separate, matching Python beancount behavior.
340        // Lot aggregation for display purposes is handled separately in query output.
341        self.positions.push(position);
342    }
343
344    /// Reduce positions from the inventory using the specified booking method.
345    ///
346    /// # Arguments
347    ///
348    /// * `units` - The units to reduce (negative for selling)
349    /// * `cost_spec` - Optional cost specification for matching lots
350    /// * `method` - The booking method to use
351    ///
352    /// # Returns
353    ///
354    /// Returns a `BookingResult` with the matched positions and cost basis,
355    /// or a `BookingError` if the reduction cannot be performed.
356    pub fn reduce(
357        &mut self,
358        units: &Amount,
359        cost_spec: Option<&CostSpec>,
360        method: BookingMethod,
361    ) -> Result<BookingResult, BookingError> {
362        let spec = cost_spec.cloned().unwrap_or_default();
363
364        match method {
365            BookingMethod::Strict => self.reduce_strict(units, &spec),
366            BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
367            BookingMethod::Fifo => self.reduce_fifo(units, &spec),
368            BookingMethod::Lifo => self.reduce_lifo(units, &spec),
369            BookingMethod::Hifo => self.reduce_hifo(units, &spec),
370            BookingMethod::Average => self.reduce_average(units),
371            BookingMethod::None => self.reduce_none(units),
372        }
373    }
374
375    /// Remove all empty positions.
376    pub fn compact(&mut self) {
377        self.positions.retain(|p| !p.is_empty());
378        self.rebuild_index();
379    }
380
381    /// Rebuild all caches (`simple_index` and `units_cache`) from positions.
382    /// Called after operations that may invalidate caches (like retain or deserialization).
383    fn rebuild_index(&mut self) {
384        self.simple_index.clear();
385        self.units_cache.clear();
386
387        for (idx, pos) in self.positions.iter().enumerate() {
388            // Update units cache for all positions
389            *self
390                .units_cache
391                .entry(pos.units.currency.clone())
392                .or_default() += pos.units.number;
393
394            // Update simple_index only for positions without cost
395            if pos.cost.is_none() {
396                debug_assert!(
397                    !self.simple_index.contains_key(&pos.units.currency),
398                    "Invariant violated: multiple simple positions for currency {}",
399                    pos.units.currency
400                );
401                self.simple_index.insert(pos.units.currency.clone(), idx);
402            }
403        }
404    }
405
406    /// Merge this inventory with another.
407    pub fn merge(&mut self, other: &Self) {
408        for pos in &other.positions {
409            self.add(pos.clone());
410        }
411    }
412
413    /// Convert inventory to cost basis.
414    ///
415    /// Returns a new inventory where all positions are converted to their
416    /// cost basis. Positions without cost are returned as-is.
417    #[must_use]
418    pub fn at_cost(&self) -> Self {
419        let mut result = Self::new();
420
421        for pos in &self.positions {
422            if pos.is_empty() {
423                continue;
424            }
425
426            if let Some(cost) = &pos.cost {
427                // Convert to cost basis
428                let total = pos.units.number * cost.number;
429                result.add(Position::simple(Amount::new(total, &cost.currency)));
430            } else {
431                // No cost, keep as-is
432                result.add(pos.clone());
433            }
434        }
435
436        result
437    }
438
439    /// Convert inventory to units only.
440    ///
441    /// Returns a new inventory where all positions have their cost removed,
442    /// effectively aggregating by currency only.
443    #[must_use]
444    pub fn at_units(&self) -> Self {
445        let mut result = Self::new();
446
447        for pos in &self.positions {
448            if pos.is_empty() {
449                continue;
450            }
451
452            // Strip cost, keep only units
453            result.add(Position::simple(pos.units.clone()));
454        }
455
456        result
457    }
458}
459
460impl fmt::Display for Inventory {
461    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
462        if self.is_empty() {
463            return write!(f, "(empty)");
464        }
465
466        // Sort positions alphabetically by currency, then by cost for consistency
467        let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
468        non_empty.sort_by(|a, b| {
469            // First by currency
470            let cmp = a.units.currency.cmp(&b.units.currency);
471            if cmp != std::cmp::Ordering::Equal {
472                return cmp;
473            }
474            // Then by cost (if present)
475            match (&a.cost, &b.cost) {
476                (Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
477                (Some(_), None) => std::cmp::Ordering::Greater,
478                (None, Some(_)) => std::cmp::Ordering::Less,
479                (None, None) => std::cmp::Ordering::Equal,
480            }
481        });
482
483        for (i, pos) in non_empty.iter().enumerate() {
484            if i > 0 {
485                write!(f, ", ")?;
486            }
487            write!(f, "{pos}")?;
488        }
489        Ok(())
490    }
491}
492
493impl FromIterator<Position> for Inventory {
494    fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
495        let mut inv = Self::new();
496        for pos in iter {
497            inv.add(pos);
498        }
499        inv
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use crate::Cost;
507    use chrono::NaiveDate;
508    use rust_decimal_macros::dec;
509
510    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
511        NaiveDate::from_ymd_opt(year, month, day).unwrap()
512    }
513
514    #[test]
515    fn test_empty_inventory() {
516        let inv = Inventory::new();
517        assert!(inv.is_empty());
518        assert_eq!(inv.len(), 0);
519    }
520
521    #[test]
522    fn test_add_simple() {
523        let mut inv = Inventory::new();
524        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
525
526        assert!(!inv.is_empty());
527        assert_eq!(inv.units("USD"), dec!(100));
528    }
529
530    #[test]
531    fn test_add_merge_simple() {
532        let mut inv = Inventory::new();
533        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
534        inv.add(Position::simple(Amount::new(dec!(50), "USD")));
535
536        // Should merge into one position
537        assert_eq!(inv.len(), 1);
538        assert_eq!(inv.units("USD"), dec!(150));
539    }
540
541    #[test]
542    fn test_add_with_cost_no_merge() {
543        let mut inv = Inventory::new();
544
545        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
546        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
547
548        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
549        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
550
551        // Should NOT merge - different costs
552        assert_eq!(inv.len(), 2);
553        assert_eq!(inv.units("AAPL"), dec!(15));
554    }
555
556    #[test]
557    fn test_currencies() {
558        let mut inv = Inventory::new();
559        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
560        inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
561        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
562
563        let currencies = inv.currencies();
564        assert_eq!(currencies.len(), 3);
565        assert!(currencies.contains(&"USD"));
566        assert!(currencies.contains(&"EUR"));
567        assert!(currencies.contains(&"AAPL"));
568    }
569
570    #[test]
571    fn test_reduce_strict_unique() {
572        let mut inv = Inventory::new();
573        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
574        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
575
576        let result = inv
577            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
578            .unwrap();
579
580        assert_eq!(inv.units("AAPL"), dec!(5));
581        assert!(result.cost_basis.is_some());
582        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); // 5 * 150
583    }
584
585    #[test]
586    fn test_reduce_strict_multiple_match_uses_fifo() {
587        let mut inv = Inventory::new();
588
589        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
590        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
591
592        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
593        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
594
595        // Reducing without cost spec in STRICT mode falls back to FIFO
596        // (Python beancount behavior: when multiple lots match, use FIFO order)
597        let result = inv
598            .reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
599            .unwrap();
600
601        assert_eq!(inv.units("AAPL"), dec!(12)); // 7 + 5 remaining
602        // Should reduce from first lot (cost1) at 150.00 USD
603        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); // 3 * 150
604    }
605
606    #[test]
607    fn test_reduce_strict_with_spec() {
608        let mut inv = Inventory::new();
609
610        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
611        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
612
613        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
614        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
615
616        // Reducing with cost spec should work
617        let spec = CostSpec::empty().with_date(date(2024, 1, 1));
618        let result = inv
619            .reduce(
620                &Amount::new(dec!(-3), "AAPL"),
621                Some(&spec),
622                BookingMethod::Strict,
623            )
624            .unwrap();
625
626        assert_eq!(inv.units("AAPL"), dec!(12)); // 7 + 5
627        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); // 3 * 150
628    }
629
630    #[test]
631    fn test_reduce_fifo() {
632        let mut inv = Inventory::new();
633
634        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
635        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
636        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
637
638        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
639        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
640        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
641
642        // FIFO should reduce from oldest (cost 100) first
643        let result = inv
644            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
645            .unwrap();
646
647        assert_eq!(inv.units("AAPL"), dec!(15));
648        // Cost basis: 10 * 100 + 5 * 150 = 1000 + 750 = 1750
649        assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
650    }
651
652    #[test]
653    fn test_reduce_lifo() {
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        // LIFO should reduce from newest (cost 200) first
665        let result = inv
666            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
667            .unwrap();
668
669        assert_eq!(inv.units("AAPL"), dec!(15));
670        // Cost basis: 10 * 200 + 5 * 150 = 2000 + 750 = 2750
671        assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
672    }
673
674    #[test]
675    fn test_reduce_insufficient() {
676        let mut inv = Inventory::new();
677        let cost = Cost::new(dec!(150.00), "USD");
678        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
679
680        let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
681
682        assert!(matches!(
683            result,
684            Err(BookingError::InsufficientUnits { .. })
685        ));
686    }
687
688    #[test]
689    fn test_book_value() {
690        let mut inv = Inventory::new();
691
692        let cost1 = Cost::new(dec!(100.00), "USD");
693        let cost2 = Cost::new(dec!(150.00), "USD");
694
695        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
696        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
697
698        let book = inv.book_value("AAPL");
699        assert_eq!(book.get("USD"), Some(&dec!(1750.00))); // 10*100 + 5*150
700    }
701
702    #[test]
703    fn test_display() {
704        let mut inv = Inventory::new();
705        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
706
707        let s = format!("{inv}");
708        assert!(s.contains("100 USD"));
709    }
710
711    #[test]
712    fn test_display_empty() {
713        let inv = Inventory::new();
714        assert_eq!(format!("{inv}"), "(empty)");
715    }
716
717    #[test]
718    fn test_from_iterator() {
719        let positions = vec![
720            Position::simple(Amount::new(dec!(100), "USD")),
721            Position::simple(Amount::new(dec!(50), "USD")),
722        ];
723
724        let inv: Inventory = positions.into_iter().collect();
725        assert_eq!(inv.units("USD"), dec!(150));
726    }
727
728    #[test]
729    fn test_add_costed_positions_kept_separate() {
730        // Costed positions are kept as separate lots for O(1) add performance.
731        // Aggregation happens at display time (in query output).
732        let mut inv = Inventory::new();
733
734        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
735
736        // Buy 10 shares
737        inv.add(Position::with_cost(
738            Amount::new(dec!(10), "AAPL"),
739            cost.clone(),
740        ));
741        assert_eq!(inv.len(), 1);
742        assert_eq!(inv.units("AAPL"), dec!(10));
743
744        // Sell 10 shares - kept as separate lot for tracking
745        inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
746        assert_eq!(inv.len(), 2); // Both lots kept
747        assert_eq!(inv.units("AAPL"), dec!(0)); // Net units still zero
748    }
749
750    #[test]
751    fn test_add_costed_positions_net_units() {
752        // Verify that units() correctly sums across all lots
753        let mut inv = Inventory::new();
754
755        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
756
757        // Buy 10 shares
758        inv.add(Position::with_cost(
759            Amount::new(dec!(10), "AAPL"),
760            cost.clone(),
761        ));
762
763        // Sell 3 shares - kept as separate lot
764        inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost));
765        assert_eq!(inv.len(), 2); // Both lots kept
766        assert_eq!(inv.units("AAPL"), dec!(7)); // Net units correct
767    }
768
769    #[test]
770    fn test_add_no_cancel_different_cost() {
771        // Test that different costs don't cancel
772        let mut inv = Inventory::new();
773
774        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
775        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
776
777        // Buy 10 shares at 150
778        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
779
780        // Sell 5 shares at 160 - should NOT cancel (different cost)
781        inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2));
782
783        // Should have two separate lots
784        assert_eq!(inv.len(), 2);
785        assert_eq!(inv.units("AAPL"), dec!(5)); // 10 - 5 = 5 total
786    }
787
788    #[test]
789    fn test_add_no_cancel_same_sign() {
790        // Test that same-sign positions don't merge even with same cost
791        let mut inv = Inventory::new();
792
793        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
794
795        // Buy 10 shares
796        inv.add(Position::with_cost(
797            Amount::new(dec!(10), "AAPL"),
798            cost.clone(),
799        ));
800
801        // Buy 5 more shares with same cost - should NOT merge
802        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
803
804        // Should have two separate lots (different acquisitions)
805        assert_eq!(inv.len(), 2);
806        assert_eq!(inv.units("AAPL"), dec!(15));
807    }
808
809    #[test]
810    fn test_merge_keeps_lots_separate() {
811        // Test that merge keeps costed lots separate (aggregation at display time)
812        let mut inv1 = Inventory::new();
813        let mut inv2 = Inventory::new();
814
815        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
816
817        // inv1: buy 10 shares
818        inv1.add(Position::with_cost(
819            Amount::new(dec!(10), "AAPL"),
820            cost.clone(),
821        ));
822
823        // inv2: sell 10 shares
824        inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
825
826        // Merge keeps both lots, net units is zero
827        inv1.merge(&inv2);
828        assert_eq!(inv1.len(), 2); // Both lots preserved
829        assert_eq!(inv1.units("AAPL"), dec!(0)); // Net units correct
830    }
831
832    // ====================================================================
833    // Phase 2: Additional Coverage Tests for Booking Methods
834    // ====================================================================
835
836    #[test]
837    fn test_hifo_with_tie_breaking() {
838        // When multiple lots have the same cost, HIFO should use insertion order
839        let mut inv = Inventory::new();
840
841        // Three lots with same cost but different dates
842        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
843        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
844        let cost3 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 3, 1));
845
846        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
847        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
848        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
849
850        // HIFO with tied costs should reduce in some deterministic order
851        let result = inv
852            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
853            .unwrap();
854
855        assert_eq!(inv.units("AAPL"), dec!(15));
856        // All at same cost, so 15 * 100 = 1500
857        assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
858    }
859
860    #[test]
861    fn test_hifo_with_different_costs() {
862        // HIFO should reduce highest cost lots first
863        let mut inv = Inventory::new();
864
865        let cost_low = Cost::new(dec!(50.00), "USD").with_date(date(2024, 1, 1));
866        let cost_mid = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
867        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
868
869        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
870        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
871        inv.add(Position::with_cost(
872            Amount::new(dec!(10), "AAPL"),
873            cost_high,
874        ));
875
876        // Reduce 15 shares - should take from highest cost (200) first
877        let result = inv
878            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
879            .unwrap();
880
881        assert_eq!(inv.units("AAPL"), dec!(15));
882        // 10 * 200 + 5 * 100 = 2000 + 500 = 2500
883        assert_eq!(result.cost_basis.unwrap().number, dec!(2500.00));
884    }
885
886    #[test]
887    fn test_average_booking_with_pre_existing_positions() {
888        let mut inv = Inventory::new();
889
890        // Add two lots with different costs
891        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
892        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
893
894        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
895        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
896
897        // Total: 20 shares, total cost = 10*100 + 10*200 = 3000, avg = 150/share
898        // Reduce 5 shares using AVERAGE
899        let result = inv
900            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
901            .unwrap();
902
903        assert_eq!(inv.units("AAPL"), dec!(15));
904        // Cost basis for 5 shares at average 150 = 750
905        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
906    }
907
908    #[test]
909    fn test_average_booking_reduces_all() {
910        let mut inv = Inventory::new();
911
912        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
913        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
914
915        // Reduce all shares
916        let result = inv
917            .reduce(
918                &Amount::new(dec!(-10), "AAPL"),
919                None,
920                BookingMethod::Average,
921            )
922            .unwrap();
923
924        assert!(inv.is_empty() || inv.units("AAPL").is_zero());
925        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
926    }
927
928    #[test]
929    fn test_none_booking_augmentation() {
930        // NONE booking with same-sign amounts should augment, not reduce
931        let mut inv = Inventory::new();
932        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
933
934        // Adding more (same sign) - this is an augmentation
935        let result = inv
936            .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
937            .unwrap();
938
939        assert_eq!(inv.units("USD"), dec!(150));
940        assert!(result.matched.is_empty()); // No lots matched for augmentation
941        assert!(result.cost_basis.is_none());
942    }
943
944    #[test]
945    fn test_none_booking_reduction() {
946        // NONE booking with opposite-sign should reduce
947        let mut inv = Inventory::new();
948        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
949
950        let result = inv
951            .reduce(&Amount::new(dec!(-30), "USD"), None, BookingMethod::None)
952            .unwrap();
953
954        assert_eq!(inv.units("USD"), dec!(70));
955        assert!(!result.matched.is_empty());
956    }
957
958    #[test]
959    fn test_none_booking_insufficient() {
960        let mut inv = Inventory::new();
961        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
962
963        let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
964
965        assert!(matches!(
966            result,
967            Err(BookingError::InsufficientUnits { .. })
968        ));
969    }
970
971    #[test]
972    fn test_booking_error_no_matching_lot() {
973        let mut inv = Inventory::new();
974
975        // Add a lot with specific cost
976        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
977        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
978
979        // Try to reduce with a cost spec that doesn't match
980        let wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
981        let result = inv.reduce(
982            &Amount::new(dec!(-5), "AAPL"),
983            Some(&wrong_spec),
984            BookingMethod::Strict,
985        );
986
987        assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
988    }
989
990    #[test]
991    fn test_booking_error_insufficient_units() {
992        let mut inv = Inventory::new();
993
994        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
995        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
996
997        // Try to reduce more than available
998        let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
999
1000        match result {
1001            Err(BookingError::InsufficientUnits {
1002                requested,
1003                available,
1004                ..
1005            }) => {
1006                assert_eq!(requested, dec!(20));
1007                assert_eq!(available, dec!(10));
1008            }
1009            _ => panic!("Expected InsufficientUnits error"),
1010        }
1011    }
1012
1013    #[test]
1014    fn test_strict_with_size_exact_match() {
1015        let mut inv = Inventory::new();
1016
1017        // Add two lots with same cost but different sizes
1018        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1019        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1020
1021        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1022        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1023
1024        // Reduce exactly 5 - should match the 5-share lot
1025        let result = inv
1026            .reduce(
1027                &Amount::new(dec!(-5), "AAPL"),
1028                None,
1029                BookingMethod::StrictWithSize,
1030            )
1031            .unwrap();
1032
1033        assert_eq!(inv.units("AAPL"), dec!(10));
1034        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1035    }
1036
1037    #[test]
1038    fn test_strict_with_size_total_match() {
1039        let mut inv = Inventory::new();
1040
1041        // Add two lots
1042        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1043        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1044
1045        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1046        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1047
1048        // Reduce exactly 15 (total) - should succeed via total match exception
1049        let result = inv
1050            .reduce(
1051                &Amount::new(dec!(-15), "AAPL"),
1052                None,
1053                BookingMethod::StrictWithSize,
1054            )
1055            .unwrap();
1056
1057        assert_eq!(inv.units("AAPL"), dec!(0));
1058        assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1059    }
1060
1061    #[test]
1062    fn test_strict_with_size_ambiguous() {
1063        let mut inv = Inventory::new();
1064
1065        // Add two lots of same size and cost
1066        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1067        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1068
1069        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1070        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1071
1072        // Reduce 7 shares - doesn't match either lot exactly, not total
1073        let result = inv.reduce(
1074            &Amount::new(dec!(-7), "AAPL"),
1075            None,
1076            BookingMethod::StrictWithSize,
1077        );
1078
1079        assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
1080    }
1081
1082    #[test]
1083    fn test_short_position() {
1084        // Test short selling (negative positions)
1085        let mut inv = Inventory::new();
1086
1087        // Short 10 shares
1088        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1089        inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1090
1091        assert_eq!(inv.units("AAPL"), dec!(-10));
1092        assert!(!inv.is_empty());
1093    }
1094
1095    #[test]
1096    fn test_at_cost() {
1097        let mut inv = Inventory::new();
1098
1099        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1100        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1101
1102        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1103        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1104        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1105
1106        let at_cost = inv.at_cost();
1107
1108        // AAPL converted: 10*100 + 5*150 = 1000 + 750 = 1750 USD
1109        // Plus 100 USD simple position = 1850 USD total
1110        assert_eq!(at_cost.units("USD"), dec!(1850));
1111        assert_eq!(at_cost.units("AAPL"), dec!(0)); // No AAPL in cost view
1112    }
1113
1114    #[test]
1115    fn test_at_units() {
1116        let mut inv = Inventory::new();
1117
1118        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1119        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1120
1121        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1122        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1123
1124        let at_units = inv.at_units();
1125
1126        // All AAPL lots merged
1127        assert_eq!(at_units.units("AAPL"), dec!(15));
1128        // Should only have one position after aggregation
1129        assert_eq!(at_units.len(), 1);
1130    }
1131
1132    #[test]
1133    fn test_add_empty_position() {
1134        let mut inv = Inventory::new();
1135        inv.add(Position::simple(Amount::new(dec!(0), "USD")));
1136
1137        assert!(inv.is_empty());
1138        assert_eq!(inv.len(), 0);
1139    }
1140
1141    #[test]
1142    fn test_compact() {
1143        let mut inv = Inventory::new();
1144
1145        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1146        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1147
1148        // Reduce all
1149        inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
1150            .unwrap();
1151
1152        // Compact to remove empty positions
1153        inv.compact();
1154        assert!(inv.is_empty());
1155        assert_eq!(inv.len(), 0);
1156    }
1157
1158    #[test]
1159    fn test_booking_method_from_str() {
1160        assert_eq!(
1161            BookingMethod::from_str("STRICT").unwrap(),
1162            BookingMethod::Strict
1163        );
1164        assert_eq!(
1165            BookingMethod::from_str("fifo").unwrap(),
1166            BookingMethod::Fifo
1167        );
1168        assert_eq!(
1169            BookingMethod::from_str("LIFO").unwrap(),
1170            BookingMethod::Lifo
1171        );
1172        assert_eq!(
1173            BookingMethod::from_str("Hifo").unwrap(),
1174            BookingMethod::Hifo
1175        );
1176        assert_eq!(
1177            BookingMethod::from_str("AVERAGE").unwrap(),
1178            BookingMethod::Average
1179        );
1180        assert_eq!(
1181            BookingMethod::from_str("NONE").unwrap(),
1182            BookingMethod::None
1183        );
1184        assert_eq!(
1185            BookingMethod::from_str("strict_with_size").unwrap(),
1186            BookingMethod::StrictWithSize
1187        );
1188        assert!(BookingMethod::from_str("INVALID").is_err());
1189    }
1190
1191    #[test]
1192    fn test_booking_method_display() {
1193        assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
1194        assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
1195        assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
1196        assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
1197        assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
1198        assert_eq!(format!("{}", BookingMethod::None), "NONE");
1199        assert_eq!(
1200            format!("{}", BookingMethod::StrictWithSize),
1201            "STRICT_WITH_SIZE"
1202        );
1203    }
1204
1205    #[test]
1206    fn test_booking_error_display() {
1207        let err = BookingError::AmbiguousMatch {
1208            num_matches: 3,
1209            currency: "AAPL".into(),
1210        };
1211        assert!(format!("{err}").contains("3 lots match"));
1212
1213        let err = BookingError::NoMatchingLot {
1214            currency: "AAPL".into(),
1215            cost_spec: CostSpec::empty(),
1216        };
1217        assert!(format!("{err}").contains("No matching lot"));
1218
1219        let err = BookingError::InsufficientUnits {
1220            currency: "AAPL".into(),
1221            requested: dec!(100),
1222            available: dec!(50),
1223        };
1224        assert!(format!("{err}").contains("requested 100"));
1225        assert!(format!("{err}").contains("available 50"));
1226
1227        let err = BookingError::CurrencyMismatch {
1228            expected: "USD".into(),
1229            got: "EUR".into(),
1230        };
1231        assert!(format!("{err}").contains("expected USD"));
1232        assert!(format!("{err}").contains("got EUR"));
1233    }
1234
1235    #[test]
1236    fn test_book_value_multiple_currencies() {
1237        let mut inv = Inventory::new();
1238
1239        // Cost in USD
1240        let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1241        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd));
1242
1243        // Cost in EUR
1244        let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
1245        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur));
1246
1247        let book = inv.book_value("AAPL");
1248        assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
1249        assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
1250    }
1251
1252    #[test]
1253    fn test_reduce_hifo_insufficient_units() {
1254        let mut inv = Inventory::new();
1255
1256        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1257        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1258
1259        let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
1260
1261        assert!(matches!(
1262            result,
1263            Err(BookingError::InsufficientUnits { .. })
1264        ));
1265    }
1266
1267    #[test]
1268    fn test_reduce_average_insufficient_units() {
1269        let mut inv = Inventory::new();
1270
1271        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1272        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1273
1274        let result = inv.reduce(
1275            &Amount::new(dec!(-20), "AAPL"),
1276            None,
1277            BookingMethod::Average,
1278        );
1279
1280        assert!(matches!(
1281            result,
1282            Err(BookingError::InsufficientUnits { .. })
1283        ));
1284    }
1285
1286    #[test]
1287    fn test_reduce_average_empty_inventory() {
1288        let mut inv = Inventory::new();
1289
1290        let result = inv.reduce(
1291            &Amount::new(dec!(-10), "AAPL"),
1292            None,
1293            BookingMethod::Average,
1294        );
1295
1296        assert!(matches!(
1297            result,
1298            Err(BookingError::InsufficientUnits { .. })
1299        ));
1300    }
1301
1302    #[test]
1303    fn test_inventory_display_sorted() {
1304        let mut inv = Inventory::new();
1305
1306        // Add in non-alphabetical order
1307        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1308        inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
1309        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
1310
1311        let display = format!("{inv}");
1312
1313        // Should be sorted alphabetically: AAPL, EUR, USD
1314        let aapl_pos = display.find("AAPL").unwrap();
1315        let eur_pos = display.find("EUR").unwrap();
1316        let usd_pos = display.find("USD").unwrap();
1317
1318        assert!(aapl_pos < eur_pos);
1319        assert!(eur_pos < usd_pos);
1320    }
1321
1322    #[test]
1323    fn test_inventory_with_cost_display_sorted() {
1324        let mut inv = Inventory::new();
1325
1326        // Add same currency with different costs
1327        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
1328        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1329
1330        inv.add(Position::with_cost(
1331            Amount::new(dec!(10), "AAPL"),
1332            cost_high,
1333        ));
1334        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1335
1336        let display = format!("{inv}");
1337
1338        // Both positions should be in the output
1339        assert!(display.contains("AAPL"));
1340        assert!(display.contains("100"));
1341        assert!(display.contains("200"));
1342    }
1343
1344    #[test]
1345    fn test_reduce_hifo_no_matching_lot() {
1346        let mut inv = Inventory::new();
1347
1348        // No AAPL positions
1349        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1350
1351        let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
1352
1353        assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1354    }
1355
1356    #[test]
1357    fn test_fifo_respects_dates() {
1358        // Ensure FIFO uses acquisition date, not insertion order
1359        let mut inv = Inventory::new();
1360
1361        // Add newer lot first (out of order)
1362        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1363        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1364
1365        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1366        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1367
1368        // FIFO should reduce from oldest (cost 100) first
1369        let result = inv
1370            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
1371            .unwrap();
1372
1373        // Should use cost from oldest lot (100)
1374        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1375    }
1376
1377    #[test]
1378    fn test_lifo_respects_dates() {
1379        // Ensure LIFO uses acquisition date, not insertion order
1380        let mut inv = Inventory::new();
1381
1382        // Add older lot first
1383        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1384        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1385
1386        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1387        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1388
1389        // LIFO should reduce from newest (cost 200) first
1390        let result = inv
1391            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
1392            .unwrap();
1393
1394        // Should use cost from newest lot (200)
1395        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1396    }
1397}