rustledger_core/
inventory.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::prelude::Signed;
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fmt;
12use std::str::FromStr;
13
14use crate::intern::InternedStr;
15use crate::{Amount, CostSpec, Position};
16
17/// Booking method determines how lots are matched when reducing positions.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
19#[cfg_attr(
20    feature = "rkyv",
21    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
22)]
23pub enum BookingMethod {
24    /// Lots must match exactly (unambiguous).
25    /// If multiple lots match the cost spec, an error is raised.
26    #[default]
27    Strict,
28    /// Like STRICT, but exact-size matches accept oldest lot.
29    /// If reduction amount equals total inventory, it's considered unambiguous.
30    StrictWithSize,
31    /// First In, First Out. Oldest lots are reduced first.
32    Fifo,
33    /// Last In, First Out. Newest lots are reduced first.
34    Lifo,
35    /// Highest In, First Out. Highest-cost lots are reduced first.
36    Hifo,
37    /// Average cost booking. All lots of a currency are merged.
38    Average,
39    /// No cost tracking. Units are reduced without matching lots.
40    None,
41}
42
43impl FromStr for BookingMethod {
44    type Err = String;
45
46    fn from_str(s: &str) -> Result<Self, Self::Err> {
47        match s.to_uppercase().as_str() {
48            "STRICT" => Ok(Self::Strict),
49            "STRICT_WITH_SIZE" => Ok(Self::StrictWithSize),
50            "FIFO" => Ok(Self::Fifo),
51            "LIFO" => Ok(Self::Lifo),
52            "HIFO" => Ok(Self::Hifo),
53            "AVERAGE" => Ok(Self::Average),
54            "NONE" => Ok(Self::None),
55            _ => Err(format!("unknown booking method: {s}")),
56        }
57    }
58}
59
60impl fmt::Display for BookingMethod {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        match self {
63            Self::Strict => write!(f, "STRICT"),
64            Self::StrictWithSize => write!(f, "STRICT_WITH_SIZE"),
65            Self::Fifo => write!(f, "FIFO"),
66            Self::Lifo => write!(f, "LIFO"),
67            Self::Hifo => write!(f, "HIFO"),
68            Self::Average => write!(f, "AVERAGE"),
69            Self::None => write!(f, "NONE"),
70        }
71    }
72}
73
74/// Result of a booking operation.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct BookingResult {
77    /// Positions that were matched/reduced.
78    pub matched: Vec<Position>,
79    /// The cost basis of the matched positions (for capital gains).
80    pub cost_basis: Option<Amount>,
81}
82
83/// Error that can occur during booking.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum BookingError {
86    /// Multiple lots match but booking method requires unambiguous match.
87    AmbiguousMatch {
88        /// Number of lots that matched.
89        num_matches: usize,
90        /// The currency being reduced.
91        currency: InternedStr,
92    },
93    /// No lots match the cost specification.
94    NoMatchingLot {
95        /// The currency being reduced.
96        currency: InternedStr,
97        /// The cost spec that didn't match.
98        cost_spec: CostSpec,
99    },
100    /// Not enough units in matching lots.
101    InsufficientUnits {
102        /// The currency being reduced.
103        currency: InternedStr,
104        /// Units requested.
105        requested: Decimal,
106        /// Units available.
107        available: Decimal,
108    },
109    /// Currency mismatch between reduction and inventory.
110    CurrencyMismatch {
111        /// Expected currency.
112        expected: InternedStr,
113        /// Got currency.
114        got: InternedStr,
115    },
116}
117
118impl fmt::Display for BookingError {
119    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120        match self {
121            Self::AmbiguousMatch {
122                num_matches,
123                currency,
124            } => write!(
125                f,
126                "Ambiguous match: {num_matches} lots match for {currency}"
127            ),
128            Self::NoMatchingLot {
129                currency,
130                cost_spec,
131            } => {
132                write!(f, "No matching lot for {currency} with cost {cost_spec}")
133            }
134            Self::InsufficientUnits {
135                currency,
136                requested,
137                available,
138            } => write!(
139                f,
140                "Insufficient units of {currency}: requested {requested}, available {available}"
141            ),
142            Self::CurrencyMismatch { expected, got } => {
143                write!(f, "Currency mismatch: expected {expected}, got {got}")
144            }
145        }
146    }
147}
148
149impl std::error::Error for BookingError {}
150
151/// An inventory is a collection of positions.
152///
153/// It tracks all positions for an account and supports booking operations
154/// for adding and reducing positions.
155///
156/// # Examples
157///
158/// ```
159/// use rustledger_core::{Inventory, Position, Amount, Cost, BookingMethod};
160/// use rust_decimal_macros::dec;
161///
162/// let mut inv = Inventory::new();
163///
164/// // Add a simple position
165/// inv.add(Position::simple(Amount::new(dec!(100), "USD")));
166/// assert_eq!(inv.units("USD"), dec!(100));
167///
168/// // Add a position with cost
169/// let cost = Cost::new(dec!(150.00), "USD");
170/// inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
171/// assert_eq!(inv.units("AAPL"), dec!(10));
172/// ```
173#[derive(Debug, Clone, Default, Serialize, Deserialize)]
174#[cfg_attr(
175    feature = "rkyv",
176    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
177)]
178pub struct Inventory {
179    positions: Vec<Position>,
180    /// Index for O(1) lookup of simple positions (no cost) by currency.
181    /// Maps currency to position index in the `positions` Vec.
182    /// Not serialized - rebuilt on demand.
183    #[serde(skip)]
184    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Skip))]
185    simple_index: HashMap<InternedStr, usize>,
186}
187
188impl PartialEq for Inventory {
189    fn eq(&self, other: &Self) -> bool {
190        // Only compare positions, not the index (which is derived data)
191        self.positions == other.positions
192    }
193}
194
195impl Eq for Inventory {}
196
197impl Inventory {
198    /// Create an empty inventory.
199    #[must_use]
200    pub fn new() -> Self {
201        Self::default()
202    }
203
204    /// Get all positions.
205    #[must_use]
206    pub fn positions(&self) -> &[Position] {
207        &self.positions
208    }
209
210    /// Get mutable access to all positions.
211    pub fn positions_mut(&mut self) -> &mut Vec<Position> {
212        &mut self.positions
213    }
214
215    /// Check if inventory is empty.
216    #[must_use]
217    pub fn is_empty(&self) -> bool {
218        self.positions.is_empty()
219            || self
220                .positions
221                .iter()
222                .all(super::position::Position::is_empty)
223    }
224
225    /// Get the number of positions (including empty ones).
226    #[must_use]
227    pub fn len(&self) -> usize {
228        self.positions.len()
229    }
230
231    /// Get total units of a currency (ignoring cost lots).
232    ///
233    /// This sums all positions of the given currency regardless of cost basis.
234    #[must_use]
235    pub fn units(&self, currency: &str) -> Decimal {
236        self.positions
237            .iter()
238            .filter(|p| p.units.currency == currency)
239            .map(|p| p.units.number)
240            .sum()
241    }
242
243    /// Get all currencies in this inventory.
244    #[must_use]
245    pub fn currencies(&self) -> Vec<&str> {
246        let mut currencies: Vec<&str> = self
247            .positions
248            .iter()
249            .filter(|p| !p.is_empty())
250            .map(|p| p.units.currency.as_str())
251            .collect();
252        currencies.sort_unstable();
253        currencies.dedup();
254        currencies
255    }
256
257    /// Get the total book value (cost basis) for a currency.
258    ///
259    /// Returns the sum of all cost bases for positions of the given currency.
260    #[must_use]
261    pub fn book_value(&self, units_currency: &str) -> HashMap<InternedStr, Decimal> {
262        let mut totals: HashMap<InternedStr, Decimal> = HashMap::new();
263
264        for pos in &self.positions {
265            if pos.units.currency == units_currency {
266                if let Some(book) = pos.book_value() {
267                    *totals.entry(book.currency.clone()).or_default() += book.number;
268                }
269            }
270        }
271
272        totals
273    }
274
275    /// Add a position to the inventory.
276    ///
277    /// For positions with cost, this creates a new lot.
278    /// For positions without cost, this merges with existing positions
279    /// of the same currency using O(1) `HashMap` lookup.
280    pub fn add(&mut self, position: Position) {
281        if position.is_empty() {
282            return;
283        }
284
285        // For positions without cost, use index for O(1) lookup
286        if position.cost.is_none() {
287            if let Some(&idx) = self.simple_index.get(&position.units.currency) {
288                // Merge with existing position
289                debug_assert!(self.positions[idx].cost.is_none());
290                self.positions[idx].units += &position.units;
291                return;
292            }
293            // No existing position - add new one and index it
294            let idx = self.positions.len();
295            self.simple_index
296                .insert(position.units.currency.clone(), idx);
297        }
298
299        // Add as new lot (either with cost, or first simple position for this currency)
300        self.positions.push(position);
301    }
302
303    /// Reduce positions from the inventory using the specified booking method.
304    ///
305    /// # Arguments
306    ///
307    /// * `units` - The units to reduce (negative for selling)
308    /// * `cost_spec` - Optional cost specification for matching lots
309    /// * `method` - The booking method to use
310    ///
311    /// # Returns
312    ///
313    /// Returns a `BookingResult` with the matched positions and cost basis,
314    /// or a `BookingError` if the reduction cannot be performed.
315    pub fn reduce(
316        &mut self,
317        units: &Amount,
318        cost_spec: Option<&CostSpec>,
319        method: BookingMethod,
320    ) -> Result<BookingResult, BookingError> {
321        let spec = cost_spec.cloned().unwrap_or_default();
322
323        match method {
324            BookingMethod::Strict => self.reduce_strict(units, &spec),
325            BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
326            BookingMethod::Fifo => self.reduce_fifo(units, &spec),
327            BookingMethod::Lifo => self.reduce_lifo(units, &spec),
328            BookingMethod::Hifo => self.reduce_hifo(units, &spec),
329            BookingMethod::Average => self.reduce_average(units),
330            BookingMethod::None => self.reduce_none(units),
331        }
332    }
333
334    /// STRICT booking: require exactly one matching lot.
335    /// Also allows "total match exception": if reduction equals total inventory, accept.
336    fn reduce_strict(
337        &mut self,
338        units: &Amount,
339        spec: &CostSpec,
340    ) -> Result<BookingResult, BookingError> {
341        let matching_indices: Vec<usize> = self
342            .positions
343            .iter()
344            .enumerate()
345            .filter(|(_, p)| {
346                p.units.currency == units.currency
347                    && !p.is_empty()
348                    && p.can_reduce(units)
349                    && p.matches_cost_spec(spec)
350            })
351            .map(|(i, _)| i)
352            .collect();
353
354        match matching_indices.len() {
355            0 => Err(BookingError::NoMatchingLot {
356                currency: units.currency.clone(),
357                cost_spec: spec.clone(),
358            }),
359            1 => {
360                let idx = matching_indices[0];
361                self.reduce_from_lot(idx, units)
362            }
363            n => {
364                // Total match exception: if reduction equals total inventory, it's unambiguous
365                let total_units: Decimal = matching_indices
366                    .iter()
367                    .map(|&i| self.positions[i].units.number.abs())
368                    .sum();
369                if total_units == units.number.abs() {
370                    // Reduce from all matching lots (use FIFO order)
371                    self.reduce_ordered(units, spec, false)
372                } else {
373                    Err(BookingError::AmbiguousMatch {
374                        num_matches: n,
375                        currency: units.currency.clone(),
376                    })
377                }
378            }
379        }
380    }
381
382    /// `STRICT_WITH_SIZE` booking: like STRICT, but exact-size matches accept oldest lot.
383    fn reduce_strict_with_size(
384        &mut self,
385        units: &Amount,
386        spec: &CostSpec,
387    ) -> Result<BookingResult, BookingError> {
388        let matching_indices: Vec<usize> = self
389            .positions
390            .iter()
391            .enumerate()
392            .filter(|(_, p)| {
393                p.units.currency == units.currency
394                    && !p.is_empty()
395                    && p.can_reduce(units)
396                    && p.matches_cost_spec(spec)
397            })
398            .map(|(i, _)| i)
399            .collect();
400
401        match matching_indices.len() {
402            0 => Err(BookingError::NoMatchingLot {
403                currency: units.currency.clone(),
404                cost_spec: spec.clone(),
405            }),
406            1 => {
407                let idx = matching_indices[0];
408                self.reduce_from_lot(idx, units)
409            }
410            n => {
411                // Check for exact-size match with any lot
412                let exact_matches: Vec<usize> = matching_indices
413                    .iter()
414                    .filter(|&&i| self.positions[i].units.number.abs() == units.number.abs())
415                    .copied()
416                    .collect();
417
418                if exact_matches.is_empty() {
419                    // Total match exception
420                    let total_units: Decimal = matching_indices
421                        .iter()
422                        .map(|&i| self.positions[i].units.number.abs())
423                        .sum();
424                    if total_units == units.number.abs() {
425                        self.reduce_ordered(units, spec, false)
426                    } else {
427                        Err(BookingError::AmbiguousMatch {
428                            num_matches: n,
429                            currency: units.currency.clone(),
430                        })
431                    }
432                } else {
433                    // Use oldest (first) exact-size match
434                    let idx = exact_matches[0];
435                    self.reduce_from_lot(idx, units)
436                }
437            }
438        }
439    }
440
441    /// FIFO booking: reduce from oldest lots first.
442    fn reduce_fifo(
443        &mut self,
444        units: &Amount,
445        spec: &CostSpec,
446    ) -> Result<BookingResult, BookingError> {
447        self.reduce_ordered(units, spec, false)
448    }
449
450    /// LIFO booking: reduce from newest lots first.
451    fn reduce_lifo(
452        &mut self,
453        units: &Amount,
454        spec: &CostSpec,
455    ) -> Result<BookingResult, BookingError> {
456        self.reduce_ordered(units, spec, true)
457    }
458
459    /// HIFO booking: reduce from highest-cost lots first.
460    fn reduce_hifo(
461        &mut self,
462        units: &Amount,
463        spec: &CostSpec,
464    ) -> Result<BookingResult, BookingError> {
465        let mut remaining = units.number.abs();
466        let mut matched = Vec::new();
467        let mut cost_basis = Decimal::ZERO;
468        let mut cost_currency = None;
469
470        // Get matching positions with their costs
471        let mut matching: Vec<(usize, Decimal)> = self
472            .positions
473            .iter()
474            .enumerate()
475            .filter(|(_, p)| {
476                p.units.currency == units.currency
477                    && !p.is_empty()
478                    && p.units.number.signum() != units.number.signum()
479                    && p.matches_cost_spec(spec)
480            })
481            .map(|(i, p)| {
482                let cost = p.cost.as_ref().map_or(Decimal::ZERO, |c| c.number);
483                (i, cost)
484            })
485            .collect();
486
487        if matching.is_empty() {
488            return Err(BookingError::NoMatchingLot {
489                currency: units.currency.clone(),
490                cost_spec: spec.clone(),
491            });
492        }
493
494        // Sort by cost descending (highest first)
495        matching.sort_by(|a, b| b.1.cmp(&a.1));
496
497        let indices: Vec<usize> = matching.into_iter().map(|(i, _)| i).collect();
498
499        for idx in indices {
500            if remaining.is_zero() {
501                break;
502            }
503
504            let pos = &self.positions[idx];
505            let available = pos.units.number.abs();
506            let take = remaining.min(available);
507
508            // Calculate cost basis for this portion
509            if let Some(cost) = &pos.cost {
510                cost_basis += take * cost.number;
511                cost_currency = Some(cost.currency.clone());
512            }
513
514            // Record what we matched
515            let (taken, _) = pos.split(take * pos.units.number.signum());
516            matched.push(taken);
517
518            // Reduce the lot
519            let reduction = if units.number.is_sign_negative() {
520                -take
521            } else {
522                take
523            };
524
525            let new_pos = Position {
526                units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
527                cost: pos.cost.clone(),
528            };
529            self.positions[idx] = new_pos;
530
531            remaining -= take;
532        }
533
534        if !remaining.is_zero() {
535            let available = units.number.abs() - remaining;
536            return Err(BookingError::InsufficientUnits {
537                currency: units.currency.clone(),
538                requested: units.number.abs(),
539                available,
540            });
541        }
542
543        // Clean up empty positions
544        self.positions.retain(|p| !p.is_empty());
545        self.rebuild_index();
546
547        Ok(BookingResult {
548            matched,
549            cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
550        })
551    }
552
553    /// Reduce in order (FIFO or LIFO).
554    fn reduce_ordered(
555        &mut self,
556        units: &Amount,
557        spec: &CostSpec,
558        reverse: bool,
559    ) -> Result<BookingResult, BookingError> {
560        let mut remaining = units.number.abs();
561        let mut matched = Vec::new();
562        let mut cost_basis = Decimal::ZERO;
563        let mut cost_currency = None;
564
565        // Get indices of matching positions
566        let mut indices: Vec<usize> = self
567            .positions
568            .iter()
569            .enumerate()
570            .filter(|(_, p)| {
571                p.units.currency == units.currency
572                    && !p.is_empty()
573                    && p.units.number.signum() != units.number.signum()
574                    && p.matches_cost_spec(spec)
575            })
576            .map(|(i, _)| i)
577            .collect();
578
579        // Sort by date for correct FIFO/LIFO ordering (oldest first)
580        // This ensures we select by acquisition date, not insertion order
581        indices.sort_by_key(|&i| self.positions[i].cost.as_ref().and_then(|c| c.date));
582
583        if reverse {
584            indices.reverse();
585        }
586
587        if indices.is_empty() {
588            return Err(BookingError::NoMatchingLot {
589                currency: units.currency.clone(),
590                cost_spec: spec.clone(),
591            });
592        }
593
594        for idx in indices {
595            if remaining.is_zero() {
596                break;
597            }
598
599            let pos = &self.positions[idx];
600            let available = pos.units.number.abs();
601            let take = remaining.min(available);
602
603            // Calculate cost basis for this portion
604            if let Some(cost) = &pos.cost {
605                cost_basis += take * cost.number;
606                cost_currency = Some(cost.currency.clone());
607            }
608
609            // Record what we matched
610            let (taken, _) = pos.split(take * pos.units.number.signum());
611            matched.push(taken);
612
613            // Reduce the lot
614            let reduction = if units.number.is_sign_negative() {
615                -take
616            } else {
617                take
618            };
619
620            let new_pos = Position {
621                units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
622                cost: pos.cost.clone(),
623            };
624            self.positions[idx] = new_pos;
625
626            remaining -= take;
627        }
628
629        if !remaining.is_zero() {
630            let available = units.number.abs() - remaining;
631            return Err(BookingError::InsufficientUnits {
632                currency: units.currency.clone(),
633                requested: units.number.abs(),
634                available,
635            });
636        }
637
638        // Clean up empty positions
639        self.positions.retain(|p| !p.is_empty());
640        self.rebuild_index();
641
642        Ok(BookingResult {
643            matched,
644            cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
645        })
646    }
647
648    /// AVERAGE booking: merge all lots of the currency.
649    fn reduce_average(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
650        // Calculate average cost
651        let total_units: Decimal = self
652            .positions
653            .iter()
654            .filter(|p| p.units.currency == units.currency && !p.is_empty())
655            .map(|p| p.units.number)
656            .sum();
657
658        if total_units.is_zero() {
659            return Err(BookingError::InsufficientUnits {
660                currency: units.currency.clone(),
661                requested: units.number.abs(),
662                available: Decimal::ZERO,
663            });
664        }
665
666        // Check sufficient units
667        let reduction = units.number.abs();
668        if reduction > total_units.abs() {
669            return Err(BookingError::InsufficientUnits {
670                currency: units.currency.clone(),
671                requested: reduction,
672                available: total_units.abs(),
673            });
674        }
675
676        // Calculate total cost basis
677        let book_values = self.book_value(&units.currency);
678        let cost_basis = if let Some((curr, &total)) = book_values.iter().next() {
679            let per_unit_cost = total / total_units;
680            Some(Amount::new(reduction * per_unit_cost, curr.clone()))
681        } else {
682            None
683        };
684
685        // Create merged position
686        let new_units = total_units + units.number;
687
688        // Remove all positions of this currency
689        let matched: Vec<Position> = self
690            .positions
691            .iter()
692            .filter(|p| p.units.currency == units.currency && !p.is_empty())
693            .cloned()
694            .collect();
695
696        self.positions
697            .retain(|p| p.units.currency != units.currency);
698
699        // Add back the remainder if non-zero
700        if !new_units.is_zero() {
701            self.positions.push(Position::simple(Amount::new(
702                new_units,
703                units.currency.clone(),
704            )));
705        }
706
707        // Rebuild index after modifications
708        self.rebuild_index();
709
710        Ok(BookingResult {
711            matched,
712            cost_basis,
713        })
714    }
715
716    /// NONE booking: reduce without matching lots.
717    fn reduce_none(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
718        // For NONE booking, we just reduce the total without caring about lots
719        let total_units = self.units(&units.currency);
720
721        // Check we have enough in the right direction
722        if total_units.signum() == units.number.signum() || total_units.is_zero() {
723            // This is an augmentation, not a reduction - just add it
724            self.add(Position::simple(units.clone()));
725            return Ok(BookingResult {
726                matched: vec![],
727                cost_basis: None,
728            });
729        }
730
731        let available = total_units.abs();
732        let requested = units.number.abs();
733
734        if requested > available {
735            return Err(BookingError::InsufficientUnits {
736                currency: units.currency.clone(),
737                requested,
738                available,
739            });
740        }
741
742        // Reduce positions proportionally (simplified: just reduce first matching)
743        self.reduce_ordered(units, &CostSpec::default(), false)
744    }
745
746    /// Reduce from a specific lot.
747    fn reduce_from_lot(
748        &mut self,
749        idx: usize,
750        units: &Amount,
751    ) -> Result<BookingResult, BookingError> {
752        let pos = &self.positions[idx];
753        let available = pos.units.number.abs();
754        let requested = units.number.abs();
755
756        if requested > available {
757            return Err(BookingError::InsufficientUnits {
758                currency: units.currency.clone(),
759                requested,
760                available,
761            });
762        }
763
764        // Calculate cost basis
765        let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
766
767        // Record matched
768        let (matched, _) = pos.split(requested * pos.units.number.signum());
769
770        // Update the position
771        let new_units = pos.units.number + units.number;
772        let new_pos = Position {
773            units: Amount::new(new_units, pos.units.currency.clone()),
774            cost: pos.cost.clone(),
775        };
776        self.positions[idx] = new_pos;
777
778        // Remove if empty
779        if self.positions[idx].is_empty() {
780            self.positions.remove(idx);
781        }
782
783        Ok(BookingResult {
784            matched: vec![matched],
785            cost_basis,
786        })
787    }
788
789    /// Remove all empty positions.
790    pub fn compact(&mut self) {
791        self.positions.retain(|p| !p.is_empty());
792        self.rebuild_index();
793    }
794
795    /// Rebuild the `simple_index` from positions.
796    /// Called after operations that may invalidate the index (like retain).
797    fn rebuild_index(&mut self) {
798        self.simple_index.clear();
799        for (idx, pos) in self.positions.iter().enumerate() {
800            if pos.cost.is_none() {
801                debug_assert!(
802                    !self.simple_index.contains_key(&pos.units.currency),
803                    "Invariant violated: multiple simple positions for currency {}",
804                    pos.units.currency
805                );
806                self.simple_index.insert(pos.units.currency.clone(), idx);
807            }
808        }
809    }
810
811    /// Merge this inventory with another.
812    pub fn merge(&mut self, other: &Self) {
813        for pos in &other.positions {
814            self.add(pos.clone());
815        }
816    }
817
818    /// Convert inventory to cost basis.
819    ///
820    /// Returns a new inventory where all positions are converted to their
821    /// cost basis. Positions without cost are returned as-is.
822    #[must_use]
823    pub fn at_cost(&self) -> Self {
824        let mut result = Self::new();
825
826        for pos in &self.positions {
827            if pos.is_empty() {
828                continue;
829            }
830
831            if let Some(cost) = &pos.cost {
832                // Convert to cost basis
833                let total = pos.units.number * cost.number;
834                result.add(Position::simple(Amount::new(total, &cost.currency)));
835            } else {
836                // No cost, keep as-is
837                result.add(pos.clone());
838            }
839        }
840
841        result
842    }
843
844    /// Convert inventory to units only.
845    ///
846    /// Returns a new inventory where all positions have their cost removed,
847    /// effectively aggregating by currency only.
848    #[must_use]
849    pub fn at_units(&self) -> Self {
850        let mut result = Self::new();
851
852        for pos in &self.positions {
853            if pos.is_empty() {
854                continue;
855            }
856
857            // Strip cost, keep only units
858            result.add(Position::simple(pos.units.clone()));
859        }
860
861        result
862    }
863}
864
865impl fmt::Display for Inventory {
866    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
867        if self.is_empty() {
868            return write!(f, "(empty)");
869        }
870
871        let non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
872        for (i, pos) in non_empty.iter().enumerate() {
873            if i > 0 {
874                write!(f, ", ")?;
875            }
876            write!(f, "{pos}")?;
877        }
878        Ok(())
879    }
880}
881
882impl FromIterator<Position> for Inventory {
883    fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
884        let mut inv = Self::new();
885        for pos in iter {
886            inv.add(pos);
887        }
888        inv
889    }
890}
891
892#[cfg(test)]
893mod tests {
894    use super::*;
895    use crate::Cost;
896    use chrono::NaiveDate;
897    use rust_decimal_macros::dec;
898
899    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
900        NaiveDate::from_ymd_opt(year, month, day).unwrap()
901    }
902
903    #[test]
904    fn test_empty_inventory() {
905        let inv = Inventory::new();
906        assert!(inv.is_empty());
907        assert_eq!(inv.len(), 0);
908    }
909
910    #[test]
911    fn test_add_simple() {
912        let mut inv = Inventory::new();
913        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
914
915        assert!(!inv.is_empty());
916        assert_eq!(inv.units("USD"), dec!(100));
917    }
918
919    #[test]
920    fn test_add_merge_simple() {
921        let mut inv = Inventory::new();
922        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
923        inv.add(Position::simple(Amount::new(dec!(50), "USD")));
924
925        // Should merge into one position
926        assert_eq!(inv.len(), 1);
927        assert_eq!(inv.units("USD"), dec!(150));
928    }
929
930    #[test]
931    fn test_add_with_cost_no_merge() {
932        let mut inv = Inventory::new();
933
934        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
935        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
936
937        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
938        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
939
940        // Should NOT merge - different costs
941        assert_eq!(inv.len(), 2);
942        assert_eq!(inv.units("AAPL"), dec!(15));
943    }
944
945    #[test]
946    fn test_currencies() {
947        let mut inv = Inventory::new();
948        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
949        inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
950        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
951
952        let currencies = inv.currencies();
953        assert_eq!(currencies.len(), 3);
954        assert!(currencies.contains(&"USD"));
955        assert!(currencies.contains(&"EUR"));
956        assert!(currencies.contains(&"AAPL"));
957    }
958
959    #[test]
960    fn test_reduce_strict_unique() {
961        let mut inv = Inventory::new();
962        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
963        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
964
965        let result = inv
966            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
967            .unwrap();
968
969        assert_eq!(inv.units("AAPL"), dec!(5));
970        assert!(result.cost_basis.is_some());
971        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); // 5 * 150
972    }
973
974    #[test]
975    fn test_reduce_strict_ambiguous() {
976        let mut inv = Inventory::new();
977
978        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
979        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
980
981        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
982        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
983
984        // Reducing without cost spec should fail (ambiguous)
985        let result = inv.reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict);
986
987        assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
988    }
989
990    #[test]
991    fn test_reduce_strict_with_spec() {
992        let mut inv = Inventory::new();
993
994        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
995        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
996
997        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
998        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
999
1000        // Reducing with cost spec should work
1001        let spec = CostSpec::empty().with_date(date(2024, 1, 1));
1002        let result = inv
1003            .reduce(
1004                &Amount::new(dec!(-3), "AAPL"),
1005                Some(&spec),
1006                BookingMethod::Strict,
1007            )
1008            .unwrap();
1009
1010        assert_eq!(inv.units("AAPL"), dec!(12)); // 7 + 5
1011        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); // 3 * 150
1012    }
1013
1014    #[test]
1015    fn test_reduce_fifo() {
1016        let mut inv = Inventory::new();
1017
1018        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1019        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1020        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1021
1022        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1023        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1024        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1025
1026        // FIFO should reduce from oldest (cost 100) first
1027        let result = inv
1028            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
1029            .unwrap();
1030
1031        assert_eq!(inv.units("AAPL"), dec!(15));
1032        // Cost basis: 10 * 100 + 5 * 150 = 1000 + 750 = 1750
1033        assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
1034    }
1035
1036    #[test]
1037    fn test_reduce_lifo() {
1038        let mut inv = Inventory::new();
1039
1040        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1041        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1042        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1043
1044        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1045        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1046        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1047
1048        // LIFO should reduce from newest (cost 200) first
1049        let result = inv
1050            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
1051            .unwrap();
1052
1053        assert_eq!(inv.units("AAPL"), dec!(15));
1054        // Cost basis: 10 * 200 + 5 * 150 = 2000 + 750 = 2750
1055        assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
1056    }
1057
1058    #[test]
1059    fn test_reduce_insufficient() {
1060        let mut inv = Inventory::new();
1061        let cost = Cost::new(dec!(150.00), "USD");
1062        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1063
1064        let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
1065
1066        assert!(matches!(
1067            result,
1068            Err(BookingError::InsufficientUnits { .. })
1069        ));
1070    }
1071
1072    #[test]
1073    fn test_book_value() {
1074        let mut inv = Inventory::new();
1075
1076        let cost1 = Cost::new(dec!(100.00), "USD");
1077        let cost2 = Cost::new(dec!(150.00), "USD");
1078
1079        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1080        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1081
1082        let book = inv.book_value("AAPL");
1083        assert_eq!(book.get("USD"), Some(&dec!(1750.00))); // 10*100 + 5*150
1084    }
1085
1086    #[test]
1087    fn test_display() {
1088        let mut inv = Inventory::new();
1089        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1090
1091        let s = format!("{inv}");
1092        assert!(s.contains("100 USD"));
1093    }
1094
1095    #[test]
1096    fn test_display_empty() {
1097        let inv = Inventory::new();
1098        assert_eq!(format!("{inv}"), "(empty)");
1099    }
1100
1101    #[test]
1102    fn test_from_iterator() {
1103        let positions = vec![
1104            Position::simple(Amount::new(dec!(100), "USD")),
1105            Position::simple(Amount::new(dec!(50), "USD")),
1106        ];
1107
1108        let inv: Inventory = positions.into_iter().collect();
1109        assert_eq!(inv.units("USD"), dec!(150));
1110    }
1111}