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::Decimal;
8use rust_decimal::prelude::Signed;
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 const 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 without cost, this merges with existing positions
278    /// of the same currency using O(1) `HashMap` lookup.
279    ///
280    /// For positions with cost, this adds as a new lot (O(1)).
281    /// Lot aggregation for display purposes is handled separately at output time
282    /// (e.g., in the query result formatter).
283    pub fn add(&mut self, position: Position) {
284        if position.is_empty() {
285            return;
286        }
287
288        // For positions without cost, use index for O(1) lookup
289        if position.cost.is_none() {
290            if let Some(&idx) = self.simple_index.get(&position.units.currency) {
291                // Merge with existing position
292                debug_assert!(self.positions[idx].cost.is_none());
293                self.positions[idx].units += &position.units;
294                return;
295            }
296            // No existing position - add new one and index it
297            let idx = self.positions.len();
298            self.simple_index
299                .insert(position.units.currency.clone(), idx);
300            self.positions.push(position);
301            return;
302        }
303
304        // For positions with cost, just add as a new lot.
305        // This is O(1) and keeps all lots separate, matching Python beancount behavior.
306        // Lot aggregation for display purposes is handled separately in query output.
307        self.positions.push(position);
308    }
309
310    /// Reduce positions from the inventory using the specified booking method.
311    ///
312    /// # Arguments
313    ///
314    /// * `units` - The units to reduce (negative for selling)
315    /// * `cost_spec` - Optional cost specification for matching lots
316    /// * `method` - The booking method to use
317    ///
318    /// # Returns
319    ///
320    /// Returns a `BookingResult` with the matched positions and cost basis,
321    /// or a `BookingError` if the reduction cannot be performed.
322    pub fn reduce(
323        &mut self,
324        units: &Amount,
325        cost_spec: Option<&CostSpec>,
326        method: BookingMethod,
327    ) -> Result<BookingResult, BookingError> {
328        let spec = cost_spec.cloned().unwrap_or_default();
329
330        match method {
331            BookingMethod::Strict => self.reduce_strict(units, &spec),
332            BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
333            BookingMethod::Fifo => self.reduce_fifo(units, &spec),
334            BookingMethod::Lifo => self.reduce_lifo(units, &spec),
335            BookingMethod::Hifo => self.reduce_hifo(units, &spec),
336            BookingMethod::Average => self.reduce_average(units),
337            BookingMethod::None => self.reduce_none(units),
338        }
339    }
340
341    /// STRICT booking: require exactly one matching lot.
342    /// Also allows "total match exception": if reduction equals total inventory, accept.
343    fn reduce_strict(
344        &mut self,
345        units: &Amount,
346        spec: &CostSpec,
347    ) -> Result<BookingResult, BookingError> {
348        let matching_indices: Vec<usize> = self
349            .positions
350            .iter()
351            .enumerate()
352            .filter(|(_, p)| {
353                p.units.currency == units.currency
354                    && !p.is_empty()
355                    && p.can_reduce(units)
356                    && p.matches_cost_spec(spec)
357            })
358            .map(|(i, _)| i)
359            .collect();
360
361        match matching_indices.len() {
362            0 => Err(BookingError::NoMatchingLot {
363                currency: units.currency.clone(),
364                cost_spec: spec.clone(),
365            }),
366            1 => {
367                let idx = matching_indices[0];
368                self.reduce_from_lot(idx, units)
369            }
370            _n => {
371                // When multiple lots match the same cost spec, Python beancount falls back to FIFO
372                // order rather than erroring. This is consistent with how beancount handles
373                // identical lots - if the cost spec is specified, it's considered "matched"
374                // and we just pick by insertion order.
375                self.reduce_ordered(units, spec, false)
376            }
377        }
378    }
379
380    /// `STRICT_WITH_SIZE` booking: like STRICT, but exact-size matches accept oldest lot.
381    fn reduce_strict_with_size(
382        &mut self,
383        units: &Amount,
384        spec: &CostSpec,
385    ) -> Result<BookingResult, BookingError> {
386        let matching_indices: Vec<usize> = self
387            .positions
388            .iter()
389            .enumerate()
390            .filter(|(_, p)| {
391                p.units.currency == units.currency
392                    && !p.is_empty()
393                    && p.can_reduce(units)
394                    && p.matches_cost_spec(spec)
395            })
396            .map(|(i, _)| i)
397            .collect();
398
399        match matching_indices.len() {
400            0 => Err(BookingError::NoMatchingLot {
401                currency: units.currency.clone(),
402                cost_spec: spec.clone(),
403            }),
404            1 => {
405                let idx = matching_indices[0];
406                self.reduce_from_lot(idx, units)
407            }
408            n => {
409                // Check for exact-size match with any lot
410                let exact_matches: Vec<usize> = matching_indices
411                    .iter()
412                    .filter(|&&i| self.positions[i].units.number.abs() == units.number.abs())
413                    .copied()
414                    .collect();
415
416                if exact_matches.is_empty() {
417                    // Total match exception
418                    let total_units: Decimal = matching_indices
419                        .iter()
420                        .map(|&i| self.positions[i].units.number.abs())
421                        .sum();
422                    if total_units == units.number.abs() {
423                        self.reduce_ordered(units, spec, false)
424                    } else {
425                        Err(BookingError::AmbiguousMatch {
426                            num_matches: n,
427                            currency: units.currency.clone(),
428                        })
429                    }
430                } else {
431                    // Use oldest (first) exact-size match
432                    let idx = exact_matches[0];
433                    self.reduce_from_lot(idx, units)
434                }
435            }
436        }
437    }
438
439    /// FIFO booking: reduce from oldest lots first.
440    fn reduce_fifo(
441        &mut self,
442        units: &Amount,
443        spec: &CostSpec,
444    ) -> Result<BookingResult, BookingError> {
445        self.reduce_ordered(units, spec, false)
446    }
447
448    /// LIFO booking: reduce from newest lots first.
449    fn reduce_lifo(
450        &mut self,
451        units: &Amount,
452        spec: &CostSpec,
453    ) -> Result<BookingResult, BookingError> {
454        self.reduce_ordered(units, spec, true)
455    }
456
457    /// HIFO booking: reduce from highest-cost lots first.
458    fn reduce_hifo(
459        &mut self,
460        units: &Amount,
461        spec: &CostSpec,
462    ) -> Result<BookingResult, BookingError> {
463        let mut remaining = units.number.abs();
464        let mut matched = Vec::new();
465        let mut cost_basis = Decimal::ZERO;
466        let mut cost_currency = None;
467
468        // Get matching positions with their costs
469        let mut matching: Vec<(usize, Decimal)> = self
470            .positions
471            .iter()
472            .enumerate()
473            .filter(|(_, p)| {
474                p.units.currency == units.currency
475                    && !p.is_empty()
476                    && p.units.number.signum() != units.number.signum()
477                    && p.matches_cost_spec(spec)
478            })
479            .map(|(i, p)| {
480                let cost = p.cost.as_ref().map_or(Decimal::ZERO, |c| c.number);
481                (i, cost)
482            })
483            .collect();
484
485        if matching.is_empty() {
486            return Err(BookingError::NoMatchingLot {
487                currency: units.currency.clone(),
488                cost_spec: spec.clone(),
489            });
490        }
491
492        // Sort by cost descending (highest first)
493        matching.sort_by(|a, b| b.1.cmp(&a.1));
494
495        let indices: Vec<usize> = matching.into_iter().map(|(i, _)| i).collect();
496
497        for idx in indices {
498            if remaining.is_zero() {
499                break;
500            }
501
502            let pos = &self.positions[idx];
503            let available = pos.units.number.abs();
504            let take = remaining.min(available);
505
506            // Calculate cost basis for this portion
507            if let Some(cost) = &pos.cost {
508                cost_basis += take * cost.number;
509                cost_currency = Some(cost.currency.clone());
510            }
511
512            // Record what we matched
513            let (taken, _) = pos.split(take * pos.units.number.signum());
514            matched.push(taken);
515
516            // Reduce the lot
517            let reduction = if units.number.is_sign_negative() {
518                -take
519            } else {
520                take
521            };
522
523            let new_pos = Position {
524                units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
525                cost: pos.cost.clone(),
526            };
527            self.positions[idx] = new_pos;
528
529            remaining -= take;
530        }
531
532        if !remaining.is_zero() {
533            let available = units.number.abs() - remaining;
534            return Err(BookingError::InsufficientUnits {
535                currency: units.currency.clone(),
536                requested: units.number.abs(),
537                available,
538            });
539        }
540
541        // Clean up empty positions
542        self.positions.retain(|p| !p.is_empty());
543        self.rebuild_index();
544
545        Ok(BookingResult {
546            matched,
547            cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
548        })
549    }
550
551    /// Reduce in order (FIFO or LIFO).
552    fn reduce_ordered(
553        &mut self,
554        units: &Amount,
555        spec: &CostSpec,
556        reverse: bool,
557    ) -> Result<BookingResult, BookingError> {
558        let mut remaining = units.number.abs();
559        let mut matched = Vec::new();
560        let mut cost_basis = Decimal::ZERO;
561        let mut cost_currency = None;
562
563        // Get indices of matching positions
564        let mut indices: Vec<usize> = self
565            .positions
566            .iter()
567            .enumerate()
568            .filter(|(_, p)| {
569                p.units.currency == units.currency
570                    && !p.is_empty()
571                    && p.units.number.signum() != units.number.signum()
572                    && p.matches_cost_spec(spec)
573            })
574            .map(|(i, _)| i)
575            .collect();
576
577        // Sort by date for correct FIFO/LIFO ordering (oldest first)
578        // This ensures we select by acquisition date, not insertion order
579        indices.sort_by_key(|&i| self.positions[i].cost.as_ref().and_then(|c| c.date));
580
581        if reverse {
582            indices.reverse();
583        }
584
585        if indices.is_empty() {
586            return Err(BookingError::NoMatchingLot {
587                currency: units.currency.clone(),
588                cost_spec: spec.clone(),
589            });
590        }
591
592        for idx in indices {
593            if remaining.is_zero() {
594                break;
595            }
596
597            let pos = &self.positions[idx];
598            let available = pos.units.number.abs();
599            let take = remaining.min(available);
600
601            // Calculate cost basis for this portion
602            if let Some(cost) = &pos.cost {
603                cost_basis += take * cost.number;
604                cost_currency = Some(cost.currency.clone());
605            }
606
607            // Record what we matched
608            let (taken, _) = pos.split(take * pos.units.number.signum());
609            matched.push(taken);
610
611            // Reduce the lot
612            let reduction = if units.number.is_sign_negative() {
613                -take
614            } else {
615                take
616            };
617
618            let new_pos = Position {
619                units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
620                cost: pos.cost.clone(),
621            };
622            self.positions[idx] = new_pos;
623
624            remaining -= take;
625        }
626
627        if !remaining.is_zero() {
628            let available = units.number.abs() - remaining;
629            return Err(BookingError::InsufficientUnits {
630                currency: units.currency.clone(),
631                requested: units.number.abs(),
632                available,
633            });
634        }
635
636        // Clean up empty positions
637        self.positions.retain(|p| !p.is_empty());
638        self.rebuild_index();
639
640        Ok(BookingResult {
641            matched,
642            cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
643        })
644    }
645
646    /// AVERAGE booking: merge all lots of the currency.
647    fn reduce_average(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
648        // Calculate average cost
649        let total_units: Decimal = self
650            .positions
651            .iter()
652            .filter(|p| p.units.currency == units.currency && !p.is_empty())
653            .map(|p| p.units.number)
654            .sum();
655
656        if total_units.is_zero() {
657            return Err(BookingError::InsufficientUnits {
658                currency: units.currency.clone(),
659                requested: units.number.abs(),
660                available: Decimal::ZERO,
661            });
662        }
663
664        // Check sufficient units
665        let reduction = units.number.abs();
666        if reduction > total_units.abs() {
667            return Err(BookingError::InsufficientUnits {
668                currency: units.currency.clone(),
669                requested: reduction,
670                available: total_units.abs(),
671            });
672        }
673
674        // Calculate total cost basis
675        let book_values = self.book_value(&units.currency);
676        let cost_basis = if let Some((curr, &total)) = book_values.iter().next() {
677            let per_unit_cost = total / total_units;
678            Some(Amount::new(reduction * per_unit_cost, curr.clone()))
679        } else {
680            None
681        };
682
683        // Create merged position
684        let new_units = total_units + units.number;
685
686        // Remove all positions of this currency
687        let matched: Vec<Position> = self
688            .positions
689            .iter()
690            .filter(|p| p.units.currency == units.currency && !p.is_empty())
691            .cloned()
692            .collect();
693
694        self.positions
695            .retain(|p| p.units.currency != units.currency);
696
697        // Add back the remainder if non-zero
698        if !new_units.is_zero() {
699            self.positions.push(Position::simple(Amount::new(
700                new_units,
701                units.currency.clone(),
702            )));
703        }
704
705        // Rebuild index after modifications
706        self.rebuild_index();
707
708        Ok(BookingResult {
709            matched,
710            cost_basis,
711        })
712    }
713
714    /// NONE booking: reduce without matching lots.
715    fn reduce_none(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
716        // For NONE booking, we just reduce the total without caring about lots
717        let total_units = self.units(&units.currency);
718
719        // Check we have enough in the right direction
720        if total_units.signum() == units.number.signum() || total_units.is_zero() {
721            // This is an augmentation, not a reduction - just add it
722            self.add(Position::simple(units.clone()));
723            return Ok(BookingResult {
724                matched: vec![],
725                cost_basis: None,
726            });
727        }
728
729        let available = total_units.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        // Reduce positions proportionally (simplified: just reduce first matching)
741        self.reduce_ordered(units, &CostSpec::default(), false)
742    }
743
744    /// Reduce from a specific lot.
745    fn reduce_from_lot(
746        &mut self,
747        idx: usize,
748        units: &Amount,
749    ) -> Result<BookingResult, BookingError> {
750        let pos = &self.positions[idx];
751        let available = pos.units.number.abs();
752        let requested = units.number.abs();
753
754        if requested > available {
755            return Err(BookingError::InsufficientUnits {
756                currency: units.currency.clone(),
757                requested,
758                available,
759            });
760        }
761
762        // Calculate cost basis
763        let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
764
765        // Record matched
766        let (matched, _) = pos.split(requested * pos.units.number.signum());
767
768        // Update the position
769        let new_units = pos.units.number + units.number;
770        let new_pos = Position {
771            units: Amount::new(new_units, pos.units.currency.clone()),
772            cost: pos.cost.clone(),
773        };
774        self.positions[idx] = new_pos;
775
776        // Remove if empty
777        if self.positions[idx].is_empty() {
778            self.positions.remove(idx);
779        }
780
781        Ok(BookingResult {
782            matched: vec![matched],
783            cost_basis,
784        })
785    }
786
787    /// Remove all empty positions.
788    pub fn compact(&mut self) {
789        self.positions.retain(|p| !p.is_empty());
790        self.rebuild_index();
791    }
792
793    /// Rebuild the `simple_index` from positions.
794    /// Called after operations that may invalidate the index (like retain).
795    fn rebuild_index(&mut self) {
796        self.simple_index.clear();
797        for (idx, pos) in self.positions.iter().enumerate() {
798            if pos.cost.is_none() {
799                debug_assert!(
800                    !self.simple_index.contains_key(&pos.units.currency),
801                    "Invariant violated: multiple simple positions for currency {}",
802                    pos.units.currency
803                );
804                self.simple_index.insert(pos.units.currency.clone(), idx);
805            }
806        }
807    }
808
809    /// Merge this inventory with another.
810    pub fn merge(&mut self, other: &Self) {
811        for pos in &other.positions {
812            self.add(pos.clone());
813        }
814    }
815
816    /// Convert inventory to cost basis.
817    ///
818    /// Returns a new inventory where all positions are converted to their
819    /// cost basis. Positions without cost are returned as-is.
820    #[must_use]
821    pub fn at_cost(&self) -> Self {
822        let mut result = Self::new();
823
824        for pos in &self.positions {
825            if pos.is_empty() {
826                continue;
827            }
828
829            if let Some(cost) = &pos.cost {
830                // Convert to cost basis
831                let total = pos.units.number * cost.number;
832                result.add(Position::simple(Amount::new(total, &cost.currency)));
833            } else {
834                // No cost, keep as-is
835                result.add(pos.clone());
836            }
837        }
838
839        result
840    }
841
842    /// Convert inventory to units only.
843    ///
844    /// Returns a new inventory where all positions have their cost removed,
845    /// effectively aggregating by currency only.
846    #[must_use]
847    pub fn at_units(&self) -> Self {
848        let mut result = Self::new();
849
850        for pos in &self.positions {
851            if pos.is_empty() {
852                continue;
853            }
854
855            // Strip cost, keep only units
856            result.add(Position::simple(pos.units.clone()));
857        }
858
859        result
860    }
861}
862
863impl fmt::Display for Inventory {
864    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
865        if self.is_empty() {
866            return write!(f, "(empty)");
867        }
868
869        // Sort positions alphabetically by currency, then by cost for consistency
870        let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
871        non_empty.sort_by(|a, b| {
872            // First by currency
873            let cmp = a.units.currency.cmp(&b.units.currency);
874            if cmp != std::cmp::Ordering::Equal {
875                return cmp;
876            }
877            // Then by cost (if present)
878            match (&a.cost, &b.cost) {
879                (Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
880                (Some(_), None) => std::cmp::Ordering::Greater,
881                (None, Some(_)) => std::cmp::Ordering::Less,
882                (None, None) => std::cmp::Ordering::Equal,
883            }
884        });
885
886        for (i, pos) in non_empty.iter().enumerate() {
887            if i > 0 {
888                write!(f, ", ")?;
889            }
890            write!(f, "{pos}")?;
891        }
892        Ok(())
893    }
894}
895
896impl FromIterator<Position> for Inventory {
897    fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
898        let mut inv = Self::new();
899        for pos in iter {
900            inv.add(pos);
901        }
902        inv
903    }
904}
905
906#[cfg(test)]
907mod tests {
908    use super::*;
909    use crate::Cost;
910    use chrono::NaiveDate;
911    use rust_decimal_macros::dec;
912
913    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
914        NaiveDate::from_ymd_opt(year, month, day).unwrap()
915    }
916
917    #[test]
918    fn test_empty_inventory() {
919        let inv = Inventory::new();
920        assert!(inv.is_empty());
921        assert_eq!(inv.len(), 0);
922    }
923
924    #[test]
925    fn test_add_simple() {
926        let mut inv = Inventory::new();
927        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
928
929        assert!(!inv.is_empty());
930        assert_eq!(inv.units("USD"), dec!(100));
931    }
932
933    #[test]
934    fn test_add_merge_simple() {
935        let mut inv = Inventory::new();
936        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
937        inv.add(Position::simple(Amount::new(dec!(50), "USD")));
938
939        // Should merge into one position
940        assert_eq!(inv.len(), 1);
941        assert_eq!(inv.units("USD"), dec!(150));
942    }
943
944    #[test]
945    fn test_add_with_cost_no_merge() {
946        let mut inv = Inventory::new();
947
948        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
949        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
950
951        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
952        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
953
954        // Should NOT merge - different costs
955        assert_eq!(inv.len(), 2);
956        assert_eq!(inv.units("AAPL"), dec!(15));
957    }
958
959    #[test]
960    fn test_currencies() {
961        let mut inv = Inventory::new();
962        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
963        inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
964        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
965
966        let currencies = inv.currencies();
967        assert_eq!(currencies.len(), 3);
968        assert!(currencies.contains(&"USD"));
969        assert!(currencies.contains(&"EUR"));
970        assert!(currencies.contains(&"AAPL"));
971    }
972
973    #[test]
974    fn test_reduce_strict_unique() {
975        let mut inv = Inventory::new();
976        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
977        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
978
979        let result = inv
980            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
981            .unwrap();
982
983        assert_eq!(inv.units("AAPL"), dec!(5));
984        assert!(result.cost_basis.is_some());
985        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); // 5 * 150
986    }
987
988    #[test]
989    fn test_reduce_strict_multiple_match_uses_fifo() {
990        let mut inv = Inventory::new();
991
992        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
993        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
994
995        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
996        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
997
998        // Reducing without cost spec in STRICT mode falls back to FIFO
999        // (Python beancount behavior: when multiple lots match, use FIFO order)
1000        let result = inv
1001            .reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
1002            .unwrap();
1003
1004        assert_eq!(inv.units("AAPL"), dec!(12)); // 7 + 5 remaining
1005        // Should reduce from first lot (cost1) at 150.00 USD
1006        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); // 3 * 150
1007    }
1008
1009    #[test]
1010    fn test_reduce_strict_with_spec() {
1011        let mut inv = Inventory::new();
1012
1013        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1014        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
1015
1016        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1017        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1018
1019        // Reducing with cost spec should work
1020        let spec = CostSpec::empty().with_date(date(2024, 1, 1));
1021        let result = inv
1022            .reduce(
1023                &Amount::new(dec!(-3), "AAPL"),
1024                Some(&spec),
1025                BookingMethod::Strict,
1026            )
1027            .unwrap();
1028
1029        assert_eq!(inv.units("AAPL"), dec!(12)); // 7 + 5
1030        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); // 3 * 150
1031    }
1032
1033    #[test]
1034    fn test_reduce_fifo() {
1035        let mut inv = Inventory::new();
1036
1037        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1038        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1039        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1040
1041        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1042        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1043        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1044
1045        // FIFO should reduce from oldest (cost 100) first
1046        let result = inv
1047            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
1048            .unwrap();
1049
1050        assert_eq!(inv.units("AAPL"), dec!(15));
1051        // Cost basis: 10 * 100 + 5 * 150 = 1000 + 750 = 1750
1052        assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
1053    }
1054
1055    #[test]
1056    fn test_reduce_lifo() {
1057        let mut inv = Inventory::new();
1058
1059        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1060        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1061        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1062
1063        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1064        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1065        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1066
1067        // LIFO should reduce from newest (cost 200) first
1068        let result = inv
1069            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
1070            .unwrap();
1071
1072        assert_eq!(inv.units("AAPL"), dec!(15));
1073        // Cost basis: 10 * 200 + 5 * 150 = 2000 + 750 = 2750
1074        assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
1075    }
1076
1077    #[test]
1078    fn test_reduce_insufficient() {
1079        let mut inv = Inventory::new();
1080        let cost = Cost::new(dec!(150.00), "USD");
1081        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1082
1083        let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
1084
1085        assert!(matches!(
1086            result,
1087            Err(BookingError::InsufficientUnits { .. })
1088        ));
1089    }
1090
1091    #[test]
1092    fn test_book_value() {
1093        let mut inv = Inventory::new();
1094
1095        let cost1 = Cost::new(dec!(100.00), "USD");
1096        let cost2 = Cost::new(dec!(150.00), "USD");
1097
1098        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1099        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1100
1101        let book = inv.book_value("AAPL");
1102        assert_eq!(book.get("USD"), Some(&dec!(1750.00))); // 10*100 + 5*150
1103    }
1104
1105    #[test]
1106    fn test_display() {
1107        let mut inv = Inventory::new();
1108        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1109
1110        let s = format!("{inv}");
1111        assert!(s.contains("100 USD"));
1112    }
1113
1114    #[test]
1115    fn test_display_empty() {
1116        let inv = Inventory::new();
1117        assert_eq!(format!("{inv}"), "(empty)");
1118    }
1119
1120    #[test]
1121    fn test_from_iterator() {
1122        let positions = vec![
1123            Position::simple(Amount::new(dec!(100), "USD")),
1124            Position::simple(Amount::new(dec!(50), "USD")),
1125        ];
1126
1127        let inv: Inventory = positions.into_iter().collect();
1128        assert_eq!(inv.units("USD"), dec!(150));
1129    }
1130
1131    #[test]
1132    fn test_add_costed_positions_kept_separate() {
1133        // Costed positions are kept as separate lots for O(1) add performance.
1134        // Aggregation happens at display time (in query output).
1135        let mut inv = Inventory::new();
1136
1137        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1138
1139        // Buy 10 shares
1140        inv.add(Position::with_cost(
1141            Amount::new(dec!(10), "AAPL"),
1142            cost.clone(),
1143        ));
1144        assert_eq!(inv.len(), 1);
1145        assert_eq!(inv.units("AAPL"), dec!(10));
1146
1147        // Sell 10 shares - kept as separate lot for tracking
1148        inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1149        assert_eq!(inv.len(), 2); // Both lots kept
1150        assert_eq!(inv.units("AAPL"), dec!(0)); // Net units still zero
1151    }
1152
1153    #[test]
1154    fn test_add_costed_positions_net_units() {
1155        // Verify that units() correctly sums across all lots
1156        let mut inv = Inventory::new();
1157
1158        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1159
1160        // Buy 10 shares
1161        inv.add(Position::with_cost(
1162            Amount::new(dec!(10), "AAPL"),
1163            cost.clone(),
1164        ));
1165
1166        // Sell 3 shares - kept as separate lot
1167        inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost));
1168        assert_eq!(inv.len(), 2); // Both lots kept
1169        assert_eq!(inv.units("AAPL"), dec!(7)); // Net units correct
1170    }
1171
1172    #[test]
1173    fn test_add_no_cancel_different_cost() {
1174        // Test that different costs don't cancel
1175        let mut inv = Inventory::new();
1176
1177        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1178        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
1179
1180        // Buy 10 shares at 150
1181        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1182
1183        // Sell 5 shares at 160 - should NOT cancel (different cost)
1184        inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2));
1185
1186        // Should have two separate lots
1187        assert_eq!(inv.len(), 2);
1188        assert_eq!(inv.units("AAPL"), dec!(5)); // 10 - 5 = 5 total
1189    }
1190
1191    #[test]
1192    fn test_add_no_cancel_same_sign() {
1193        // Test that same-sign positions don't merge even with same cost
1194        let mut inv = Inventory::new();
1195
1196        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1197
1198        // Buy 10 shares
1199        inv.add(Position::with_cost(
1200            Amount::new(dec!(10), "AAPL"),
1201            cost.clone(),
1202        ));
1203
1204        // Buy 5 more shares with same cost - should NOT merge
1205        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
1206
1207        // Should have two separate lots (different acquisitions)
1208        assert_eq!(inv.len(), 2);
1209        assert_eq!(inv.units("AAPL"), dec!(15));
1210    }
1211
1212    #[test]
1213    fn test_merge_keeps_lots_separate() {
1214        // Test that merge keeps costed lots separate (aggregation at display time)
1215        let mut inv1 = Inventory::new();
1216        let mut inv2 = Inventory::new();
1217
1218        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1219
1220        // inv1: buy 10 shares
1221        inv1.add(Position::with_cost(
1222            Amount::new(dec!(10), "AAPL"),
1223            cost.clone(),
1224        ));
1225
1226        // inv2: sell 10 shares
1227        inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1228
1229        // Merge keeps both lots, net units is zero
1230        inv1.merge(&inv2);
1231        assert_eq!(inv1.len(), 2); // Both lots preserved
1232        assert_eq!(inv1.units("AAPL"), dec!(0)); // Net units correct
1233    }
1234}