Skip to main content

rustledger_core/inventory/
mod.rs

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