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, PartialEq, Eq, 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}
181
182impl Inventory {
183    /// Create an empty inventory.
184    #[must_use]
185    pub fn new() -> Self {
186        Self::default()
187    }
188
189    /// Get all positions.
190    #[must_use]
191    pub fn positions(&self) -> &[Position] {
192        &self.positions
193    }
194
195    /// Get mutable access to all positions.
196    pub fn positions_mut(&mut self) -> &mut Vec<Position> {
197        &mut self.positions
198    }
199
200    /// Check if inventory is empty.
201    #[must_use]
202    pub fn is_empty(&self) -> bool {
203        self.positions.is_empty()
204            || self
205                .positions
206                .iter()
207                .all(super::position::Position::is_empty)
208    }
209
210    /// Get the number of positions (including empty ones).
211    #[must_use]
212    pub fn len(&self) -> usize {
213        self.positions.len()
214    }
215
216    /// Get total units of a currency (ignoring cost lots).
217    ///
218    /// This sums all positions of the given currency regardless of cost basis.
219    #[must_use]
220    pub fn units(&self, currency: &str) -> Decimal {
221        self.positions
222            .iter()
223            .filter(|p| p.units.currency == currency)
224            .map(|p| p.units.number)
225            .sum()
226    }
227
228    /// Get all currencies in this inventory.
229    #[must_use]
230    pub fn currencies(&self) -> Vec<&str> {
231        let mut currencies: Vec<&str> = self
232            .positions
233            .iter()
234            .filter(|p| !p.is_empty())
235            .map(|p| p.units.currency.as_str())
236            .collect();
237        currencies.sort_unstable();
238        currencies.dedup();
239        currencies
240    }
241
242    /// Get the total book value (cost basis) for a currency.
243    ///
244    /// Returns the sum of all cost bases for positions of the given currency.
245    #[must_use]
246    pub fn book_value(&self, units_currency: &str) -> HashMap<InternedStr, Decimal> {
247        let mut totals: HashMap<InternedStr, Decimal> = HashMap::new();
248
249        for pos in &self.positions {
250            if pos.units.currency == units_currency {
251                if let Some(book) = pos.book_value() {
252                    *totals.entry(book.currency.clone()).or_default() += book.number;
253                }
254            }
255        }
256
257        totals
258    }
259
260    /// Add a position to the inventory.
261    ///
262    /// For positions with cost, this creates a new lot.
263    /// For positions without cost, this merges with existing positions
264    /// of the same currency.
265    pub fn add(&mut self, position: Position) {
266        if position.is_empty() {
267            return;
268        }
269
270        // For positions without cost, try to merge
271        if position.cost.is_none() {
272            for existing in &mut self.positions {
273                if existing.cost.is_none() && existing.units.currency == position.units.currency {
274                    existing.units += &position.units;
275                    return;
276                }
277            }
278        }
279
280        // Otherwise, add as new lot
281        self.positions.push(position);
282    }
283
284    /// Reduce positions from the inventory using the specified booking method.
285    ///
286    /// # Arguments
287    ///
288    /// * `units` - The units to reduce (negative for selling)
289    /// * `cost_spec` - Optional cost specification for matching lots
290    /// * `method` - The booking method to use
291    ///
292    /// # Returns
293    ///
294    /// Returns a `BookingResult` with the matched positions and cost basis,
295    /// or a `BookingError` if the reduction cannot be performed.
296    pub fn reduce(
297        &mut self,
298        units: &Amount,
299        cost_spec: Option<&CostSpec>,
300        method: BookingMethod,
301    ) -> Result<BookingResult, BookingError> {
302        let spec = cost_spec.cloned().unwrap_or_default();
303
304        match method {
305            BookingMethod::Strict => self.reduce_strict(units, &spec),
306            BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
307            BookingMethod::Fifo => self.reduce_fifo(units, &spec),
308            BookingMethod::Lifo => self.reduce_lifo(units, &spec),
309            BookingMethod::Hifo => self.reduce_hifo(units, &spec),
310            BookingMethod::Average => self.reduce_average(units),
311            BookingMethod::None => self.reduce_none(units),
312        }
313    }
314
315    /// STRICT booking: require exactly one matching lot.
316    /// Also allows "total match exception": if reduction equals total inventory, accept.
317    fn reduce_strict(
318        &mut self,
319        units: &Amount,
320        spec: &CostSpec,
321    ) -> Result<BookingResult, BookingError> {
322        let matching_indices: Vec<usize> = self
323            .positions
324            .iter()
325            .enumerate()
326            .filter(|(_, p)| {
327                p.units.currency == units.currency
328                    && !p.is_empty()
329                    && p.can_reduce(units)
330                    && p.matches_cost_spec(spec)
331            })
332            .map(|(i, _)| i)
333            .collect();
334
335        match matching_indices.len() {
336            0 => Err(BookingError::NoMatchingLot {
337                currency: units.currency.clone(),
338                cost_spec: spec.clone(),
339            }),
340            1 => {
341                let idx = matching_indices[0];
342                self.reduce_from_lot(idx, units)
343            }
344            n => {
345                // Total match exception: if reduction equals total inventory, it's unambiguous
346                let total_units: Decimal = matching_indices
347                    .iter()
348                    .map(|&i| self.positions[i].units.number.abs())
349                    .sum();
350                if total_units == units.number.abs() {
351                    // Reduce from all matching lots (use FIFO order)
352                    self.reduce_ordered(units, spec, false)
353                } else {
354                    Err(BookingError::AmbiguousMatch {
355                        num_matches: n,
356                        currency: units.currency.clone(),
357                    })
358                }
359            }
360        }
361    }
362
363    /// `STRICT_WITH_SIZE` booking: like STRICT, but exact-size matches accept oldest lot.
364    fn reduce_strict_with_size(
365        &mut self,
366        units: &Amount,
367        spec: &CostSpec,
368    ) -> Result<BookingResult, BookingError> {
369        let matching_indices: Vec<usize> = self
370            .positions
371            .iter()
372            .enumerate()
373            .filter(|(_, p)| {
374                p.units.currency == units.currency
375                    && !p.is_empty()
376                    && p.can_reduce(units)
377                    && p.matches_cost_spec(spec)
378            })
379            .map(|(i, _)| i)
380            .collect();
381
382        match matching_indices.len() {
383            0 => Err(BookingError::NoMatchingLot {
384                currency: units.currency.clone(),
385                cost_spec: spec.clone(),
386            }),
387            1 => {
388                let idx = matching_indices[0];
389                self.reduce_from_lot(idx, units)
390            }
391            n => {
392                // Check for exact-size match with any lot
393                let exact_matches: Vec<usize> = matching_indices
394                    .iter()
395                    .filter(|&&i| self.positions[i].units.number.abs() == units.number.abs())
396                    .copied()
397                    .collect();
398
399                if exact_matches.is_empty() {
400                    // Total match exception
401                    let total_units: Decimal = matching_indices
402                        .iter()
403                        .map(|&i| self.positions[i].units.number.abs())
404                        .sum();
405                    if total_units == units.number.abs() {
406                        self.reduce_ordered(units, spec, false)
407                    } else {
408                        Err(BookingError::AmbiguousMatch {
409                            num_matches: n,
410                            currency: units.currency.clone(),
411                        })
412                    }
413                } else {
414                    // Use oldest (first) exact-size match
415                    let idx = exact_matches[0];
416                    self.reduce_from_lot(idx, units)
417                }
418            }
419        }
420    }
421
422    /// FIFO booking: reduce from oldest lots first.
423    fn reduce_fifo(
424        &mut self,
425        units: &Amount,
426        spec: &CostSpec,
427    ) -> Result<BookingResult, BookingError> {
428        self.reduce_ordered(units, spec, false)
429    }
430
431    /// LIFO booking: reduce from newest lots first.
432    fn reduce_lifo(
433        &mut self,
434        units: &Amount,
435        spec: &CostSpec,
436    ) -> Result<BookingResult, BookingError> {
437        self.reduce_ordered(units, spec, true)
438    }
439
440    /// HIFO booking: reduce from highest-cost lots first.
441    fn reduce_hifo(
442        &mut self,
443        units: &Amount,
444        spec: &CostSpec,
445    ) -> Result<BookingResult, BookingError> {
446        let mut remaining = units.number.abs();
447        let mut matched = Vec::new();
448        let mut cost_basis = Decimal::ZERO;
449        let mut cost_currency = None;
450
451        // Get matching positions with their costs
452        let mut matching: Vec<(usize, Decimal)> = self
453            .positions
454            .iter()
455            .enumerate()
456            .filter(|(_, p)| {
457                p.units.currency == units.currency
458                    && !p.is_empty()
459                    && p.units.number.signum() != units.number.signum()
460                    && p.matches_cost_spec(spec)
461            })
462            .map(|(i, p)| {
463                let cost = p.cost.as_ref().map_or(Decimal::ZERO, |c| c.number);
464                (i, cost)
465            })
466            .collect();
467
468        if matching.is_empty() {
469            return Err(BookingError::NoMatchingLot {
470                currency: units.currency.clone(),
471                cost_spec: spec.clone(),
472            });
473        }
474
475        // Sort by cost descending (highest first)
476        matching.sort_by(|a, b| b.1.cmp(&a.1));
477
478        let indices: Vec<usize> = matching.into_iter().map(|(i, _)| i).collect();
479
480        for idx in indices {
481            if remaining.is_zero() {
482                break;
483            }
484
485            let pos = &self.positions[idx];
486            let available = pos.units.number.abs();
487            let take = remaining.min(available);
488
489            // Calculate cost basis for this portion
490            if let Some(cost) = &pos.cost {
491                cost_basis += take * cost.number;
492                cost_currency = Some(cost.currency.clone());
493            }
494
495            // Record what we matched
496            let (taken, _) = pos.split(take * pos.units.number.signum());
497            matched.push(taken);
498
499            // Reduce the lot
500            let reduction = if units.number.is_sign_negative() {
501                -take
502            } else {
503                take
504            };
505
506            let new_pos = Position {
507                units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
508                cost: pos.cost.clone(),
509            };
510            self.positions[idx] = new_pos;
511
512            remaining -= take;
513        }
514
515        if !remaining.is_zero() {
516            let available = units.number.abs() - remaining;
517            return Err(BookingError::InsufficientUnits {
518                currency: units.currency.clone(),
519                requested: units.number.abs(),
520                available,
521            });
522        }
523
524        // Clean up empty positions
525        self.positions.retain(|p| !p.is_empty());
526
527        Ok(BookingResult {
528            matched,
529            cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
530        })
531    }
532
533    /// Reduce in order (FIFO or LIFO).
534    fn reduce_ordered(
535        &mut self,
536        units: &Amount,
537        spec: &CostSpec,
538        reverse: bool,
539    ) -> Result<BookingResult, BookingError> {
540        let mut remaining = units.number.abs();
541        let mut matched = Vec::new();
542        let mut cost_basis = Decimal::ZERO;
543        let mut cost_currency = None;
544
545        // Get indices of matching positions
546        let mut indices: Vec<usize> = self
547            .positions
548            .iter()
549            .enumerate()
550            .filter(|(_, p)| {
551                p.units.currency == units.currency
552                    && !p.is_empty()
553                    && p.units.number.signum() != units.number.signum()
554                    && p.matches_cost_spec(spec)
555            })
556            .map(|(i, _)| i)
557            .collect();
558
559        // Sort by date for correct FIFO/LIFO ordering (oldest first)
560        // This ensures we select by acquisition date, not insertion order
561        indices.sort_by_key(|&i| self.positions[i].cost.as_ref().and_then(|c| c.date));
562
563        if reverse {
564            indices.reverse();
565        }
566
567        if indices.is_empty() {
568            return Err(BookingError::NoMatchingLot {
569                currency: units.currency.clone(),
570                cost_spec: spec.clone(),
571            });
572        }
573
574        for idx in indices {
575            if remaining.is_zero() {
576                break;
577            }
578
579            let pos = &self.positions[idx];
580            let available = pos.units.number.abs();
581            let take = remaining.min(available);
582
583            // Calculate cost basis for this portion
584            if let Some(cost) = &pos.cost {
585                cost_basis += take * cost.number;
586                cost_currency = Some(cost.currency.clone());
587            }
588
589            // Record what we matched
590            let (taken, _) = pos.split(take * pos.units.number.signum());
591            matched.push(taken);
592
593            // Reduce the lot
594            let reduction = if units.number.is_sign_negative() {
595                -take
596            } else {
597                take
598            };
599
600            let new_pos = Position {
601                units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
602                cost: pos.cost.clone(),
603            };
604            self.positions[idx] = new_pos;
605
606            remaining -= take;
607        }
608
609        if !remaining.is_zero() {
610            let available = units.number.abs() - remaining;
611            return Err(BookingError::InsufficientUnits {
612                currency: units.currency.clone(),
613                requested: units.number.abs(),
614                available,
615            });
616        }
617
618        // Clean up empty positions
619        self.positions.retain(|p| !p.is_empty());
620
621        Ok(BookingResult {
622            matched,
623            cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
624        })
625    }
626
627    /// AVERAGE booking: merge all lots of the currency.
628    fn reduce_average(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
629        // Calculate average cost
630        let total_units: Decimal = self
631            .positions
632            .iter()
633            .filter(|p| p.units.currency == units.currency && !p.is_empty())
634            .map(|p| p.units.number)
635            .sum();
636
637        if total_units.is_zero() {
638            return Err(BookingError::InsufficientUnits {
639                currency: units.currency.clone(),
640                requested: units.number.abs(),
641                available: Decimal::ZERO,
642            });
643        }
644
645        // Check sufficient units
646        let reduction = units.number.abs();
647        if reduction > total_units.abs() {
648            return Err(BookingError::InsufficientUnits {
649                currency: units.currency.clone(),
650                requested: reduction,
651                available: total_units.abs(),
652            });
653        }
654
655        // Calculate total cost basis
656        let book_values = self.book_value(&units.currency);
657        let cost_basis = if let Some((curr, &total)) = book_values.iter().next() {
658            let per_unit_cost = total / total_units;
659            Some(Amount::new(reduction * per_unit_cost, curr.clone()))
660        } else {
661            None
662        };
663
664        // Create merged position
665        let new_units = total_units + units.number;
666
667        // Remove all positions of this currency
668        let matched: Vec<Position> = self
669            .positions
670            .iter()
671            .filter(|p| p.units.currency == units.currency && !p.is_empty())
672            .cloned()
673            .collect();
674
675        self.positions
676            .retain(|p| p.units.currency != units.currency);
677
678        // Add back the remainder if non-zero
679        if !new_units.is_zero() {
680            self.positions.push(Position::simple(Amount::new(
681                new_units,
682                units.currency.clone(),
683            )));
684        }
685
686        Ok(BookingResult {
687            matched,
688            cost_basis,
689        })
690    }
691
692    /// NONE booking: reduce without matching lots.
693    fn reduce_none(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
694        // For NONE booking, we just reduce the total without caring about lots
695        let total_units = self.units(&units.currency);
696
697        // Check we have enough in the right direction
698        if total_units.signum() == units.number.signum() || total_units.is_zero() {
699            // This is an augmentation, not a reduction - just add it
700            self.add(Position::simple(units.clone()));
701            return Ok(BookingResult {
702                matched: vec![],
703                cost_basis: None,
704            });
705        }
706
707        let available = total_units.abs();
708        let requested = units.number.abs();
709
710        if requested > available {
711            return Err(BookingError::InsufficientUnits {
712                currency: units.currency.clone(),
713                requested,
714                available,
715            });
716        }
717
718        // Reduce positions proportionally (simplified: just reduce first matching)
719        self.reduce_ordered(units, &CostSpec::default(), false)
720    }
721
722    /// Reduce from a specific lot.
723    fn reduce_from_lot(
724        &mut self,
725        idx: usize,
726        units: &Amount,
727    ) -> Result<BookingResult, BookingError> {
728        let pos = &self.positions[idx];
729        let available = pos.units.number.abs();
730        let requested = units.number.abs();
731
732        if requested > available {
733            return Err(BookingError::InsufficientUnits {
734                currency: units.currency.clone(),
735                requested,
736                available,
737            });
738        }
739
740        // Calculate cost basis
741        let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
742
743        // Record matched
744        let (matched, _) = pos.split(requested * pos.units.number.signum());
745
746        // Update the position
747        let new_units = pos.units.number + units.number;
748        let new_pos = Position {
749            units: Amount::new(new_units, pos.units.currency.clone()),
750            cost: pos.cost.clone(),
751        };
752        self.positions[idx] = new_pos;
753
754        // Remove if empty
755        if self.positions[idx].is_empty() {
756            self.positions.remove(idx);
757        }
758
759        Ok(BookingResult {
760            matched: vec![matched],
761            cost_basis,
762        })
763    }
764
765    /// Remove all empty positions.
766    pub fn compact(&mut self) {
767        self.positions.retain(|p| !p.is_empty());
768    }
769
770    /// Merge this inventory with another.
771    pub fn merge(&mut self, other: &Self) {
772        for pos in &other.positions {
773            self.add(pos.clone());
774        }
775    }
776
777    /// Convert inventory to cost basis.
778    ///
779    /// Returns a new inventory where all positions are converted to their
780    /// cost basis. Positions without cost are returned as-is.
781    #[must_use]
782    pub fn at_cost(&self) -> Self {
783        let mut result = Self::new();
784
785        for pos in &self.positions {
786            if pos.is_empty() {
787                continue;
788            }
789
790            if let Some(cost) = &pos.cost {
791                // Convert to cost basis
792                let total = pos.units.number * cost.number;
793                result.add(Position::simple(Amount::new(total, &cost.currency)));
794            } else {
795                // No cost, keep as-is
796                result.add(pos.clone());
797            }
798        }
799
800        result
801    }
802
803    /// Convert inventory to units only.
804    ///
805    /// Returns a new inventory where all positions have their cost removed,
806    /// effectively aggregating by currency only.
807    #[must_use]
808    pub fn at_units(&self) -> Self {
809        let mut result = Self::new();
810
811        for pos in &self.positions {
812            if pos.is_empty() {
813                continue;
814            }
815
816            // Strip cost, keep only units
817            result.add(Position::simple(pos.units.clone()));
818        }
819
820        result
821    }
822}
823
824impl fmt::Display for Inventory {
825    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
826        if self.is_empty() {
827            return write!(f, "(empty)");
828        }
829
830        let non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
831        for (i, pos) in non_empty.iter().enumerate() {
832            if i > 0 {
833                write!(f, ", ")?;
834            }
835            write!(f, "{pos}")?;
836        }
837        Ok(())
838    }
839}
840
841impl FromIterator<Position> for Inventory {
842    fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
843        let mut inv = Self::new();
844        for pos in iter {
845            inv.add(pos);
846        }
847        inv
848    }
849}
850
851#[cfg(test)]
852mod tests {
853    use super::*;
854    use crate::Cost;
855    use chrono::NaiveDate;
856    use rust_decimal_macros::dec;
857
858    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
859        NaiveDate::from_ymd_opt(year, month, day).unwrap()
860    }
861
862    #[test]
863    fn test_empty_inventory() {
864        let inv = Inventory::new();
865        assert!(inv.is_empty());
866        assert_eq!(inv.len(), 0);
867    }
868
869    #[test]
870    fn test_add_simple() {
871        let mut inv = Inventory::new();
872        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
873
874        assert!(!inv.is_empty());
875        assert_eq!(inv.units("USD"), dec!(100));
876    }
877
878    #[test]
879    fn test_add_merge_simple() {
880        let mut inv = Inventory::new();
881        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
882        inv.add(Position::simple(Amount::new(dec!(50), "USD")));
883
884        // Should merge into one position
885        assert_eq!(inv.len(), 1);
886        assert_eq!(inv.units("USD"), dec!(150));
887    }
888
889    #[test]
890    fn test_add_with_cost_no_merge() {
891        let mut inv = Inventory::new();
892
893        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
894        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
895
896        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
897        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
898
899        // Should NOT merge - different costs
900        assert_eq!(inv.len(), 2);
901        assert_eq!(inv.units("AAPL"), dec!(15));
902    }
903
904    #[test]
905    fn test_currencies() {
906        let mut inv = Inventory::new();
907        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
908        inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
909        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
910
911        let currencies = inv.currencies();
912        assert_eq!(currencies.len(), 3);
913        assert!(currencies.contains(&"USD"));
914        assert!(currencies.contains(&"EUR"));
915        assert!(currencies.contains(&"AAPL"));
916    }
917
918    #[test]
919    fn test_reduce_strict_unique() {
920        let mut inv = Inventory::new();
921        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
922        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
923
924        let result = inv
925            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
926            .unwrap();
927
928        assert_eq!(inv.units("AAPL"), dec!(5));
929        assert!(result.cost_basis.is_some());
930        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); // 5 * 150
931    }
932
933    #[test]
934    fn test_reduce_strict_ambiguous() {
935        let mut inv = Inventory::new();
936
937        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
938        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
939
940        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
941        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
942
943        // Reducing without cost spec should fail (ambiguous)
944        let result = inv.reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict);
945
946        assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
947    }
948
949    #[test]
950    fn test_reduce_strict_with_spec() {
951        let mut inv = Inventory::new();
952
953        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
954        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
955
956        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
957        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
958
959        // Reducing with cost spec should work
960        let spec = CostSpec::empty().with_date(date(2024, 1, 1));
961        let result = inv
962            .reduce(
963                &Amount::new(dec!(-3), "AAPL"),
964                Some(&spec),
965                BookingMethod::Strict,
966            )
967            .unwrap();
968
969        assert_eq!(inv.units("AAPL"), dec!(12)); // 7 + 5
970        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); // 3 * 150
971    }
972
973    #[test]
974    fn test_reduce_fifo() {
975        let mut inv = Inventory::new();
976
977        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
978        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
979        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
980
981        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
982        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
983        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
984
985        // FIFO should reduce from oldest (cost 100) first
986        let result = inv
987            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
988            .unwrap();
989
990        assert_eq!(inv.units("AAPL"), dec!(15));
991        // Cost basis: 10 * 100 + 5 * 150 = 1000 + 750 = 1750
992        assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
993    }
994
995    #[test]
996    fn test_reduce_lifo() {
997        let mut inv = Inventory::new();
998
999        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1000        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1001        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1002
1003        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1004        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1005        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1006
1007        // LIFO should reduce from newest (cost 200) first
1008        let result = inv
1009            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
1010            .unwrap();
1011
1012        assert_eq!(inv.units("AAPL"), dec!(15));
1013        // Cost basis: 10 * 200 + 5 * 150 = 2000 + 750 = 2750
1014        assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
1015    }
1016
1017    #[test]
1018    fn test_reduce_insufficient() {
1019        let mut inv = Inventory::new();
1020        let cost = Cost::new(dec!(150.00), "USD");
1021        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1022
1023        let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
1024
1025        assert!(matches!(
1026            result,
1027            Err(BookingError::InsufficientUnits { .. })
1028        ));
1029    }
1030
1031    #[test]
1032    fn test_book_value() {
1033        let mut inv = Inventory::new();
1034
1035        let cost1 = Cost::new(dec!(100.00), "USD");
1036        let cost2 = Cost::new(dec!(150.00), "USD");
1037
1038        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1039        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1040
1041        let book = inv.book_value("AAPL");
1042        assert_eq!(book.get("USD"), Some(&dec!(1750.00))); // 10*100 + 5*150
1043    }
1044
1045    #[test]
1046    fn test_display() {
1047        let mut inv = Inventory::new();
1048        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1049
1050        let s = format!("{inv}");
1051        assert!(s.contains("100 USD"));
1052    }
1053
1054    #[test]
1055    fn test_display_empty() {
1056        let inv = Inventory::new();
1057        assert_eq!(format!("{inv}"), "(empty)");
1058    }
1059
1060    #[test]
1061    fn test_from_iterator() {
1062        let positions = vec![
1063            Position::simple(Amount::new(dec!(100), "USD")),
1064            Position::simple(Amount::new(dec!(50), "USD")),
1065        ];
1066
1067        let inv: Inventory = positions.into_iter().collect();
1068        assert_eq!(inv.units("USD"), dec!(150));
1069    }
1070}