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        // {*} merge operator: merge all lots into a single weighted-average-cost
466        // lot before reducing, regardless of the account's booking method.
467        if spec.merge {
468            return self.reduce_merge(units);
469        }
470
471        match method {
472            BookingMethod::Strict => self.reduce_strict(units, &spec),
473            BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
474            BookingMethod::Fifo => self.reduce_fifo(units, &spec),
475            BookingMethod::Lifo => self.reduce_lifo(units, &spec),
476            BookingMethod::Hifo => self.reduce_hifo(units, &spec),
477            BookingMethod::Average => self.reduce_average(units),
478            BookingMethod::None => self.reduce_none(units),
479        }
480    }
481
482    /// Remove all empty positions.
483    pub fn compact(&mut self) {
484        self.positions.retain(|p| !p.is_empty());
485        self.rebuild_index();
486    }
487
488    /// Rebuild all caches (`simple_index` and `units_cache`) from positions.
489    /// Called after operations that may invalidate caches (like retain or deserialization).
490    fn rebuild_index(&mut self) {
491        self.simple_index.clear();
492        self.units_cache.clear();
493
494        for (idx, pos) in self.positions.iter().enumerate() {
495            // Update units cache for all positions
496            *self
497                .units_cache
498                .entry(pos.units.currency.clone())
499                .or_default() += pos.units.number;
500
501            // Update simple_index only for positions without cost
502            if pos.cost.is_none() {
503                debug_assert!(
504                    !self.simple_index.contains_key(&pos.units.currency),
505                    "Invariant violated: multiple simple positions for currency {}",
506                    pos.units.currency
507                );
508                self.simple_index.insert(pos.units.currency.clone(), idx);
509            }
510        }
511    }
512
513    /// Merge this inventory with another.
514    pub fn merge(&mut self, other: &Self) {
515        for pos in &other.positions {
516            self.add(pos.clone());
517        }
518    }
519
520    /// Convert inventory to cost basis.
521    ///
522    /// Returns a new inventory where all positions are converted to their
523    /// cost basis. Positions without cost are returned as-is.
524    #[must_use]
525    pub fn at_cost(&self) -> Self {
526        let mut result = Self::new();
527
528        for pos in &self.positions {
529            if pos.is_empty() {
530                continue;
531            }
532
533            if let Some(cost) = &pos.cost {
534                // Convert to cost basis
535                let total = pos.units.number * cost.number;
536                result.add(Position::simple(Amount::new(total, &cost.currency)));
537            } else {
538                // No cost, keep as-is
539                result.add(pos.clone());
540            }
541        }
542
543        result
544    }
545
546    /// Convert inventory to units only.
547    ///
548    /// Returns a new inventory where all positions have their cost removed,
549    /// effectively aggregating by currency only.
550    #[must_use]
551    pub fn at_units(&self) -> Self {
552        let mut result = Self::new();
553
554        for pos in &self.positions {
555            if pos.is_empty() {
556                continue;
557            }
558
559            // Strip cost, keep only units
560            result.add(Position::simple(pos.units.clone()));
561        }
562
563        result
564    }
565}
566
567impl fmt::Display for Inventory {
568    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
569        if self.is_empty() {
570            return write!(f, "(empty)");
571        }
572
573        // Sort positions alphabetically by currency, then by cost for consistency
574        let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
575        non_empty.sort_by(|a, b| {
576            // First by currency
577            let cmp = a.units.currency.cmp(&b.units.currency);
578            if cmp != std::cmp::Ordering::Equal {
579                return cmp;
580            }
581            // Then by cost (if present)
582            match (&a.cost, &b.cost) {
583                (Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
584                (Some(_), None) => std::cmp::Ordering::Greater,
585                (None, Some(_)) => std::cmp::Ordering::Less,
586                (None, None) => std::cmp::Ordering::Equal,
587            }
588        });
589
590        for (i, pos) in non_empty.iter().enumerate() {
591            if i > 0 {
592                write!(f, ", ")?;
593            }
594            write!(f, "{pos}")?;
595        }
596        Ok(())
597    }
598}
599
600impl FromIterator<Position> for Inventory {
601    fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
602        let mut inv = Self::new();
603        for pos in iter {
604            inv.add(pos);
605        }
606        inv
607    }
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use crate::Cost;
614    use crate::NaiveDate;
615    use rust_decimal_macros::dec;
616
617    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
618        crate::naive_date(year, month, day).unwrap()
619    }
620
621    #[test]
622    fn test_empty_inventory() {
623        let inv = Inventory::new();
624        assert!(inv.is_empty());
625        assert_eq!(inv.len(), 0);
626    }
627
628    #[test]
629    fn test_add_simple() {
630        let mut inv = Inventory::new();
631        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
632
633        assert!(!inv.is_empty());
634        assert_eq!(inv.units("USD"), dec!(100));
635    }
636
637    #[test]
638    fn test_add_merge_simple() {
639        let mut inv = Inventory::new();
640        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
641        inv.add(Position::simple(Amount::new(dec!(50), "USD")));
642
643        // Should merge into one position
644        assert_eq!(inv.len(), 1);
645        assert_eq!(inv.units("USD"), dec!(150));
646    }
647
648    #[test]
649    fn test_add_with_cost_no_merge() {
650        let mut inv = Inventory::new();
651
652        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
653        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
654
655        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
656        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
657
658        // Should NOT merge - different costs
659        assert_eq!(inv.len(), 2);
660        assert_eq!(inv.units("AAPL"), dec!(15));
661    }
662
663    #[test]
664    fn test_currencies() {
665        let mut inv = Inventory::new();
666        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
667        inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
668        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
669
670        let currencies = inv.currencies();
671        assert_eq!(currencies.len(), 3);
672        assert!(currencies.contains(&"USD"));
673        assert!(currencies.contains(&"EUR"));
674        assert!(currencies.contains(&"AAPL"));
675    }
676
677    #[test]
678    fn test_reduce_strict_unique() {
679        let mut inv = Inventory::new();
680        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
681        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
682
683        let result = inv
684            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
685            .unwrap();
686
687        assert_eq!(inv.units("AAPL"), dec!(5));
688        assert!(result.cost_basis.is_some());
689        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); // 5 * 150
690    }
691
692    #[test]
693    fn test_reduce_strict_multiple_match_with_different_costs_is_ambiguous() {
694        let mut inv = Inventory::new();
695
696        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
697        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
698
699        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
700        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
701
702        // Per Python beancount: a wildcard reduction (`-3 AAPL` with no cost
703        // spec) against an inventory with lots at different costs is
704        // genuinely ambiguous and must error. Issue #737.
705        let result = inv.reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict);
706
707        assert!(
708            matches!(result, Err(BookingError::AmbiguousMatch { .. })),
709            "expected AmbiguousMatch, got {result:?}"
710        );
711        // Inventory unchanged after a failed reduction
712        assert_eq!(inv.units("AAPL"), dec!(15));
713    }
714
715    #[test]
716    fn test_reduce_strict_multiple_match_with_identical_costs_uses_fifo() {
717        let mut inv = Inventory::new();
718
719        // Two lots with identical cost — interchangeable, so FIFO is fine.
720        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
721
722        inv.add(Position::with_cost(
723            Amount::new(dec!(10), "AAPL"),
724            cost.clone(),
725        ));
726        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
727
728        let result = inv
729            .reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
730            .expect("identical lots should fall back to FIFO without error");
731
732        assert_eq!(inv.units("AAPL"), dec!(12));
733        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00));
734    }
735
736    #[test]
737    fn test_reduce_strict_multiple_match_different_dates_same_cost_uses_fifo() {
738        let mut inv = Inventory::new();
739
740        // Two lots at the same cost number but different acquisition dates.
741        // The user's cost spec could not have constrained the date without
742        // naming it, so the lots are interchangeable for the spec — FIFO.
743        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
744        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 15));
745
746        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
747        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
748
749        let result = inv
750            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
751            .expect("same cost number, different dates should fall back to FIFO");
752
753        assert_eq!(inv.units("AAPL"), dec!(15));
754        // Reduced from the first (oldest) lot at 150.00 USD: 5 * 150 = 750.
755        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
756    }
757
758    #[test]
759    fn test_reduce_strict_multiple_match_total_match_exception() {
760        let mut inv = Inventory::new();
761
762        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
763        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
764
765        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
766        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
767
768        // Selling exactly the entire inventory (10 + 5 = 15) is unambiguous
769        // even with mixed costs — the user is liquidating the position.
770        let result = inv
771            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Strict)
772            .expect("total-match exception should accept a full liquidation");
773
774        assert_eq!(inv.units("AAPL"), dec!(0));
775        // Cost basis = 10*150 + 5*160 = 1500 + 800 = 2300
776        assert_eq!(result.cost_basis.unwrap().number, dec!(2300.00));
777    }
778
779    #[test]
780    fn test_reduce_strict_with_spec() {
781        let mut inv = Inventory::new();
782
783        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
784        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
785
786        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
787        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
788
789        // Reducing with cost spec should work
790        let spec = CostSpec::empty().with_date(date(2024, 1, 1));
791        let result = inv
792            .reduce(
793                &Amount::new(dec!(-3), "AAPL"),
794                Some(&spec),
795                BookingMethod::Strict,
796            )
797            .unwrap();
798
799        assert_eq!(inv.units("AAPL"), dec!(12)); // 7 + 5
800        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); // 3 * 150
801    }
802
803    #[test]
804    fn test_reduce_fifo() {
805        let mut inv = Inventory::new();
806
807        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
808        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
809        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
810
811        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
812        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
813        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
814
815        // FIFO should reduce from oldest (cost 100) first
816        let result = inv
817            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
818            .unwrap();
819
820        assert_eq!(inv.units("AAPL"), dec!(15));
821        // Cost basis: 10 * 100 + 5 * 150 = 1000 + 750 = 1750
822        assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
823    }
824
825    #[test]
826    fn test_reduce_lifo() {
827        let mut inv = Inventory::new();
828
829        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
830        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
831        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
832
833        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
834        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
835        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
836
837        // LIFO should reduce from newest (cost 200) first
838        let result = inv
839            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
840            .unwrap();
841
842        assert_eq!(inv.units("AAPL"), dec!(15));
843        // Cost basis: 10 * 200 + 5 * 150 = 2000 + 750 = 2750
844        assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
845    }
846
847    #[test]
848    fn test_reduce_insufficient() {
849        let mut inv = Inventory::new();
850        let cost = Cost::new(dec!(150.00), "USD");
851        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
852
853        let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
854
855        assert!(matches!(
856            result,
857            Err(BookingError::InsufficientUnits { .. })
858        ));
859    }
860
861    #[test]
862    fn test_book_value() {
863        let mut inv = Inventory::new();
864
865        let cost1 = Cost::new(dec!(100.00), "USD");
866        let cost2 = Cost::new(dec!(150.00), "USD");
867
868        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
869        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
870
871        let book = inv.book_value("AAPL");
872        assert_eq!(book.get("USD"), Some(&dec!(1750.00))); // 10*100 + 5*150
873    }
874
875    #[test]
876    fn test_display() {
877        let mut inv = Inventory::new();
878        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
879
880        let s = format!("{inv}");
881        assert!(s.contains("100 USD"));
882    }
883
884    #[test]
885    fn test_display_empty() {
886        let inv = Inventory::new();
887        assert_eq!(format!("{inv}"), "(empty)");
888    }
889
890    #[test]
891    fn test_from_iterator() {
892        let positions = vec![
893            Position::simple(Amount::new(dec!(100), "USD")),
894            Position::simple(Amount::new(dec!(50), "USD")),
895        ];
896
897        let inv: Inventory = positions.into_iter().collect();
898        assert_eq!(inv.units("USD"), dec!(150));
899    }
900
901    #[test]
902    fn test_add_costed_positions_kept_separate() {
903        // Costed positions are kept as separate lots for O(1) add performance.
904        // Aggregation happens at display time (in query output).
905        let mut inv = Inventory::new();
906
907        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
908
909        // Buy 10 shares
910        inv.add(Position::with_cost(
911            Amount::new(dec!(10), "AAPL"),
912            cost.clone(),
913        ));
914        assert_eq!(inv.len(), 1);
915        assert_eq!(inv.units("AAPL"), dec!(10));
916
917        // Sell 10 shares - kept as separate lot for tracking
918        inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
919        assert_eq!(inv.len(), 2); // Both lots kept
920        assert_eq!(inv.units("AAPL"), dec!(0)); // Net units still zero
921    }
922
923    #[test]
924    fn test_add_costed_positions_net_units() {
925        // Verify that units() correctly sums across all lots
926        let mut inv = Inventory::new();
927
928        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
929
930        // Buy 10 shares
931        inv.add(Position::with_cost(
932            Amount::new(dec!(10), "AAPL"),
933            cost.clone(),
934        ));
935
936        // Sell 3 shares - kept as separate lot
937        inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost));
938        assert_eq!(inv.len(), 2); // Both lots kept
939        assert_eq!(inv.units("AAPL"), dec!(7)); // Net units correct
940    }
941
942    #[test]
943    fn test_add_no_cancel_different_cost() {
944        // Test that different costs don't cancel
945        let mut inv = Inventory::new();
946
947        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
948        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
949
950        // Buy 10 shares at 150
951        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
952
953        // Sell 5 shares at 160 - should NOT cancel (different cost)
954        inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2));
955
956        // Should have two separate lots
957        assert_eq!(inv.len(), 2);
958        assert_eq!(inv.units("AAPL"), dec!(5)); // 10 - 5 = 5 total
959    }
960
961    #[test]
962    fn test_add_no_cancel_same_sign() {
963        // Test that same-sign positions don't merge even with same cost
964        let mut inv = Inventory::new();
965
966        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
967
968        // Buy 10 shares
969        inv.add(Position::with_cost(
970            Amount::new(dec!(10), "AAPL"),
971            cost.clone(),
972        ));
973
974        // Buy 5 more shares with same cost - should NOT merge
975        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
976
977        // Should have two separate lots (different acquisitions)
978        assert_eq!(inv.len(), 2);
979        assert_eq!(inv.units("AAPL"), dec!(15));
980    }
981
982    #[test]
983    fn test_merge_keeps_lots_separate() {
984        // Test that merge keeps costed lots separate (aggregation at display time)
985        let mut inv1 = Inventory::new();
986        let mut inv2 = Inventory::new();
987
988        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
989
990        // inv1: buy 10 shares
991        inv1.add(Position::with_cost(
992            Amount::new(dec!(10), "AAPL"),
993            cost.clone(),
994        ));
995
996        // inv2: sell 10 shares
997        inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
998
999        // Merge keeps both lots, net units is zero
1000        inv1.merge(&inv2);
1001        assert_eq!(inv1.len(), 2); // Both lots preserved
1002        assert_eq!(inv1.units("AAPL"), dec!(0)); // Net units correct
1003    }
1004
1005    // ====================================================================
1006    // Phase 2: Additional Coverage Tests for Booking Methods
1007    // ====================================================================
1008
1009    #[test]
1010    fn test_hifo_with_tie_breaking() {
1011        // When multiple lots have the same cost, HIFO should use insertion order
1012        let mut inv = Inventory::new();
1013
1014        // Three lots with same cost but different dates
1015        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1016        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1017        let cost3 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 3, 1));
1018
1019        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1020        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1021        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1022
1023        // HIFO with tied costs should reduce in some deterministic order
1024        let result = inv
1025            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
1026            .unwrap();
1027
1028        assert_eq!(inv.units("AAPL"), dec!(15));
1029        // All at same cost, so 15 * 100 = 1500
1030        assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1031    }
1032
1033    #[test]
1034    fn test_hifo_with_different_costs() {
1035        // HIFO should reduce highest cost lots first
1036        let mut inv = Inventory::new();
1037
1038        let cost_low = Cost::new(dec!(50.00), "USD").with_date(date(2024, 1, 1));
1039        let cost_mid = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1040        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1041
1042        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
1043        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
1044        inv.add(Position::with_cost(
1045            Amount::new(dec!(10), "AAPL"),
1046            cost_high,
1047        ));
1048
1049        // Reduce 15 shares - should take from highest cost (200) first
1050        let result = inv
1051            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
1052            .unwrap();
1053
1054        assert_eq!(inv.units("AAPL"), dec!(15));
1055        // 10 * 200 + 5 * 100 = 2000 + 500 = 2500
1056        assert_eq!(result.cost_basis.unwrap().number, dec!(2500.00));
1057    }
1058
1059    #[test]
1060    fn test_average_booking_with_pre_existing_positions() {
1061        let mut inv = Inventory::new();
1062
1063        // Add two lots with different costs
1064        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1065        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1066
1067        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1068        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1069
1070        // Total: 20 shares, total cost = 10*100 + 10*200 = 3000, avg = 150/share
1071        // Reduce 5 shares using AVERAGE
1072        let result = inv
1073            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
1074            .unwrap();
1075
1076        assert_eq!(inv.units("AAPL"), dec!(15));
1077        // Cost basis for 5 shares at average 150 = 750
1078        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
1079    }
1080
1081    #[test]
1082    fn test_average_booking_reduces_all() {
1083        let mut inv = Inventory::new();
1084
1085        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1086        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1087
1088        // Reduce all shares
1089        let result = inv
1090            .reduce(
1091                &Amount::new(dec!(-10), "AAPL"),
1092                None,
1093                BookingMethod::Average,
1094            )
1095            .unwrap();
1096
1097        assert!(inv.is_empty() || inv.units("AAPL").is_zero());
1098        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1099    }
1100
1101    #[test]
1102    fn test_none_booking_augmentation() {
1103        // NONE booking with same-sign amounts should augment, not reduce
1104        let mut inv = Inventory::new();
1105        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1106
1107        // Adding more (same sign) - this is an augmentation
1108        let result = inv
1109            .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
1110            .unwrap();
1111
1112        assert_eq!(inv.units("USD"), dec!(150));
1113        assert!(result.matched.is_empty()); // No lots matched for augmentation
1114        assert!(result.cost_basis.is_none());
1115    }
1116
1117    #[test]
1118    fn test_none_booking_reduction() {
1119        // NONE booking with opposite-sign should reduce
1120        let mut inv = Inventory::new();
1121        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1122
1123        let result = inv
1124            .reduce(&Amount::new(dec!(-30), "USD"), None, BookingMethod::None)
1125            .unwrap();
1126
1127        assert_eq!(inv.units("USD"), dec!(70));
1128        assert!(!result.matched.is_empty());
1129    }
1130
1131    #[test]
1132    fn test_none_booking_insufficient() {
1133        let mut inv = Inventory::new();
1134        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1135
1136        let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
1137
1138        assert!(matches!(
1139            result,
1140            Err(BookingError::InsufficientUnits { .. })
1141        ));
1142    }
1143
1144    #[test]
1145    fn test_booking_error_no_matching_lot() {
1146        let mut inv = Inventory::new();
1147
1148        // Add a lot with specific cost
1149        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1150        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1151
1152        // Try to reduce with a cost spec that doesn't match
1153        let wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
1154        let result = inv.reduce(
1155            &Amount::new(dec!(-5), "AAPL"),
1156            Some(&wrong_spec),
1157            BookingMethod::Strict,
1158        );
1159
1160        assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1161    }
1162
1163    #[test]
1164    fn test_booking_error_insufficient_units() {
1165        let mut inv = Inventory::new();
1166
1167        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1168        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1169
1170        // Try to reduce more than available
1171        let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
1172
1173        match result {
1174            Err(BookingError::InsufficientUnits {
1175                requested,
1176                available,
1177                ..
1178            }) => {
1179                assert_eq!(requested, dec!(20));
1180                assert_eq!(available, dec!(10));
1181            }
1182            _ => panic!("Expected InsufficientUnits error"),
1183        }
1184    }
1185
1186    #[test]
1187    fn test_strict_with_size_exact_match() {
1188        let mut inv = Inventory::new();
1189
1190        // Add two lots with same cost but different sizes
1191        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1192        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1193
1194        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1195        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1196
1197        // Reduce exactly 5 - should match the 5-share lot
1198        let result = inv
1199            .reduce(
1200                &Amount::new(dec!(-5), "AAPL"),
1201                None,
1202                BookingMethod::StrictWithSize,
1203            )
1204            .unwrap();
1205
1206        assert_eq!(inv.units("AAPL"), dec!(10));
1207        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1208    }
1209
1210    #[test]
1211    fn test_strict_with_size_total_match() {
1212        let mut inv = Inventory::new();
1213
1214        // Add two lots
1215        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1216        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1217
1218        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1219        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1220
1221        // Reduce exactly 15 (total) - should succeed via total match exception
1222        let result = inv
1223            .reduce(
1224                &Amount::new(dec!(-15), "AAPL"),
1225                None,
1226                BookingMethod::StrictWithSize,
1227            )
1228            .unwrap();
1229
1230        assert_eq!(inv.units("AAPL"), dec!(0));
1231        assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1232    }
1233
1234    #[test]
1235    fn test_strict_with_size_ambiguous() {
1236        let mut inv = Inventory::new();
1237
1238        // Add two lots of same size and cost
1239        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1240        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1241
1242        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1243        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1244
1245        // Reduce 7 shares - doesn't match either lot exactly, not total
1246        let result = inv.reduce(
1247            &Amount::new(dec!(-7), "AAPL"),
1248            None,
1249            BookingMethod::StrictWithSize,
1250        );
1251
1252        assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
1253    }
1254
1255    #[test]
1256    fn test_short_position() {
1257        // Test short selling (negative positions)
1258        let mut inv = Inventory::new();
1259
1260        // Short 10 shares
1261        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1262        inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1263
1264        assert_eq!(inv.units("AAPL"), dec!(-10));
1265        assert!(!inv.is_empty());
1266    }
1267
1268    #[test]
1269    fn test_at_cost() {
1270        let mut inv = Inventory::new();
1271
1272        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1273        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1274
1275        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1276        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1277        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1278
1279        let at_cost = inv.at_cost();
1280
1281        // AAPL converted: 10*100 + 5*150 = 1000 + 750 = 1750 USD
1282        // Plus 100 USD simple position = 1850 USD total
1283        assert_eq!(at_cost.units("USD"), dec!(1850));
1284        assert_eq!(at_cost.units("AAPL"), dec!(0)); // No AAPL in cost view
1285    }
1286
1287    #[test]
1288    fn test_at_units() {
1289        let mut inv = Inventory::new();
1290
1291        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1292        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1293
1294        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1295        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1296
1297        let at_units = inv.at_units();
1298
1299        // All AAPL lots merged
1300        assert_eq!(at_units.units("AAPL"), dec!(15));
1301        // Should only have one position after aggregation
1302        assert_eq!(at_units.len(), 1);
1303    }
1304
1305    #[test]
1306    fn test_add_empty_position() {
1307        let mut inv = Inventory::new();
1308        inv.add(Position::simple(Amount::new(dec!(0), "USD")));
1309
1310        assert!(inv.is_empty());
1311        assert_eq!(inv.len(), 0);
1312    }
1313
1314    #[test]
1315    fn test_compact() {
1316        let mut inv = Inventory::new();
1317
1318        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1319        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1320
1321        // Reduce all
1322        inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
1323            .unwrap();
1324
1325        // Compact to remove empty positions
1326        inv.compact();
1327        assert!(inv.is_empty());
1328        assert_eq!(inv.len(), 0);
1329    }
1330
1331    #[test]
1332    fn test_booking_method_from_str() {
1333        assert_eq!(
1334            BookingMethod::from_str("STRICT").unwrap(),
1335            BookingMethod::Strict
1336        );
1337        assert_eq!(
1338            BookingMethod::from_str("fifo").unwrap(),
1339            BookingMethod::Fifo
1340        );
1341        assert_eq!(
1342            BookingMethod::from_str("LIFO").unwrap(),
1343            BookingMethod::Lifo
1344        );
1345        assert_eq!(
1346            BookingMethod::from_str("Hifo").unwrap(),
1347            BookingMethod::Hifo
1348        );
1349        assert_eq!(
1350            BookingMethod::from_str("AVERAGE").unwrap(),
1351            BookingMethod::Average
1352        );
1353        assert_eq!(
1354            BookingMethod::from_str("NONE").unwrap(),
1355            BookingMethod::None
1356        );
1357        assert_eq!(
1358            BookingMethod::from_str("strict_with_size").unwrap(),
1359            BookingMethod::StrictWithSize
1360        );
1361        assert!(BookingMethod::from_str("INVALID").is_err());
1362    }
1363
1364    #[test]
1365    fn test_booking_method_display() {
1366        assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
1367        assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
1368        assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
1369        assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
1370        assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
1371        assert_eq!(format!("{}", BookingMethod::None), "NONE");
1372        assert_eq!(
1373            format!("{}", BookingMethod::StrictWithSize),
1374            "STRICT_WITH_SIZE"
1375        );
1376    }
1377
1378    #[test]
1379    fn test_booking_error_display() {
1380        let err = BookingError::AmbiguousMatch {
1381            num_matches: 3,
1382            currency: "AAPL".into(),
1383        };
1384        assert!(format!("{err}").contains("3 lots match"));
1385
1386        let err = BookingError::NoMatchingLot {
1387            currency: "AAPL".into(),
1388            cost_spec: CostSpec::empty(),
1389        };
1390        assert!(format!("{err}").contains("No matching lot"));
1391
1392        let err = BookingError::InsufficientUnits {
1393            currency: "AAPL".into(),
1394            requested: dec!(100),
1395            available: dec!(50),
1396        };
1397        assert!(format!("{err}").contains("requested 100"));
1398        assert!(format!("{err}").contains("available 50"));
1399
1400        let err = BookingError::CurrencyMismatch {
1401            expected: "USD".into(),
1402            got: "EUR".into(),
1403        };
1404        assert!(format!("{err}").contains("expected USD"));
1405        assert!(format!("{err}").contains("got EUR"));
1406    }
1407
1408    #[test]
1409    fn test_book_value_multiple_currencies() {
1410        let mut inv = Inventory::new();
1411
1412        // Cost in USD
1413        let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1414        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd));
1415
1416        // Cost in EUR
1417        let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
1418        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur));
1419
1420        let book = inv.book_value("AAPL");
1421        assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
1422        assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
1423    }
1424
1425    #[test]
1426    fn test_reduce_hifo_insufficient_units() {
1427        let mut inv = Inventory::new();
1428
1429        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1430        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1431
1432        let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
1433
1434        assert!(matches!(
1435            result,
1436            Err(BookingError::InsufficientUnits { .. })
1437        ));
1438    }
1439
1440    #[test]
1441    fn test_reduce_average_insufficient_units() {
1442        let mut inv = Inventory::new();
1443
1444        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1445        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1446
1447        let result = inv.reduce(
1448            &Amount::new(dec!(-20), "AAPL"),
1449            None,
1450            BookingMethod::Average,
1451        );
1452
1453        assert!(matches!(
1454            result,
1455            Err(BookingError::InsufficientUnits { .. })
1456        ));
1457    }
1458
1459    #[test]
1460    fn test_reduce_average_empty_inventory() {
1461        let mut inv = Inventory::new();
1462
1463        let result = inv.reduce(
1464            &Amount::new(dec!(-10), "AAPL"),
1465            None,
1466            BookingMethod::Average,
1467        );
1468
1469        assert!(matches!(
1470            result,
1471            Err(BookingError::InsufficientUnits { .. })
1472        ));
1473    }
1474
1475    #[test]
1476    fn test_reduce_merge_operator() {
1477        // {*} merge: two lots merged into weighted-average, then reduced
1478        let mut inv = Inventory::new();
1479        inv.add(Position::with_cost(
1480            Amount::new(dec!(10), "AAPL"),
1481            Cost::new(dec!(150), "USD"),
1482        ));
1483        inv.add(Position::with_cost(
1484            Amount::new(dec!(10), "AAPL"),
1485            Cost::new(dec!(160), "USD"),
1486        ));
1487
1488        let merge_spec = CostSpec::empty().with_merge();
1489        let result = inv
1490            .reduce(
1491                &Amount::new(dec!(-5), "AAPL"),
1492                Some(&merge_spec),
1493                BookingMethod::Strict,
1494            )
1495            .expect("merge reduction should succeed");
1496
1497        // Cost basis: 5 units * 155 USD average = 775 USD
1498        assert_eq!(result.cost_basis, Some(Amount::new(dec!(775), "USD")));
1499
1500        // Inventory should have a single merged lot with 15 remaining @ 155
1501        assert_eq!(inv.positions.len(), 1);
1502        assert_eq!(inv.positions[0].units.number, dec!(15));
1503        let cost = inv.positions[0].cost.as_ref().expect("should have cost");
1504        assert_eq!(cost.number, dec!(155));
1505    }
1506
1507    #[test]
1508    fn test_reduce_merge_insufficient_units() {
1509        let mut inv = Inventory::new();
1510        inv.add(Position::with_cost(
1511            Amount::new(dec!(10), "AAPL"),
1512            Cost::new(dec!(150), "USD"),
1513        ));
1514
1515        let merge_spec = CostSpec::empty().with_merge();
1516        let result = inv.reduce(
1517            &Amount::new(dec!(-20), "AAPL"),
1518            Some(&merge_spec),
1519            BookingMethod::Strict,
1520        );
1521
1522        assert!(matches!(
1523            result,
1524            Err(BookingError::InsufficientUnits { .. })
1525        ));
1526    }
1527
1528    #[test]
1529    fn test_reduce_merge_sells_all() {
1530        // Merge and sell entire position
1531        let mut inv = Inventory::new();
1532        inv.add(Position::with_cost(
1533            Amount::new(dec!(10), "AAPL"),
1534            Cost::new(dec!(150), "USD"),
1535        ));
1536        inv.add(Position::with_cost(
1537            Amount::new(dec!(10), "AAPL"),
1538            Cost::new(dec!(160), "USD"),
1539        ));
1540
1541        let merge_spec = CostSpec::empty().with_merge();
1542        let result = inv
1543            .reduce(
1544                &Amount::new(dec!(-20), "AAPL"),
1545                Some(&merge_spec),
1546                BookingMethod::Strict,
1547            )
1548            .expect("merge reduction should succeed");
1549
1550        // Cost basis: 20 * 155 = 3100 USD
1551        assert_eq!(result.cost_basis, Some(Amount::new(dec!(3100), "USD")));
1552
1553        // Inventory should be empty
1554        assert!(inv.positions.is_empty() || inv.positions.iter().all(Position::is_empty));
1555    }
1556
1557    #[test]
1558    fn test_reduce_merge_single_lot() {
1559        // {*} with a single lot should work trivially
1560        let mut inv = Inventory::new();
1561        inv.add(Position::with_cost(
1562            Amount::new(dec!(10), "AAPL"),
1563            Cost::new(dec!(150), "USD"),
1564        ));
1565
1566        let merge_spec = CostSpec::empty().with_merge();
1567        let result = inv
1568            .reduce(
1569                &Amount::new(dec!(-3), "AAPL"),
1570                Some(&merge_spec),
1571                BookingMethod::Strict,
1572            )
1573            .expect("single-lot merge should succeed");
1574
1575        assert_eq!(result.cost_basis, Some(Amount::new(dec!(450), "USD")));
1576        assert_eq!(inv.positions.len(), 1);
1577        assert_eq!(inv.positions[0].units.number, dec!(7));
1578    }
1579
1580    #[test]
1581    fn test_reduce_merge_three_lots() {
1582        // {*} with three lots at different costs
1583        let mut inv = Inventory::new();
1584        inv.add(Position::with_cost(
1585            Amount::new(dec!(10), "AAPL"),
1586            Cost::new(dec!(100), "USD"),
1587        ));
1588        inv.add(Position::with_cost(
1589            Amount::new(dec!(10), "AAPL"),
1590            Cost::new(dec!(150), "USD"),
1591        ));
1592        inv.add(Position::with_cost(
1593            Amount::new(dec!(10), "AAPL"),
1594            Cost::new(dec!(200), "USD"),
1595        ));
1596
1597        // Average cost: (1000 + 1500 + 2000) / 30 = 150 USD
1598        let merge_spec = CostSpec::empty().with_merge();
1599        let result = inv
1600            .reduce(
1601                &Amount::new(dec!(-6), "AAPL"),
1602                Some(&merge_spec),
1603                BookingMethod::Strict,
1604            )
1605            .expect("three-lot merge should succeed");
1606
1607        assert_eq!(result.cost_basis, Some(Amount::new(dec!(900), "USD")));
1608        assert_eq!(inv.positions.len(), 1);
1609        assert_eq!(inv.positions[0].units.number, dec!(24));
1610        let cost = inv.positions[0].cost.as_ref().expect("should have cost");
1611        assert_eq!(cost.number, dec!(150));
1612    }
1613
1614    #[test]
1615    fn test_reduce_merge_mixed_cost_currencies_errors() {
1616        // Lots with different cost currencies cannot be merged
1617        let mut inv = Inventory::new();
1618        inv.add(Position::with_cost(
1619            Amount::new(dec!(10), "AAPL"),
1620            Cost::new(dec!(150), "USD"),
1621        ));
1622        inv.add(Position::with_cost(
1623            Amount::new(dec!(10), "AAPL"),
1624            Cost::new(dec!(130), "EUR"),
1625        ));
1626
1627        let merge_spec = CostSpec::empty().with_merge();
1628        let result = inv.reduce(
1629            &Amount::new(dec!(-5), "AAPL"),
1630            Some(&merge_spec),
1631            BookingMethod::Strict,
1632        );
1633
1634        assert!(
1635            matches!(result, Err(BookingError::CurrencyMismatch { .. })),
1636            "expected CurrencyMismatch, got {result:?}"
1637        );
1638    }
1639
1640    #[test]
1641    fn test_reduce_merge_empty_inventory() {
1642        let mut inv = Inventory::new();
1643
1644        let merge_spec = CostSpec::empty().with_merge();
1645        let result = inv.reduce(
1646            &Amount::new(dec!(-5), "AAPL"),
1647            Some(&merge_spec),
1648            BookingMethod::Strict,
1649        );
1650
1651        assert!(matches!(
1652            result,
1653            Err(BookingError::InsufficientUnits { .. })
1654        ));
1655    }
1656
1657    #[test]
1658    fn test_inventory_display_sorted() {
1659        let mut inv = Inventory::new();
1660
1661        // Add in non-alphabetical order
1662        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1663        inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
1664        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
1665
1666        let display = format!("{inv}");
1667
1668        // Should be sorted alphabetically: AAPL, EUR, USD
1669        let aapl_pos = display.find("AAPL").unwrap();
1670        let eur_pos = display.find("EUR").unwrap();
1671        let usd_pos = display.find("USD").unwrap();
1672
1673        assert!(aapl_pos < eur_pos);
1674        assert!(eur_pos < usd_pos);
1675    }
1676
1677    #[test]
1678    fn test_inventory_with_cost_display_sorted() {
1679        let mut inv = Inventory::new();
1680
1681        // Add same currency with different costs
1682        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
1683        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1684
1685        inv.add(Position::with_cost(
1686            Amount::new(dec!(10), "AAPL"),
1687            cost_high,
1688        ));
1689        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1690
1691        let display = format!("{inv}");
1692
1693        // Both positions should be in the output
1694        assert!(display.contains("AAPL"));
1695        assert!(display.contains("100"));
1696        assert!(display.contains("200"));
1697    }
1698
1699    #[test]
1700    fn test_reduce_hifo_no_matching_lot() {
1701        let mut inv = Inventory::new();
1702
1703        // No AAPL positions
1704        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1705
1706        let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
1707
1708        assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1709    }
1710
1711    #[test]
1712    fn test_fifo_respects_dates() {
1713        // Ensure FIFO uses acquisition date, not insertion order
1714        let mut inv = Inventory::new();
1715
1716        // Add newer lot first (out of order)
1717        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1718        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1719
1720        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1721        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1722
1723        // FIFO should reduce from oldest (cost 100) first
1724        let result = inv
1725            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
1726            .unwrap();
1727
1728        // Should use cost from oldest lot (100)
1729        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1730    }
1731
1732    #[test]
1733    fn test_lifo_respects_dates() {
1734        // Ensure LIFO uses acquisition date, not insertion order
1735        let mut inv = Inventory::new();
1736
1737        // Add older lot first
1738        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1739        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1740
1741        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1742        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1743
1744        // LIFO should reduce from newest (cost 200) first
1745        let result = inv
1746            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
1747            .unwrap();
1748
1749        // Should use cost from newest lot (200)
1750        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1751    }
1752
1753    // =========================================================================
1754    // Booking method coverage tests
1755    //
1756    // These tests cover gaps identified during the spring 2026 audit:
1757    // - STRICT_WITH_SIZE: cost spec + exact-size, multiple exact-size matches
1758    // - HIFO: multi-lot ordering, partial reduction, cost spec filtering
1759    // - AVERAGE: weighted average with different costs, partial reduction preserves cost
1760    // - NONE: with cost positions, short position reduction
1761    // =========================================================================
1762
1763    // --- STRICT_WITH_SIZE ---
1764
1765    #[test]
1766    fn test_strict_with_size_different_costs_exact_match() {
1767        // When lots have different costs but one matches the reduction size exactly,
1768        // STRICT_WITH_SIZE should pick that lot instead of raising AmbiguousMatch
1769        let mut inv = Inventory::new();
1770
1771        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1772        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1773
1774        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1775        inv.add(Position::with_cost(Amount::new(dec!(7), "AAPL"), cost2));
1776
1777        // Reduce exactly 7 - should match the 7-share lot at cost 200
1778        let result = inv
1779            .reduce(
1780                &Amount::new(dec!(-7), "AAPL"),
1781                None,
1782                BookingMethod::StrictWithSize,
1783            )
1784            .unwrap();
1785
1786        assert_eq!(inv.units("AAPL"), dec!(10));
1787        assert_eq!(result.cost_basis.unwrap().number, dec!(1400.00)); // 7 * 200
1788    }
1789
1790    #[test]
1791    fn test_strict_with_size_multiple_exact_matches_picks_oldest() {
1792        // When multiple lots have the exact same size, STRICT_WITH_SIZE should
1793        // pick the oldest one (first in index order)
1794        let mut inv = Inventory::new();
1795
1796        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1797        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 6, 1));
1798
1799        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost1));
1800        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1801
1802        // Both lots are size 5 — should pick the first (oldest) one
1803        let result = inv
1804            .reduce(
1805                &Amount::new(dec!(-5), "AAPL"),
1806                None,
1807                BookingMethod::StrictWithSize,
1808            )
1809            .unwrap();
1810
1811        assert_eq!(inv.units("AAPL"), dec!(5));
1812        // Should use cost from the oldest lot (100)
1813        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1814    }
1815
1816    #[test]
1817    fn test_strict_with_size_with_cost_spec() {
1818        // Cost spec should filter lots before exact-size matching
1819        let mut inv = Inventory::new();
1820
1821        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1822        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1823
1824        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1825        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1826
1827        // With cost spec filtering to the 200 USD lot, should find unique match
1828        let spec = CostSpec::empty().with_number_per(dec!(200.00));
1829        let result = inv
1830            .reduce(
1831                &Amount::new(dec!(-5), "AAPL"),
1832                Some(&spec),
1833                BookingMethod::StrictWithSize,
1834            )
1835            .unwrap();
1836
1837        assert_eq!(inv.units("AAPL"), dec!(15));
1838        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
1839    }
1840
1841    // --- HIFO ---
1842
1843    #[test]
1844    fn test_hifo_reduces_highest_cost_first() {
1845        // HIFO should reduce the highest-cost lot first, regardless of date
1846        let mut inv = Inventory::new();
1847
1848        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1849        let cost_mid = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1850        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1851
1852        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
1853        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
1854        inv.add(Position::with_cost(
1855            Amount::new(dec!(10), "AAPL"),
1856            cost_high,
1857        ));
1858
1859        // Reduce 5 — should come from highest cost lot (200)
1860        let result = inv
1861            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Hifo)
1862            .unwrap();
1863
1864        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
1865        assert_eq!(inv.units("AAPL"), dec!(25));
1866    }
1867
1868    #[test]
1869    fn test_hifo_spans_multiple_lots() {
1870        // When reducing more than the highest-cost lot holds, HIFO should
1871        // continue to the next highest
1872        let mut inv = Inventory::new();
1873
1874        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1875        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1876
1877        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1878        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_high));
1879
1880        // Reduce 8: 5 from high (200) + 3 from low (100)
1881        let result = inv
1882            .reduce(&Amount::new(dec!(-8), "AAPL"), None, BookingMethod::Hifo)
1883            .unwrap();
1884
1885        // Cost basis: 5*200 + 3*100 = 1300
1886        assert_eq!(result.cost_basis.unwrap().number, dec!(1300.00));
1887        assert_eq!(inv.units("AAPL"), dec!(2));
1888    }
1889
1890    #[test]
1891    fn test_hifo_with_cost_spec_filter() {
1892        // Cost spec should filter lots before HIFO ordering
1893        let mut inv = Inventory::new();
1894
1895        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1896        let cost2 = Cost::new(dec!(200.00), "EUR").with_date(date(2024, 2, 1));
1897
1898        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1899        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1900
1901        // Filter to USD lots only
1902        let spec = CostSpec::empty().with_currency("USD");
1903        let result = inv
1904            .reduce(
1905                &Amount::new(dec!(-5), "AAPL"),
1906                Some(&spec),
1907                BookingMethod::Hifo,
1908            )
1909            .unwrap();
1910
1911        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); // 5 * 100 USD
1912    }
1913
1914    #[test]
1915    fn test_hifo_short_position() {
1916        // HIFO with short positions: covering shorts should work correctly
1917        let mut inv = Inventory::new();
1918
1919        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1920        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1921
1922        // Short positions (negative units)
1923        inv.add(Position::with_cost(
1924            Amount::new(dec!(-10), "AAPL"),
1925            cost_low,
1926        ));
1927        inv.add(Position::with_cost(
1928            Amount::new(dec!(-10), "AAPL"),
1929            cost_high,
1930        ));
1931
1932        // Cover 5 shares (positive = reduce short position)
1933        // HIFO should pick the highest-cost short lot (200)
1934        let result = inv
1935            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Hifo)
1936            .unwrap();
1937
1938        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
1939        assert_eq!(inv.units("AAPL"), dec!(-15));
1940    }
1941
1942    // --- AVERAGE ---
1943
1944    #[test]
1945    fn test_average_weighted_cost() {
1946        // AVERAGE should compute weighted average across lots with different costs
1947        let mut inv = Inventory::new();
1948
1949        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1950        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1951
1952        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1953        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1954
1955        // Average cost = (10*100 + 10*200) / 20 = 150
1956        let result = inv
1957            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
1958            .unwrap();
1959
1960        // Cost basis: 5 * 150 = 750
1961        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
1962        assert_eq!(inv.units("AAPL"), dec!(15));
1963    }
1964
1965    #[test]
1966    fn test_average_merges_into_single_position() {
1967        // After AVERAGE reduction, inventory should have a single simple position
1968        let mut inv = Inventory::new();
1969
1970        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1971        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1972
1973        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1974        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1975
1976        inv.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
1977            .unwrap();
1978
1979        // Should have exactly one AAPL position remaining
1980        let aapl_positions: Vec<_> = inv
1981            .positions
1982            .iter()
1983            .filter(|p| p.units.currency.as_ref() == "AAPL")
1984            .collect();
1985        assert_eq!(aapl_positions.len(), 1);
1986        assert_eq!(aapl_positions[0].units.number, dec!(15));
1987    }
1988
1989    #[test]
1990    fn test_average_uneven_lots() {
1991        // Weighted average with unequal lot sizes
1992        let mut inv = Inventory::new();
1993
1994        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1995        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1996
1997        inv.add(Position::with_cost(Amount::new(dec!(30), "AAPL"), cost1));
1998        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1999
2000        // Average cost = (30*100 + 10*200) / 40 = 5000/40 = 125
2001        let result = inv
2002            .reduce(
2003                &Amount::new(dec!(-10), "AAPL"),
2004                None,
2005                BookingMethod::Average,
2006            )
2007            .unwrap();
2008
2009        assert_eq!(result.cost_basis.unwrap().number, dec!(1250.00)); // 10 * 125
2010    }
2011
2012    // --- NONE ---
2013
2014    #[test]
2015    fn test_none_booking_with_cost_positions() {
2016        // NONE booking should work even when positions have costs
2017        let mut inv = Inventory::new();
2018
2019        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2020        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
2021
2022        let result = inv
2023            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::None)
2024            .unwrap();
2025
2026        assert_eq!(inv.units("AAPL"), dec!(5));
2027        // NONE delegates to reduce_ordered (FIFO) internally, so cost basis is computed
2028        assert!(result.cost_basis.is_some());
2029        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
2030    }
2031
2032    #[test]
2033    fn test_none_booking_short_cover() {
2034        // Covering a short position with NONE booking
2035        let mut inv = Inventory::new();
2036        inv.add(Position::simple(Amount::new(dec!(-100), "USD")));
2037
2038        // Positive amount should reduce the negative position
2039        let result = inv
2040            .reduce(&Amount::new(dec!(30), "USD"), None, BookingMethod::None)
2041            .unwrap();
2042
2043        assert_eq!(inv.units("USD"), dec!(-70));
2044        assert!(!result.matched.is_empty());
2045    }
2046
2047    #[test]
2048    fn test_none_booking_empty_inventory_augments() {
2049        // NONE booking on empty inventory should augment
2050        let mut inv = Inventory::new();
2051
2052        let result = inv
2053            .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
2054            .unwrap();
2055
2056        assert_eq!(inv.units("USD"), dec!(50));
2057        assert!(result.matched.is_empty()); // Augmentation, not reduction
2058    }
2059
2060    // --- Cross-method: short positions ---
2061
2062    #[test]
2063    fn test_fifo_short_position_cover() {
2064        // FIFO: cover short positions (oldest short first)
2065        let mut inv = Inventory::new();
2066
2067        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2068        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2069
2070        inv.add(Position::with_cost(
2071            Amount::new(dec!(-10), "AAPL"),
2072            cost_old,
2073        ));
2074        inv.add(Position::with_cost(
2075            Amount::new(dec!(-10), "AAPL"),
2076            cost_new,
2077        ));
2078
2079        // Cover 5 shares — FIFO should pick oldest short (cost 100)
2080        let result = inv
2081            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Fifo)
2082            .unwrap();
2083
2084        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); // 5 * 100
2085        assert_eq!(inv.units("AAPL"), dec!(-15));
2086    }
2087
2088    #[test]
2089    fn test_lifo_short_position_cover() {
2090        // LIFO: cover short positions (newest short first)
2091        let mut inv = Inventory::new();
2092
2093        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2094        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2095
2096        inv.add(Position::with_cost(
2097            Amount::new(dec!(-10), "AAPL"),
2098            cost_old,
2099        ));
2100        inv.add(Position::with_cost(
2101            Amount::new(dec!(-10), "AAPL"),
2102            cost_new,
2103        ));
2104
2105        // Cover 5 shares — LIFO should pick newest short (cost 200)
2106        let result = inv
2107            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Lifo)
2108            .unwrap();
2109
2110        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
2111        assert_eq!(inv.units("AAPL"), dec!(-15));
2112    }
2113
2114    // === AccountedBookingError Display tests ===
2115    //
2116    // These tests pin the canonical user-facing wording for every variant
2117    // of `AccountedBookingError`. The whole point of unifying booking-error
2118    // Display into `rustledger-core` (#750) is that there's a single source
2119    // of truth — and a single source of truth with no tests is one refactor
2120    // away from drifting again, which is exactly the failure mode that
2121    // produced #748. Any change to the Display strings below will break
2122    // these tests, forcing the author to consciously re-check pta-standards
2123    // conformance assertions and downstream user tooling.
2124
2125    #[test]
2126    fn test_accounted_error_display_insufficient_units() {
2127        let err = BookingError::InsufficientUnits {
2128            currency: "AAPL".into(),
2129            requested: dec!(15),
2130            available: dec!(10),
2131        }
2132        .with_account("Assets:Stock".into());
2133        let rendered = format!("{err}");
2134
2135        // Pinned by pta-standards `reduction-exceeds-inventory`
2136        // (`error_contains: ["not enough"]`). See #748 / #749.
2137        assert!(
2138            rendered.contains("not enough"),
2139            "must contain 'not enough' (pta-standards): {rendered}"
2140        );
2141        assert!(
2142            rendered.contains("Assets:Stock"),
2143            "must contain account name: {rendered}"
2144        );
2145        assert!(
2146            rendered.contains("15") && rendered.contains("10"),
2147            "must contain requested and available amounts: {rendered}"
2148        );
2149    }
2150
2151    #[test]
2152    fn test_accounted_error_display_no_matching_lot() {
2153        let err = BookingError::NoMatchingLot {
2154            currency: "AAPL".into(),
2155            cost_spec: CostSpec::empty(),
2156        }
2157        .with_account("Assets:Stock".into());
2158        let rendered = format!("{err}");
2159
2160        assert!(
2161            rendered.contains("No matching lot"),
2162            "must contain 'No matching lot': {rendered}"
2163        );
2164        assert!(
2165            rendered.contains("AAPL"),
2166            "must contain currency: {rendered}"
2167        );
2168        assert!(
2169            rendered.contains("Assets:Stock"),
2170            "must contain account name: {rendered}"
2171        );
2172    }
2173
2174    #[test]
2175    fn test_accounted_error_display_ambiguous_match() {
2176        let err = BookingError::AmbiguousMatch {
2177            num_matches: 3,
2178            currency: "AAPL".into(),
2179        }
2180        .with_account("Assets:Stock".into());
2181        let rendered = format!("{err}");
2182
2183        assert!(
2184            rendered.contains("Ambiguous"),
2185            "must contain 'Ambiguous': {rendered}"
2186        );
2187        assert!(
2188            rendered.contains("AAPL"),
2189            "must contain currency: {rendered}"
2190        );
2191        assert!(
2192            rendered.contains("Assets:Stock"),
2193            "must contain account name: {rendered}"
2194        );
2195        assert!(
2196            rendered.contains('3'),
2197            "must contain match count: {rendered}"
2198        );
2199    }
2200
2201    #[test]
2202    fn test_accounted_error_display_currency_mismatch_renders_as_no_matching_lot() {
2203        // CurrencyMismatch is semantically a specialization of NoMatchingLot
2204        // (there is no lot for the given currency in this inventory) and the
2205        // canonical Display collapses them into the same user-facing phrasing
2206        // so that consumers filtering on E4001 don't need to special-case it.
2207        // This variant is defensive — no `Inventory::reduce` path currently
2208        // emits it — but we still pin its rendering in case a future emission
2209        // site is added.
2210        let err = BookingError::CurrencyMismatch {
2211            expected: "USD".into(),
2212            got: "EUR".into(),
2213        }
2214        .with_account("Assets:Cash".into());
2215        let rendered = format!("{err}");
2216
2217        assert!(
2218            rendered.contains("No matching lot"),
2219            "CurrencyMismatch must render as 'No matching lot' for E4001 \
2220             consistency: {rendered}"
2221        );
2222        assert!(
2223            rendered.contains("EUR"),
2224            "must contain the mismatched (got) currency: {rendered}"
2225        );
2226        assert!(
2227            rendered.contains("Assets:Cash"),
2228            "must contain account name: {rendered}"
2229        );
2230    }
2231}