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/// Controls which positions are considered when checking whether incoming
76/// units reduce (i.e. have the opposite sign of) an existing inventory.
77///
78/// - [`AllPositions`](ReductionScope::AllPositions): every position is
79///   considered, regardless of whether it carries a cost.
80/// - [`CostBearingOnly`](ReductionScope::CostBearingOnly): only positions
81///   with a cost are considered.  This prevents a negative simple (no-cost)
82///   position — left behind by a sell-without-cost-spec — from causing a
83///   subsequent cost-bearing augmentation to be misclassified as a reduction.
84///   See: issue #875, beancount#889.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
86pub enum ReductionScope {
87    /// Consider all positions (cost-bearing and simple).
88    AllPositions,
89    /// Consider only positions that carry a cost.
90    CostBearingOnly,
91}
92
93/// Result of a booking operation.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub struct BookingResult {
96    /// Positions that were matched/reduced.
97    pub matched: Vec<Position>,
98    /// The cost basis of the matched positions (for capital gains).
99    pub cost_basis: Option<Amount>,
100}
101
102/// Error that can occur during booking.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum BookingError {
105    /// Multiple lots match but booking method requires unambiguous match.
106    AmbiguousMatch {
107        /// Number of lots that matched.
108        num_matches: usize,
109        /// The currency being reduced.
110        currency: InternedStr,
111    },
112    /// No lots match the cost specification.
113    NoMatchingLot {
114        /// The currency being reduced.
115        currency: InternedStr,
116        /// The cost spec that didn't match.
117        cost_spec: CostSpec,
118    },
119    /// Not enough units in matching lots.
120    InsufficientUnits {
121        /// The currency being reduced.
122        currency: InternedStr,
123        /// Units requested.
124        requested: Decimal,
125        /// Units available.
126        available: Decimal,
127    },
128    /// Currency mismatch between reduction and inventory.
129    CurrencyMismatch {
130        /// Expected currency.
131        expected: InternedStr,
132        /// Got currency.
133        got: InternedStr,
134    },
135}
136
137impl fmt::Display for BookingError {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        match self {
140            Self::AmbiguousMatch {
141                num_matches,
142                currency,
143            } => write!(
144                f,
145                "Ambiguous match: {num_matches} lots match for {currency}"
146            ),
147            Self::NoMatchingLot {
148                currency,
149                cost_spec,
150            } => {
151                write!(f, "No matching lot for {currency} with cost {cost_spec}")
152            }
153            Self::InsufficientUnits {
154                currency,
155                requested,
156                available,
157            } => write!(
158                f,
159                "Insufficient units of {currency}: requested {requested}, available {available}"
160            ),
161            Self::CurrencyMismatch { expected, got } => {
162                write!(f, "Currency mismatch: expected {expected}, got {got}")
163            }
164        }
165    }
166}
167
168impl std::error::Error for BookingError {}
169
170impl BookingError {
171    /// Wrap this booking error with the account context that produced it.
172    ///
173    /// `Inventory` itself doesn't know which account it belongs to, so the
174    /// raw `BookingError` carries no `account` field. The caller (booking
175    /// engine, validator) knows the account and uses this constructor to
176    /// produce the user-facing error.
177    ///
178    /// The resulting [`AccountedBookingError`] is the **single canonical
179    /// rendering** of an inventory failure for user-facing output. Both the
180    /// booking layer and the validator format errors via this type so the
181    /// wording cannot drift between them — the failure mode that produced
182    /// #748.
183    #[must_use]
184    pub const fn with_account(self, account: InternedStr) -> AccountedBookingError {
185        AccountedBookingError {
186            error: self,
187            account,
188        }
189    }
190}
191
192/// A [`BookingError`] paired with the account that produced it.
193///
194/// This is the canonical user-facing inventory error type. Its `Display`
195/// impl is the **single source of truth** for booking-error wording across
196/// `rustledger-booking` and `rustledger-validate`. Conformance assertions
197/// (e.g. pta-standards `reduction-exceeds-inventory` requires the literal
198/// substring `"not enough"`) are pinned by this Display.
199///
200/// Construct via [`BookingError::with_account`].
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct AccountedBookingError {
203    /// The underlying inventory-level error.
204    pub error: BookingError,
205    /// The account whose inventory produced the error.
206    pub account: InternedStr,
207}
208
209impl fmt::Display for AccountedBookingError {
210    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211        match &self.error {
212            BookingError::InsufficientUnits {
213                requested,
214                available,
215                ..
216            } => write!(
217                f,
218                "Not enough units in {}: requested {}, available {}; not enough to reduce",
219                self.account, requested, available
220            ),
221            BookingError::NoMatchingLot { currency, .. } => {
222                write!(f, "No matching lot for {} in {}", currency, self.account)
223            }
224            BookingError::AmbiguousMatch {
225                num_matches,
226                currency,
227            } => write!(
228                f,
229                "Ambiguous lot match for {}: {} lots match in {}",
230                currency, num_matches, self.account
231            ),
232            // Currency mismatch is semantically a specialization of
233            // NoMatchingLot (there is no lot for the given currency in this
234            // inventory), so we render and classify it the same way. Consumers
235            // filtering on E4001 don't need to special-case CurrencyMismatch.
236            //
237            // This variant is defensive: no `Inventory::reduce` path in
238            // `rustledger-core` currently emits it, but we still render it
239            // consistently in case a future emission site is added.
240            BookingError::CurrencyMismatch { got, .. } => {
241                write!(f, "No matching lot for {} in {}", got, self.account)
242            }
243        }
244    }
245}
246
247impl std::error::Error for AccountedBookingError {}
248
249/// An inventory is a collection of positions.
250///
251/// It tracks all positions for an account and supports booking operations
252/// for adding and reducing positions.
253///
254/// # Examples
255///
256/// ```
257/// use rustledger_core::{Inventory, Position, Amount, Cost, BookingMethod};
258/// use rust_decimal_macros::dec;
259///
260/// let mut inv = Inventory::new();
261///
262/// // Add a simple position
263/// inv.add(Position::simple(Amount::new(dec!(100), "USD")));
264/// assert_eq!(inv.units("USD"), dec!(100));
265///
266/// // Add a position with cost
267/// let cost = Cost::new(dec!(150.00), "USD");
268/// inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
269/// assert_eq!(inv.units("AAPL"), dec!(10));
270/// ```
271#[derive(Debug, Clone, Default, Serialize, Deserialize)]
272#[cfg_attr(
273    feature = "rkyv",
274    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
275)]
276pub struct Inventory {
277    positions: Vec<Position>,
278    /// Index for O(1) lookup of simple positions (no cost) by currency.
279    /// Maps currency to position index in the `positions` Vec.
280    /// Not serialized - rebuilt on demand.
281    #[serde(skip)]
282    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Skip))]
283    simple_index: FxHashMap<InternedStr, usize>,
284    /// Cache of total units per currency for O(1) `units()` lookups.
285    /// Updated incrementally on `add()` and `reduce()`.
286    /// Not serialized - rebuilt on demand.
287    #[serde(skip)]
288    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Skip))]
289    units_cache: FxHashMap<InternedStr, Decimal>,
290}
291
292impl PartialEq for Inventory {
293    fn eq(&self, other: &Self) -> bool {
294        // Only compare positions, not the index (which is derived data)
295        self.positions == other.positions
296    }
297}
298
299impl Eq for Inventory {}
300
301impl Inventory {
302    /// Create an empty inventory.
303    #[must_use]
304    pub fn new() -> Self {
305        Self::default()
306    }
307
308    /// Get all positions.
309    #[must_use]
310    pub fn positions(&self) -> &[Position] {
311        &self.positions
312    }
313
314    /// Get mutable access to all positions.
315    pub const fn positions_mut(&mut self) -> &mut Vec<Position> {
316        &mut self.positions
317    }
318
319    /// Check if inventory is empty.
320    #[must_use]
321    pub fn is_empty(&self) -> bool {
322        self.positions.is_empty()
323            || self
324                .positions
325                .iter()
326                .all(super::position::Position::is_empty)
327    }
328
329    /// Get the number of positions (including empty ones).
330    #[must_use]
331    pub const fn len(&self) -> usize {
332        self.positions.len()
333    }
334
335    /// Get total units of a currency (ignoring cost lots).
336    ///
337    /// This sums all positions of the given currency regardless of cost basis.
338    /// Uses an internal cache for O(1) lookups.
339    #[must_use]
340    pub fn units(&self, currency: &str) -> Decimal {
341        // Use cache if available, otherwise compute and the caller should
342        // ensure cache is built via rebuild_caches() after deserialization
343        self.units_cache.get(currency).copied().unwrap_or_else(|| {
344            // Fallback to computation if cache miss (e.g., after deserialization)
345            self.positions
346                .iter()
347                .filter(|p| p.units.currency == currency)
348                .map(|p| p.units.number)
349                .sum()
350        })
351    }
352
353    /// Get all currencies in this inventory.
354    #[must_use]
355    pub fn currencies(&self) -> Vec<&str> {
356        let mut currencies: Vec<&str> = self
357            .positions
358            .iter()
359            .filter(|p| !p.is_empty())
360            .map(|p| p.units.currency.as_str())
361            .collect();
362        currencies.sort_unstable();
363        currencies.dedup();
364        currencies
365    }
366
367    /// Check if the given units would reduce (not augment) this inventory.
368    ///
369    /// Returns `true` if there's a position with the same currency but opposite
370    /// sign, meaning these units would reduce the inventory rather than add to it.
371    ///
372    /// When `has_cost_spec` is `true`, only positions **with** a cost basis are
373    /// considered for reduction matching.  Simple (no-cost) positions are ignored
374    /// because they live in a different "cost layer" — a sell-without-cost-spec
375    /// that left a negative simple position should not cause a subsequent
376    /// cost-bearing augmentation to be misclassified as a reduction.
377    /// See: issue #875, beancount#889.
378    ///
379    /// This is used to determine whether a posting is a sale/reduction or a
380    /// purchase/augmentation.
381    #[must_use]
382    pub fn is_reduced_by(&self, units: &Amount, scope: ReductionScope) -> bool {
383        self.positions.iter().any(|pos| {
384            pos.units.currency == units.currency
385                && pos.units.number.is_sign_positive() != units.number.is_sign_positive()
386                && match scope {
387                    ReductionScope::AllPositions => true,
388                    ReductionScope::CostBearingOnly => pos.cost.is_some(),
389                }
390        })
391    }
392
393    /// Get the total book value (cost basis) for a currency.
394    ///
395    /// Returns the sum of all cost bases for positions of the given currency.
396    #[must_use]
397    pub fn book_value(&self, units_currency: &str) -> FxHashMap<InternedStr, Decimal> {
398        let mut totals: FxHashMap<InternedStr, Decimal> = FxHashMap::default();
399
400        for pos in &self.positions {
401            if pos.units.currency == units_currency
402                && let Some(book) = pos.book_value()
403            {
404                *totals.entry(book.currency.clone()).or_default() += book.number;
405            }
406        }
407
408        totals
409    }
410
411    /// Add a position to the inventory.
412    ///
413    /// For positions without cost, this merges with existing positions
414    /// of the same currency using O(1) `HashMap` lookup.
415    ///
416    /// For positions with cost, this adds as a new lot (O(1)).
417    /// Lot aggregation for display purposes is handled separately at output time
418    /// (e.g., in the query result formatter).
419    ///
420    /// # TLA+ Specification
421    ///
422    /// Implements `AddAmount` action from `Conservation.tla`:
423    /// - Invariant: `inventory + totalReduced = totalAdded`
424    /// - After add: `totalAdded' = totalAdded + amount`
425    ///
426    /// See: `spec/tla/Conservation.tla`
427    pub fn add(&mut self, position: Position) {
428        if position.is_empty() {
429            return;
430        }
431
432        // Update units cache
433        *self
434            .units_cache
435            .entry(position.units.currency.clone())
436            .or_default() += position.units.number;
437
438        // For positions without cost, use index for O(1) lookup
439        if position.cost.is_none() {
440            if let Some(&idx) = self.simple_index.get(&position.units.currency) {
441                // Merge with existing position
442                debug_assert!(self.positions[idx].cost.is_none());
443                self.positions[idx].units += &position.units;
444                return;
445            }
446            // No existing position - add new one and index it
447            let idx = self.positions.len();
448            self.simple_index
449                .insert(position.units.currency.clone(), idx);
450            self.positions.push(position);
451            return;
452        }
453
454        // For positions with cost, just add as a new lot.
455        // This is O(1) and keeps all lots separate, matching Python beancount behavior.
456        // Lot aggregation for display purposes is handled separately in query output.
457        self.positions.push(position);
458    }
459
460    /// Reduce positions from the inventory using the specified booking method.
461    ///
462    /// # Arguments
463    ///
464    /// * `units` - The units to reduce (negative for selling)
465    /// * `cost_spec` - Optional cost specification for matching lots
466    /// * `method` - The booking method to use
467    ///
468    /// # Returns
469    ///
470    /// Returns a `BookingResult` with the matched positions and cost basis,
471    /// or a `BookingError` if the reduction cannot be performed.
472    ///
473    /// # TLA+ Specification
474    ///
475    /// Implements `ReduceAmount` action from `Conservation.tla`:
476    /// - Invariant: `inventory + totalReduced = totalAdded`
477    /// - After reduce: `totalReduced' = totalReduced + amount`
478    /// - Precondition: `amount <= inventory` (else `InsufficientUnits` error)
479    ///
480    /// Lot selection follows these TLA+ specs based on `method`:
481    /// - `Fifo`: `FIFOCorrect.tla` - Oldest lots first (`selected_date <= all other dates`)
482    /// - `Lifo`: `LIFOCorrect.tla` - Newest lots first (`selected_date >= all other dates`)
483    /// - `Hifo`: `HIFOCorrect.tla` - Highest cost first (`selected_cost >= all other costs`)
484    ///
485    /// See: `spec/tla/Conservation.tla`, `spec/tla/FIFOCorrect.tla`, etc.
486    pub fn reduce(
487        &mut self,
488        units: &Amount,
489        cost_spec: Option<&CostSpec>,
490        method: BookingMethod,
491    ) -> Result<BookingResult, BookingError> {
492        let spec = cost_spec.cloned().unwrap_or_default();
493
494        // {*} merge operator: merge all lots into a single weighted-average-cost
495        // lot before reducing, regardless of the account's booking method.
496        if spec.merge {
497            return self.reduce_merge(units);
498        }
499
500        match method {
501            BookingMethod::Strict => self.reduce_strict(units, &spec),
502            BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
503            BookingMethod::Fifo => self.reduce_fifo(units, &spec),
504            BookingMethod::Lifo => self.reduce_lifo(units, &spec),
505            BookingMethod::Hifo => self.reduce_hifo(units, &spec),
506            BookingMethod::Average => self.reduce_average(units),
507            BookingMethod::None => self.reduce_none(units),
508        }
509    }
510
511    /// Remove all empty positions.
512    pub fn compact(&mut self) {
513        self.positions.retain(|p| !p.is_empty());
514        self.rebuild_index();
515    }
516
517    /// Rebuild all caches (`simple_index` and `units_cache`) from positions.
518    /// Called after operations that may invalidate caches (like retain or deserialization).
519    fn rebuild_index(&mut self) {
520        self.simple_index.clear();
521        self.units_cache.clear();
522
523        for (idx, pos) in self.positions.iter().enumerate() {
524            // Update units cache for all positions
525            *self
526                .units_cache
527                .entry(pos.units.currency.clone())
528                .or_default() += pos.units.number;
529
530            // Update simple_index only for positions without cost
531            if pos.cost.is_none() {
532                debug_assert!(
533                    !self.simple_index.contains_key(&pos.units.currency),
534                    "Invariant violated: multiple simple positions for currency {}",
535                    pos.units.currency
536                );
537                self.simple_index.insert(pos.units.currency.clone(), idx);
538            }
539        }
540    }
541
542    /// Merge this inventory with another.
543    pub fn merge(&mut self, other: &Self) {
544        for pos in &other.positions {
545            self.add(pos.clone());
546        }
547    }
548
549    /// Convert inventory to cost basis.
550    ///
551    /// Returns a new inventory where all positions are converted to their
552    /// cost basis. Positions without cost are returned as-is.
553    #[must_use]
554    pub fn at_cost(&self) -> Self {
555        let mut result = Self::new();
556
557        for pos in &self.positions {
558            if pos.is_empty() {
559                continue;
560            }
561
562            if let Some(cost) = &pos.cost {
563                // Convert to cost basis
564                let total = pos.units.number * cost.number;
565                result.add(Position::simple(Amount::new(total, &cost.currency)));
566            } else {
567                // No cost, keep as-is
568                result.add(pos.clone());
569            }
570        }
571
572        result
573    }
574
575    /// Convert inventory to units only.
576    ///
577    /// Returns a new inventory where all positions have their cost removed,
578    /// effectively aggregating by currency only.
579    #[must_use]
580    pub fn at_units(&self) -> Self {
581        let mut result = Self::new();
582
583        for pos in &self.positions {
584            if pos.is_empty() {
585                continue;
586            }
587
588            // Strip cost, keep only units
589            result.add(Position::simple(pos.units.clone()));
590        }
591
592        result
593    }
594}
595
596impl fmt::Display for Inventory {
597    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
598        if self.is_empty() {
599            return write!(f, "(empty)");
600        }
601
602        // Sort positions alphabetically by currency, then by cost for consistency
603        let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
604        non_empty.sort_by(|a, b| {
605            // First by currency
606            let cmp = a.units.currency.cmp(&b.units.currency);
607            if cmp != std::cmp::Ordering::Equal {
608                return cmp;
609            }
610            // Then by cost (if present)
611            match (&a.cost, &b.cost) {
612                (Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
613                (Some(_), None) => std::cmp::Ordering::Greater,
614                (None, Some(_)) => std::cmp::Ordering::Less,
615                (None, None) => std::cmp::Ordering::Equal,
616            }
617        });
618
619        for (i, pos) in non_empty.iter().enumerate() {
620            if i > 0 {
621                write!(f, ", ")?;
622            }
623            write!(f, "{pos}")?;
624        }
625        Ok(())
626    }
627}
628
629impl FromIterator<Position> for Inventory {
630    fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
631        let mut inv = Self::new();
632        for pos in iter {
633            inv.add(pos);
634        }
635        inv
636    }
637}
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642    use crate::Cost;
643    use crate::NaiveDate;
644    use rust_decimal_macros::dec;
645
646    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
647        crate::naive_date(year, month, day).unwrap()
648    }
649
650    #[test]
651    fn test_empty_inventory() {
652        let inv = Inventory::new();
653        assert!(inv.is_empty());
654        assert_eq!(inv.len(), 0);
655    }
656
657    #[test]
658    fn test_add_simple() {
659        let mut inv = Inventory::new();
660        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
661
662        assert!(!inv.is_empty());
663        assert_eq!(inv.units("USD"), dec!(100));
664    }
665
666    #[test]
667    fn test_add_merge_simple() {
668        let mut inv = Inventory::new();
669        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
670        inv.add(Position::simple(Amount::new(dec!(50), "USD")));
671
672        // Should merge into one position
673        assert_eq!(inv.len(), 1);
674        assert_eq!(inv.units("USD"), dec!(150));
675    }
676
677    #[test]
678    fn test_add_with_cost_no_merge() {
679        let mut inv = Inventory::new();
680
681        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
682        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
683
684        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
685        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
686
687        // Should NOT merge - different costs
688        assert_eq!(inv.len(), 2);
689        assert_eq!(inv.units("AAPL"), dec!(15));
690    }
691
692    #[test]
693    fn test_currencies() {
694        let mut inv = Inventory::new();
695        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
696        inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
697        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
698
699        let currencies = inv.currencies();
700        assert_eq!(currencies.len(), 3);
701        assert!(currencies.contains(&"USD"));
702        assert!(currencies.contains(&"EUR"));
703        assert!(currencies.contains(&"AAPL"));
704    }
705
706    #[test]
707    fn test_reduce_strict_unique() {
708        let mut inv = Inventory::new();
709        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
710        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
711
712        let result = inv
713            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
714            .unwrap();
715
716        assert_eq!(inv.units("AAPL"), dec!(5));
717        assert!(result.cost_basis.is_some());
718        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); // 5 * 150
719    }
720
721    #[test]
722    fn test_reduce_strict_multiple_match_with_different_costs_is_ambiguous() {
723        let mut inv = Inventory::new();
724
725        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
726        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
727
728        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
729        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
730
731        // Per Python beancount: a wildcard reduction (`-3 AAPL` with no cost
732        // spec) against an inventory with lots at different costs is
733        // genuinely ambiguous and must error. Issue #737.
734        let result = inv.reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict);
735
736        assert!(
737            matches!(result, Err(BookingError::AmbiguousMatch { .. })),
738            "expected AmbiguousMatch, got {result:?}"
739        );
740        // Inventory unchanged after a failed reduction
741        assert_eq!(inv.units("AAPL"), dec!(15));
742    }
743
744    #[test]
745    fn test_reduce_strict_multiple_match_with_identical_costs_uses_fifo() {
746        let mut inv = Inventory::new();
747
748        // Two lots with identical cost — interchangeable, so FIFO is fine.
749        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
750
751        inv.add(Position::with_cost(
752            Amount::new(dec!(10), "AAPL"),
753            cost.clone(),
754        ));
755        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
756
757        let result = inv
758            .reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
759            .expect("identical lots should fall back to FIFO without error");
760
761        assert_eq!(inv.units("AAPL"), dec!(12));
762        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00));
763    }
764
765    #[test]
766    fn test_reduce_strict_multiple_match_different_dates_same_cost_uses_fifo() {
767        let mut inv = Inventory::new();
768
769        // Two lots at the same cost number but different acquisition dates.
770        // The user's cost spec could not have constrained the date without
771        // naming it, so the lots are interchangeable for the spec — FIFO.
772        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
773        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 15));
774
775        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
776        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
777
778        let result = inv
779            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
780            .expect("same cost number, different dates should fall back to FIFO");
781
782        assert_eq!(inv.units("AAPL"), dec!(15));
783        // Reduced from the first (oldest) lot at 150.00 USD: 5 * 150 = 750.
784        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
785    }
786
787    #[test]
788    fn test_reduce_strict_multiple_match_total_match_exception() {
789        let mut inv = Inventory::new();
790
791        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
792        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
793
794        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
795        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
796
797        // Selling exactly the entire inventory (10 + 5 = 15) is unambiguous
798        // even with mixed costs — the user is liquidating the position.
799        let result = inv
800            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Strict)
801            .expect("total-match exception should accept a full liquidation");
802
803        assert_eq!(inv.units("AAPL"), dec!(0));
804        // Cost basis = 10*150 + 5*160 = 1500 + 800 = 2300
805        assert_eq!(result.cost_basis.unwrap().number, dec!(2300.00));
806    }
807
808    #[test]
809    fn test_reduce_strict_with_spec() {
810        let mut inv = Inventory::new();
811
812        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
813        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
814
815        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
816        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
817
818        // Reducing with cost spec should work
819        let spec = CostSpec::empty().with_date(date(2024, 1, 1));
820        let result = inv
821            .reduce(
822                &Amount::new(dec!(-3), "AAPL"),
823                Some(&spec),
824                BookingMethod::Strict,
825            )
826            .unwrap();
827
828        assert_eq!(inv.units("AAPL"), dec!(12)); // 7 + 5
829        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); // 3 * 150
830    }
831
832    #[test]
833    fn test_reduce_fifo() {
834        let mut inv = Inventory::new();
835
836        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
837        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
838        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
839
840        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
841        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
842        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
843
844        // FIFO should reduce from oldest (cost 100) first
845        let result = inv
846            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
847            .unwrap();
848
849        assert_eq!(inv.units("AAPL"), dec!(15));
850        // Cost basis: 10 * 100 + 5 * 150 = 1000 + 750 = 1750
851        assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
852    }
853
854    #[test]
855    fn test_reduce_lifo() {
856        let mut inv = Inventory::new();
857
858        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
859        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
860        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
861
862        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
863        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
864        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
865
866        // LIFO should reduce from newest (cost 200) first
867        let result = inv
868            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
869            .unwrap();
870
871        assert_eq!(inv.units("AAPL"), dec!(15));
872        // Cost basis: 10 * 200 + 5 * 150 = 2000 + 750 = 2750
873        assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
874    }
875
876    #[test]
877    fn test_reduce_insufficient() {
878        let mut inv = Inventory::new();
879        let cost = Cost::new(dec!(150.00), "USD");
880        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
881
882        let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
883
884        assert!(matches!(
885            result,
886            Err(BookingError::InsufficientUnits { .. })
887        ));
888    }
889
890    #[test]
891    fn test_book_value() {
892        let mut inv = Inventory::new();
893
894        let cost1 = Cost::new(dec!(100.00), "USD");
895        let cost2 = Cost::new(dec!(150.00), "USD");
896
897        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
898        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
899
900        let book = inv.book_value("AAPL");
901        assert_eq!(book.get("USD"), Some(&dec!(1750.00))); // 10*100 + 5*150
902    }
903
904    #[test]
905    fn test_display() {
906        let mut inv = Inventory::new();
907        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
908
909        let s = format!("{inv}");
910        assert!(s.contains("100 USD"));
911    }
912
913    #[test]
914    fn test_display_empty() {
915        let inv = Inventory::new();
916        assert_eq!(format!("{inv}"), "(empty)");
917    }
918
919    #[test]
920    fn test_from_iterator() {
921        let positions = vec![
922            Position::simple(Amount::new(dec!(100), "USD")),
923            Position::simple(Amount::new(dec!(50), "USD")),
924        ];
925
926        let inv: Inventory = positions.into_iter().collect();
927        assert_eq!(inv.units("USD"), dec!(150));
928    }
929
930    #[test]
931    fn test_add_costed_positions_kept_separate() {
932        // Costed positions are kept as separate lots for O(1) add performance.
933        // Aggregation happens at display time (in query output).
934        let mut inv = Inventory::new();
935
936        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
937
938        // Buy 10 shares
939        inv.add(Position::with_cost(
940            Amount::new(dec!(10), "AAPL"),
941            cost.clone(),
942        ));
943        assert_eq!(inv.len(), 1);
944        assert_eq!(inv.units("AAPL"), dec!(10));
945
946        // Sell 10 shares - kept as separate lot for tracking
947        inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
948        assert_eq!(inv.len(), 2); // Both lots kept
949        assert_eq!(inv.units("AAPL"), dec!(0)); // Net units still zero
950    }
951
952    #[test]
953    fn test_add_costed_positions_net_units() {
954        // Verify that units() correctly sums across all lots
955        let mut inv = Inventory::new();
956
957        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
958
959        // Buy 10 shares
960        inv.add(Position::with_cost(
961            Amount::new(dec!(10), "AAPL"),
962            cost.clone(),
963        ));
964
965        // Sell 3 shares - kept as separate lot
966        inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost));
967        assert_eq!(inv.len(), 2); // Both lots kept
968        assert_eq!(inv.units("AAPL"), dec!(7)); // Net units correct
969    }
970
971    #[test]
972    fn test_add_no_cancel_different_cost() {
973        // Test that different costs don't cancel
974        let mut inv = Inventory::new();
975
976        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
977        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
978
979        // Buy 10 shares at 150
980        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
981
982        // Sell 5 shares at 160 - should NOT cancel (different cost)
983        inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2));
984
985        // Should have two separate lots
986        assert_eq!(inv.len(), 2);
987        assert_eq!(inv.units("AAPL"), dec!(5)); // 10 - 5 = 5 total
988    }
989
990    #[test]
991    fn test_add_no_cancel_same_sign() {
992        // Test that same-sign positions don't merge even with same cost
993        let mut inv = Inventory::new();
994
995        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
996
997        // Buy 10 shares
998        inv.add(Position::with_cost(
999            Amount::new(dec!(10), "AAPL"),
1000            cost.clone(),
1001        ));
1002
1003        // Buy 5 more shares with same cost - should NOT merge
1004        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
1005
1006        // Should have two separate lots (different acquisitions)
1007        assert_eq!(inv.len(), 2);
1008        assert_eq!(inv.units("AAPL"), dec!(15));
1009    }
1010
1011    #[test]
1012    fn test_merge_keeps_lots_separate() {
1013        // Test that merge keeps costed lots separate (aggregation at display time)
1014        let mut inv1 = Inventory::new();
1015        let mut inv2 = Inventory::new();
1016
1017        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
1018
1019        // inv1: buy 10 shares
1020        inv1.add(Position::with_cost(
1021            Amount::new(dec!(10), "AAPL"),
1022            cost.clone(),
1023        ));
1024
1025        // inv2: sell 10 shares
1026        inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1027
1028        // Merge keeps both lots, net units is zero
1029        inv1.merge(&inv2);
1030        assert_eq!(inv1.len(), 2); // Both lots preserved
1031        assert_eq!(inv1.units("AAPL"), dec!(0)); // Net units correct
1032    }
1033
1034    // ====================================================================
1035    // Phase 2: Additional Coverage Tests for Booking Methods
1036    // ====================================================================
1037
1038    #[test]
1039    fn test_hifo_with_tie_breaking() {
1040        // When multiple lots have the same cost, HIFO should use insertion order
1041        let mut inv = Inventory::new();
1042
1043        // Three lots with same cost but different dates
1044        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1045        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1046        let cost3 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 3, 1));
1047
1048        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1049        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1050        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1051
1052        // HIFO with tied costs should reduce in some deterministic order
1053        let result = inv
1054            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
1055            .unwrap();
1056
1057        assert_eq!(inv.units("AAPL"), dec!(15));
1058        // All at same cost, so 15 * 100 = 1500
1059        assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1060    }
1061
1062    #[test]
1063    fn test_hifo_with_different_costs() {
1064        // HIFO should reduce highest cost lots first
1065        let mut inv = Inventory::new();
1066
1067        let cost_low = Cost::new(dec!(50.00), "USD").with_date(date(2024, 1, 1));
1068        let cost_mid = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1069        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1070
1071        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
1072        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
1073        inv.add(Position::with_cost(
1074            Amount::new(dec!(10), "AAPL"),
1075            cost_high,
1076        ));
1077
1078        // Reduce 15 shares - should take from highest cost (200) first
1079        let result = inv
1080            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
1081            .unwrap();
1082
1083        assert_eq!(inv.units("AAPL"), dec!(15));
1084        // 10 * 200 + 5 * 100 = 2000 + 500 = 2500
1085        assert_eq!(result.cost_basis.unwrap().number, dec!(2500.00));
1086    }
1087
1088    #[test]
1089    fn test_average_booking_with_pre_existing_positions() {
1090        let mut inv = Inventory::new();
1091
1092        // Add two lots with different costs
1093        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1094        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1095
1096        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1097        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1098
1099        // Total: 20 shares, total cost = 10*100 + 10*200 = 3000, avg = 150/share
1100        // Reduce 5 shares using AVERAGE
1101        let result = inv
1102            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
1103            .unwrap();
1104
1105        assert_eq!(inv.units("AAPL"), dec!(15));
1106        // Cost basis for 5 shares at average 150 = 750
1107        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
1108    }
1109
1110    #[test]
1111    fn test_average_booking_reduces_all() {
1112        let mut inv = Inventory::new();
1113
1114        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1115        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1116
1117        // Reduce all shares
1118        let result = inv
1119            .reduce(
1120                &Amount::new(dec!(-10), "AAPL"),
1121                None,
1122                BookingMethod::Average,
1123            )
1124            .unwrap();
1125
1126        assert!(inv.is_empty() || inv.units("AAPL").is_zero());
1127        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1128    }
1129
1130    #[test]
1131    fn test_none_booking_augmentation() {
1132        // NONE booking with same-sign amounts should augment, not reduce
1133        let mut inv = Inventory::new();
1134        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1135
1136        // Adding more (same sign) - this is an augmentation
1137        let result = inv
1138            .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
1139            .unwrap();
1140
1141        assert_eq!(inv.units("USD"), dec!(150));
1142        assert!(result.matched.is_empty()); // No lots matched for augmentation
1143        assert!(result.cost_basis.is_none());
1144    }
1145
1146    #[test]
1147    fn test_none_booking_reduction() {
1148        // NONE booking with opposite-sign should reduce
1149        let mut inv = Inventory::new();
1150        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1151
1152        let result = inv
1153            .reduce(&Amount::new(dec!(-30), "USD"), None, BookingMethod::None)
1154            .unwrap();
1155
1156        assert_eq!(inv.units("USD"), dec!(70));
1157        assert!(!result.matched.is_empty());
1158    }
1159
1160    #[test]
1161    fn test_none_booking_insufficient() {
1162        let mut inv = Inventory::new();
1163        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1164
1165        let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
1166
1167        assert!(matches!(
1168            result,
1169            Err(BookingError::InsufficientUnits { .. })
1170        ));
1171    }
1172
1173    #[test]
1174    fn test_booking_error_no_matching_lot() {
1175        let mut inv = Inventory::new();
1176
1177        // Add a lot with specific cost
1178        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1179        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1180
1181        // Try to reduce with a cost spec that doesn't match
1182        let wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
1183        let result = inv.reduce(
1184            &Amount::new(dec!(-5), "AAPL"),
1185            Some(&wrong_spec),
1186            BookingMethod::Strict,
1187        );
1188
1189        assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1190    }
1191
1192    #[test]
1193    fn test_booking_error_insufficient_units() {
1194        let mut inv = Inventory::new();
1195
1196        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1197        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1198
1199        // Try to reduce more than available
1200        let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
1201
1202        match result {
1203            Err(BookingError::InsufficientUnits {
1204                requested,
1205                available,
1206                ..
1207            }) => {
1208                assert_eq!(requested, dec!(20));
1209                assert_eq!(available, dec!(10));
1210            }
1211            _ => panic!("Expected InsufficientUnits error"),
1212        }
1213    }
1214
1215    #[test]
1216    fn test_strict_with_size_exact_match() {
1217        let mut inv = Inventory::new();
1218
1219        // Add two lots with same cost but different sizes
1220        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1221        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1222
1223        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1224        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1225
1226        // Reduce exactly 5 - should match the 5-share lot
1227        let result = inv
1228            .reduce(
1229                &Amount::new(dec!(-5), "AAPL"),
1230                None,
1231                BookingMethod::StrictWithSize,
1232            )
1233            .unwrap();
1234
1235        assert_eq!(inv.units("AAPL"), dec!(10));
1236        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1237    }
1238
1239    #[test]
1240    fn test_strict_with_size_total_match() {
1241        let mut inv = Inventory::new();
1242
1243        // Add two lots
1244        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1245        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1246
1247        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1248        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1249
1250        // Reduce exactly 15 (total) - should succeed via total match exception
1251        let result = inv
1252            .reduce(
1253                &Amount::new(dec!(-15), "AAPL"),
1254                None,
1255                BookingMethod::StrictWithSize,
1256            )
1257            .unwrap();
1258
1259        assert_eq!(inv.units("AAPL"), dec!(0));
1260        assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1261    }
1262
1263    #[test]
1264    fn test_strict_with_size_ambiguous() {
1265        let mut inv = Inventory::new();
1266
1267        // Add two lots of same size and cost
1268        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1269        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1270
1271        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1272        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1273
1274        // Reduce 7 shares - doesn't match either lot exactly, not total
1275        let result = inv.reduce(
1276            &Amount::new(dec!(-7), "AAPL"),
1277            None,
1278            BookingMethod::StrictWithSize,
1279        );
1280
1281        assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
1282    }
1283
1284    #[test]
1285    fn test_short_position() {
1286        // Test short selling (negative positions)
1287        let mut inv = Inventory::new();
1288
1289        // Short 10 shares
1290        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1291        inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1292
1293        assert_eq!(inv.units("AAPL"), dec!(-10));
1294        assert!(!inv.is_empty());
1295    }
1296
1297    #[test]
1298    fn test_at_cost() {
1299        let mut inv = Inventory::new();
1300
1301        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1302        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1303
1304        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1305        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1306        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1307
1308        let at_cost = inv.at_cost();
1309
1310        // AAPL converted: 10*100 + 5*150 = 1000 + 750 = 1750 USD
1311        // Plus 100 USD simple position = 1850 USD total
1312        assert_eq!(at_cost.units("USD"), dec!(1850));
1313        assert_eq!(at_cost.units("AAPL"), dec!(0)); // No AAPL in cost view
1314    }
1315
1316    #[test]
1317    fn test_at_units() {
1318        let mut inv = Inventory::new();
1319
1320        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1321        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1322
1323        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1324        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1325
1326        let at_units = inv.at_units();
1327
1328        // All AAPL lots merged
1329        assert_eq!(at_units.units("AAPL"), dec!(15));
1330        // Should only have one position after aggregation
1331        assert_eq!(at_units.len(), 1);
1332    }
1333
1334    #[test]
1335    fn test_add_empty_position() {
1336        let mut inv = Inventory::new();
1337        inv.add(Position::simple(Amount::new(dec!(0), "USD")));
1338
1339        assert!(inv.is_empty());
1340        assert_eq!(inv.len(), 0);
1341    }
1342
1343    #[test]
1344    fn test_compact() {
1345        let mut inv = Inventory::new();
1346
1347        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1348        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1349
1350        // Reduce all
1351        inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
1352            .unwrap();
1353
1354        // Compact to remove empty positions
1355        inv.compact();
1356        assert!(inv.is_empty());
1357        assert_eq!(inv.len(), 0);
1358    }
1359
1360    #[test]
1361    fn test_booking_method_from_str() {
1362        assert_eq!(
1363            BookingMethod::from_str("STRICT").unwrap(),
1364            BookingMethod::Strict
1365        );
1366        assert_eq!(
1367            BookingMethod::from_str("fifo").unwrap(),
1368            BookingMethod::Fifo
1369        );
1370        assert_eq!(
1371            BookingMethod::from_str("LIFO").unwrap(),
1372            BookingMethod::Lifo
1373        );
1374        assert_eq!(
1375            BookingMethod::from_str("Hifo").unwrap(),
1376            BookingMethod::Hifo
1377        );
1378        assert_eq!(
1379            BookingMethod::from_str("AVERAGE").unwrap(),
1380            BookingMethod::Average
1381        );
1382        assert_eq!(
1383            BookingMethod::from_str("NONE").unwrap(),
1384            BookingMethod::None
1385        );
1386        assert_eq!(
1387            BookingMethod::from_str("strict_with_size").unwrap(),
1388            BookingMethod::StrictWithSize
1389        );
1390        assert!(BookingMethod::from_str("INVALID").is_err());
1391    }
1392
1393    #[test]
1394    fn test_booking_method_display() {
1395        assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
1396        assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
1397        assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
1398        assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
1399        assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
1400        assert_eq!(format!("{}", BookingMethod::None), "NONE");
1401        assert_eq!(
1402            format!("{}", BookingMethod::StrictWithSize),
1403            "STRICT_WITH_SIZE"
1404        );
1405    }
1406
1407    #[test]
1408    fn test_booking_error_display() {
1409        let err = BookingError::AmbiguousMatch {
1410            num_matches: 3,
1411            currency: "AAPL".into(),
1412        };
1413        assert!(format!("{err}").contains("3 lots match"));
1414
1415        let err = BookingError::NoMatchingLot {
1416            currency: "AAPL".into(),
1417            cost_spec: CostSpec::empty(),
1418        };
1419        assert!(format!("{err}").contains("No matching lot"));
1420
1421        let err = BookingError::InsufficientUnits {
1422            currency: "AAPL".into(),
1423            requested: dec!(100),
1424            available: dec!(50),
1425        };
1426        assert!(format!("{err}").contains("requested 100"));
1427        assert!(format!("{err}").contains("available 50"));
1428
1429        let err = BookingError::CurrencyMismatch {
1430            expected: "USD".into(),
1431            got: "EUR".into(),
1432        };
1433        assert!(format!("{err}").contains("expected USD"));
1434        assert!(format!("{err}").contains("got EUR"));
1435    }
1436
1437    #[test]
1438    fn test_book_value_multiple_currencies() {
1439        let mut inv = Inventory::new();
1440
1441        // Cost in USD
1442        let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1443        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd));
1444
1445        // Cost in EUR
1446        let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
1447        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur));
1448
1449        let book = inv.book_value("AAPL");
1450        assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
1451        assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
1452    }
1453
1454    #[test]
1455    fn test_reduce_hifo_insufficient_units() {
1456        let mut inv = Inventory::new();
1457
1458        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1459        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1460
1461        let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
1462
1463        assert!(matches!(
1464            result,
1465            Err(BookingError::InsufficientUnits { .. })
1466        ));
1467    }
1468
1469    #[test]
1470    fn test_reduce_average_insufficient_units() {
1471        let mut inv = Inventory::new();
1472
1473        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1474        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1475
1476        let result = inv.reduce(
1477            &Amount::new(dec!(-20), "AAPL"),
1478            None,
1479            BookingMethod::Average,
1480        );
1481
1482        assert!(matches!(
1483            result,
1484            Err(BookingError::InsufficientUnits { .. })
1485        ));
1486    }
1487
1488    #[test]
1489    fn test_reduce_average_empty_inventory() {
1490        let mut inv = Inventory::new();
1491
1492        let result = inv.reduce(
1493            &Amount::new(dec!(-10), "AAPL"),
1494            None,
1495            BookingMethod::Average,
1496        );
1497
1498        assert!(matches!(
1499            result,
1500            Err(BookingError::InsufficientUnits { .. })
1501        ));
1502    }
1503
1504    #[test]
1505    fn test_reduce_merge_operator() {
1506        // {*} merge: two lots merged into weighted-average, then reduced
1507        let mut inv = Inventory::new();
1508        inv.add(Position::with_cost(
1509            Amount::new(dec!(10), "AAPL"),
1510            Cost::new(dec!(150), "USD"),
1511        ));
1512        inv.add(Position::with_cost(
1513            Amount::new(dec!(10), "AAPL"),
1514            Cost::new(dec!(160), "USD"),
1515        ));
1516
1517        let merge_spec = CostSpec::empty().with_merge();
1518        let result = inv
1519            .reduce(
1520                &Amount::new(dec!(-5), "AAPL"),
1521                Some(&merge_spec),
1522                BookingMethod::Strict,
1523            )
1524            .expect("merge reduction should succeed");
1525
1526        // Cost basis: 5 units * 155 USD average = 775 USD
1527        assert_eq!(result.cost_basis, Some(Amount::new(dec!(775), "USD")));
1528
1529        // Inventory should have a single merged lot with 15 remaining @ 155
1530        assert_eq!(inv.positions.len(), 1);
1531        assert_eq!(inv.positions[0].units.number, dec!(15));
1532        let cost = inv.positions[0].cost.as_ref().expect("should have cost");
1533        assert_eq!(cost.number, dec!(155));
1534    }
1535
1536    #[test]
1537    fn test_reduce_merge_insufficient_units() {
1538        let mut inv = Inventory::new();
1539        inv.add(Position::with_cost(
1540            Amount::new(dec!(10), "AAPL"),
1541            Cost::new(dec!(150), "USD"),
1542        ));
1543
1544        let merge_spec = CostSpec::empty().with_merge();
1545        let result = inv.reduce(
1546            &Amount::new(dec!(-20), "AAPL"),
1547            Some(&merge_spec),
1548            BookingMethod::Strict,
1549        );
1550
1551        assert!(matches!(
1552            result,
1553            Err(BookingError::InsufficientUnits { .. })
1554        ));
1555    }
1556
1557    #[test]
1558    fn test_reduce_merge_sells_all() {
1559        // Merge and sell entire position
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        inv.add(Position::with_cost(
1566            Amount::new(dec!(10), "AAPL"),
1567            Cost::new(dec!(160), "USD"),
1568        ));
1569
1570        let merge_spec = CostSpec::empty().with_merge();
1571        let result = inv
1572            .reduce(
1573                &Amount::new(dec!(-20), "AAPL"),
1574                Some(&merge_spec),
1575                BookingMethod::Strict,
1576            )
1577            .expect("merge reduction should succeed");
1578
1579        // Cost basis: 20 * 155 = 3100 USD
1580        assert_eq!(result.cost_basis, Some(Amount::new(dec!(3100), "USD")));
1581
1582        // Inventory should be empty
1583        assert!(inv.positions.is_empty() || inv.positions.iter().all(Position::is_empty));
1584    }
1585
1586    #[test]
1587    fn test_reduce_merge_single_lot() {
1588        // {*} with a single lot should work trivially
1589        let mut inv = Inventory::new();
1590        inv.add(Position::with_cost(
1591            Amount::new(dec!(10), "AAPL"),
1592            Cost::new(dec!(150), "USD"),
1593        ));
1594
1595        let merge_spec = CostSpec::empty().with_merge();
1596        let result = inv
1597            .reduce(
1598                &Amount::new(dec!(-3), "AAPL"),
1599                Some(&merge_spec),
1600                BookingMethod::Strict,
1601            )
1602            .expect("single-lot merge should succeed");
1603
1604        assert_eq!(result.cost_basis, Some(Amount::new(dec!(450), "USD")));
1605        assert_eq!(inv.positions.len(), 1);
1606        assert_eq!(inv.positions[0].units.number, dec!(7));
1607    }
1608
1609    #[test]
1610    fn test_reduce_merge_three_lots() {
1611        // {*} with three lots at different costs
1612        let mut inv = Inventory::new();
1613        inv.add(Position::with_cost(
1614            Amount::new(dec!(10), "AAPL"),
1615            Cost::new(dec!(100), "USD"),
1616        ));
1617        inv.add(Position::with_cost(
1618            Amount::new(dec!(10), "AAPL"),
1619            Cost::new(dec!(150), "USD"),
1620        ));
1621        inv.add(Position::with_cost(
1622            Amount::new(dec!(10), "AAPL"),
1623            Cost::new(dec!(200), "USD"),
1624        ));
1625
1626        // Average cost: (1000 + 1500 + 2000) / 30 = 150 USD
1627        let merge_spec = CostSpec::empty().with_merge();
1628        let result = inv
1629            .reduce(
1630                &Amount::new(dec!(-6), "AAPL"),
1631                Some(&merge_spec),
1632                BookingMethod::Strict,
1633            )
1634            .expect("three-lot merge should succeed");
1635
1636        assert_eq!(result.cost_basis, Some(Amount::new(dec!(900), "USD")));
1637        assert_eq!(inv.positions.len(), 1);
1638        assert_eq!(inv.positions[0].units.number, dec!(24));
1639        let cost = inv.positions[0].cost.as_ref().expect("should have cost");
1640        assert_eq!(cost.number, dec!(150));
1641    }
1642
1643    #[test]
1644    fn test_reduce_merge_mixed_cost_currencies_errors() {
1645        // Lots with different cost currencies cannot be merged
1646        let mut inv = Inventory::new();
1647        inv.add(Position::with_cost(
1648            Amount::new(dec!(10), "AAPL"),
1649            Cost::new(dec!(150), "USD"),
1650        ));
1651        inv.add(Position::with_cost(
1652            Amount::new(dec!(10), "AAPL"),
1653            Cost::new(dec!(130), "EUR"),
1654        ));
1655
1656        let merge_spec = CostSpec::empty().with_merge();
1657        let result = inv.reduce(
1658            &Amount::new(dec!(-5), "AAPL"),
1659            Some(&merge_spec),
1660            BookingMethod::Strict,
1661        );
1662
1663        assert!(
1664            matches!(result, Err(BookingError::CurrencyMismatch { .. })),
1665            "expected CurrencyMismatch, got {result:?}"
1666        );
1667    }
1668
1669    #[test]
1670    fn test_reduce_merge_empty_inventory() {
1671        let mut inv = Inventory::new();
1672
1673        let merge_spec = CostSpec::empty().with_merge();
1674        let result = inv.reduce(
1675            &Amount::new(dec!(-5), "AAPL"),
1676            Some(&merge_spec),
1677            BookingMethod::Strict,
1678        );
1679
1680        assert!(matches!(
1681            result,
1682            Err(BookingError::InsufficientUnits { .. })
1683        ));
1684    }
1685
1686    #[test]
1687    fn test_inventory_display_sorted() {
1688        let mut inv = Inventory::new();
1689
1690        // Add in non-alphabetical order
1691        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1692        inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
1693        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
1694
1695        let display = format!("{inv}");
1696
1697        // Should be sorted alphabetically: AAPL, EUR, USD
1698        let aapl_pos = display.find("AAPL").unwrap();
1699        let eur_pos = display.find("EUR").unwrap();
1700        let usd_pos = display.find("USD").unwrap();
1701
1702        assert!(aapl_pos < eur_pos);
1703        assert!(eur_pos < usd_pos);
1704    }
1705
1706    #[test]
1707    fn test_inventory_with_cost_display_sorted() {
1708        let mut inv = Inventory::new();
1709
1710        // Add same currency with different costs
1711        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
1712        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1713
1714        inv.add(Position::with_cost(
1715            Amount::new(dec!(10), "AAPL"),
1716            cost_high,
1717        ));
1718        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1719
1720        let display = format!("{inv}");
1721
1722        // Both positions should be in the output
1723        assert!(display.contains("AAPL"));
1724        assert!(display.contains("100"));
1725        assert!(display.contains("200"));
1726    }
1727
1728    #[test]
1729    fn test_reduce_hifo_no_matching_lot() {
1730        let mut inv = Inventory::new();
1731
1732        // No AAPL positions
1733        inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1734
1735        let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
1736
1737        assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1738    }
1739
1740    #[test]
1741    fn test_fifo_respects_dates() {
1742        // Ensure FIFO uses acquisition date, not insertion order
1743        let mut inv = Inventory::new();
1744
1745        // Add newer lot first (out of order)
1746        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1747        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1748
1749        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1750        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1751
1752        // FIFO should reduce from oldest (cost 100) first
1753        let result = inv
1754            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
1755            .unwrap();
1756
1757        // Should use cost from oldest lot (100)
1758        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1759    }
1760
1761    #[test]
1762    fn test_lifo_respects_dates() {
1763        // Ensure LIFO uses acquisition date, not insertion order
1764        let mut inv = Inventory::new();
1765
1766        // Add older lot first
1767        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1768        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1769
1770        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1771        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1772
1773        // LIFO should reduce from newest (cost 200) first
1774        let result = inv
1775            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
1776            .unwrap();
1777
1778        // Should use cost from newest lot (200)
1779        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1780    }
1781
1782    // =========================================================================
1783    // Booking method coverage tests
1784    //
1785    // These tests cover gaps identified during the spring 2026 audit:
1786    // - STRICT_WITH_SIZE: cost spec + exact-size, multiple exact-size matches
1787    // - HIFO: multi-lot ordering, partial reduction, cost spec filtering
1788    // - AVERAGE: weighted average with different costs, partial reduction preserves cost
1789    // - NONE: with cost positions, short position reduction
1790    // =========================================================================
1791
1792    // --- STRICT_WITH_SIZE ---
1793
1794    #[test]
1795    fn test_strict_with_size_different_costs_exact_match() {
1796        // When lots have different costs but one matches the reduction size exactly,
1797        // STRICT_WITH_SIZE should pick that lot instead of raising AmbiguousMatch
1798        let mut inv = Inventory::new();
1799
1800        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1801        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1802
1803        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1804        inv.add(Position::with_cost(Amount::new(dec!(7), "AAPL"), cost2));
1805
1806        // Reduce exactly 7 - should match the 7-share lot at cost 200
1807        let result = inv
1808            .reduce(
1809                &Amount::new(dec!(-7), "AAPL"),
1810                None,
1811                BookingMethod::StrictWithSize,
1812            )
1813            .unwrap();
1814
1815        assert_eq!(inv.units("AAPL"), dec!(10));
1816        assert_eq!(result.cost_basis.unwrap().number, dec!(1400.00)); // 7 * 200
1817    }
1818
1819    #[test]
1820    fn test_strict_with_size_multiple_exact_matches_picks_oldest() {
1821        // When multiple lots have the exact same size, STRICT_WITH_SIZE should
1822        // pick the oldest one (first in index order)
1823        let mut inv = Inventory::new();
1824
1825        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1826        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 6, 1));
1827
1828        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost1));
1829        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1830
1831        // Both lots are size 5 — should pick the first (oldest) one
1832        let result = inv
1833            .reduce(
1834                &Amount::new(dec!(-5), "AAPL"),
1835                None,
1836                BookingMethod::StrictWithSize,
1837            )
1838            .unwrap();
1839
1840        assert_eq!(inv.units("AAPL"), dec!(5));
1841        // Should use cost from the oldest lot (100)
1842        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1843    }
1844
1845    #[test]
1846    fn test_strict_with_size_with_cost_spec() {
1847        // Cost spec should filter lots before exact-size matching
1848        let mut inv = Inventory::new();
1849
1850        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1851        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1852
1853        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1854        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1855
1856        // With cost spec filtering to the 200 USD lot, should find unique match
1857        let spec = CostSpec::empty().with_number_per(dec!(200.00));
1858        let result = inv
1859            .reduce(
1860                &Amount::new(dec!(-5), "AAPL"),
1861                Some(&spec),
1862                BookingMethod::StrictWithSize,
1863            )
1864            .unwrap();
1865
1866        assert_eq!(inv.units("AAPL"), dec!(15));
1867        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
1868    }
1869
1870    // --- HIFO ---
1871
1872    #[test]
1873    fn test_hifo_reduces_highest_cost_first() {
1874        // HIFO should reduce the highest-cost lot first, regardless of date
1875        let mut inv = Inventory::new();
1876
1877        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1878        let cost_mid = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1879        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1880
1881        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
1882        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
1883        inv.add(Position::with_cost(
1884            Amount::new(dec!(10), "AAPL"),
1885            cost_high,
1886        ));
1887
1888        // Reduce 5 — should come from highest cost lot (200)
1889        let result = inv
1890            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Hifo)
1891            .unwrap();
1892
1893        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
1894        assert_eq!(inv.units("AAPL"), dec!(25));
1895    }
1896
1897    #[test]
1898    fn test_hifo_spans_multiple_lots() {
1899        // When reducing more than the highest-cost lot holds, HIFO should
1900        // continue to the next highest
1901        let mut inv = Inventory::new();
1902
1903        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1904        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1905
1906        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1907        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_high));
1908
1909        // Reduce 8: 5 from high (200) + 3 from low (100)
1910        let result = inv
1911            .reduce(&Amount::new(dec!(-8), "AAPL"), None, BookingMethod::Hifo)
1912            .unwrap();
1913
1914        // Cost basis: 5*200 + 3*100 = 1300
1915        assert_eq!(result.cost_basis.unwrap().number, dec!(1300.00));
1916        assert_eq!(inv.units("AAPL"), dec!(2));
1917    }
1918
1919    #[test]
1920    fn test_hifo_with_cost_spec_filter() {
1921        // Cost spec should filter lots before HIFO ordering
1922        let mut inv = Inventory::new();
1923
1924        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1925        let cost2 = Cost::new(dec!(200.00), "EUR").with_date(date(2024, 2, 1));
1926
1927        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1928        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1929
1930        // Filter to USD lots only
1931        let spec = CostSpec::empty().with_currency("USD");
1932        let result = inv
1933            .reduce(
1934                &Amount::new(dec!(-5), "AAPL"),
1935                Some(&spec),
1936                BookingMethod::Hifo,
1937            )
1938            .unwrap();
1939
1940        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); // 5 * 100 USD
1941    }
1942
1943    #[test]
1944    fn test_hifo_short_position() {
1945        // HIFO with short positions: covering shorts should work correctly
1946        let mut inv = Inventory::new();
1947
1948        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1949        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1950
1951        // Short positions (negative units)
1952        inv.add(Position::with_cost(
1953            Amount::new(dec!(-10), "AAPL"),
1954            cost_low,
1955        ));
1956        inv.add(Position::with_cost(
1957            Amount::new(dec!(-10), "AAPL"),
1958            cost_high,
1959        ));
1960
1961        // Cover 5 shares (positive = reduce short position)
1962        // HIFO should pick the highest-cost short lot (200)
1963        let result = inv
1964            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Hifo)
1965            .unwrap();
1966
1967        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
1968        assert_eq!(inv.units("AAPL"), dec!(-15));
1969    }
1970
1971    // --- AVERAGE ---
1972
1973    #[test]
1974    fn test_average_weighted_cost() {
1975        // AVERAGE should compute weighted average across lots with different costs
1976        let mut inv = Inventory::new();
1977
1978        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1979        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1980
1981        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1982        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1983
1984        // Average cost = (10*100 + 10*200) / 20 = 150
1985        let result = inv
1986            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
1987            .unwrap();
1988
1989        // Cost basis: 5 * 150 = 750
1990        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
1991        assert_eq!(inv.units("AAPL"), dec!(15));
1992    }
1993
1994    #[test]
1995    fn test_average_merges_into_single_position() {
1996        // After AVERAGE reduction, inventory should have a single simple position
1997        let mut inv = Inventory::new();
1998
1999        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2000        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2001
2002        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
2003        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2004
2005        inv.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
2006            .unwrap();
2007
2008        // Should have exactly one AAPL position remaining
2009        let aapl_positions: Vec<_> = inv
2010            .positions
2011            .iter()
2012            .filter(|p| p.units.currency.as_ref() == "AAPL")
2013            .collect();
2014        assert_eq!(aapl_positions.len(), 1);
2015        assert_eq!(aapl_positions[0].units.number, dec!(15));
2016    }
2017
2018    #[test]
2019    fn test_average_uneven_lots() {
2020        // Weighted average with unequal lot sizes
2021        let mut inv = Inventory::new();
2022
2023        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2024        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
2025
2026        inv.add(Position::with_cost(Amount::new(dec!(30), "AAPL"), cost1));
2027        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
2028
2029        // Average cost = (30*100 + 10*200) / 40 = 5000/40 = 125
2030        let result = inv
2031            .reduce(
2032                &Amount::new(dec!(-10), "AAPL"),
2033                None,
2034                BookingMethod::Average,
2035            )
2036            .unwrap();
2037
2038        assert_eq!(result.cost_basis.unwrap().number, dec!(1250.00)); // 10 * 125
2039    }
2040
2041    // --- NONE ---
2042
2043    #[test]
2044    fn test_none_booking_with_cost_positions() {
2045        // NONE booking should work even when positions have costs
2046        let mut inv = Inventory::new();
2047
2048        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2049        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
2050
2051        let result = inv
2052            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::None)
2053            .unwrap();
2054
2055        assert_eq!(inv.units("AAPL"), dec!(5));
2056        // NONE delegates to reduce_ordered (FIFO) internally, so cost basis is computed
2057        assert!(result.cost_basis.is_some());
2058        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
2059    }
2060
2061    #[test]
2062    fn test_none_booking_short_cover() {
2063        // Covering a short position with NONE booking
2064        let mut inv = Inventory::new();
2065        inv.add(Position::simple(Amount::new(dec!(-100), "USD")));
2066
2067        // Positive amount should reduce the negative position
2068        let result = inv
2069            .reduce(&Amount::new(dec!(30), "USD"), None, BookingMethod::None)
2070            .unwrap();
2071
2072        assert_eq!(inv.units("USD"), dec!(-70));
2073        assert!(!result.matched.is_empty());
2074    }
2075
2076    #[test]
2077    fn test_none_booking_empty_inventory_augments() {
2078        // NONE booking on empty inventory should augment
2079        let mut inv = Inventory::new();
2080
2081        let result = inv
2082            .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
2083            .unwrap();
2084
2085        assert_eq!(inv.units("USD"), dec!(50));
2086        assert!(result.matched.is_empty()); // Augmentation, not reduction
2087    }
2088
2089    // --- Cross-method: short positions ---
2090
2091    #[test]
2092    fn test_fifo_short_position_cover() {
2093        // FIFO: cover short positions (oldest short first)
2094        let mut inv = Inventory::new();
2095
2096        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2097        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2098
2099        inv.add(Position::with_cost(
2100            Amount::new(dec!(-10), "AAPL"),
2101            cost_old,
2102        ));
2103        inv.add(Position::with_cost(
2104            Amount::new(dec!(-10), "AAPL"),
2105            cost_new,
2106        ));
2107
2108        // Cover 5 shares — FIFO should pick oldest short (cost 100)
2109        let result = inv
2110            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Fifo)
2111            .unwrap();
2112
2113        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); // 5 * 100
2114        assert_eq!(inv.units("AAPL"), dec!(-15));
2115    }
2116
2117    #[test]
2118    fn test_lifo_short_position_cover() {
2119        // LIFO: cover short positions (newest short first)
2120        let mut inv = Inventory::new();
2121
2122        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2123        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2124
2125        inv.add(Position::with_cost(
2126            Amount::new(dec!(-10), "AAPL"),
2127            cost_old,
2128        ));
2129        inv.add(Position::with_cost(
2130            Amount::new(dec!(-10), "AAPL"),
2131            cost_new,
2132        ));
2133
2134        // Cover 5 shares — LIFO should pick newest short (cost 200)
2135        let result = inv
2136            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Lifo)
2137            .unwrap();
2138
2139        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
2140        assert_eq!(inv.units("AAPL"), dec!(-15));
2141    }
2142
2143    // === AccountedBookingError Display tests ===
2144    //
2145    // These tests pin the canonical user-facing wording for every variant
2146    // of `AccountedBookingError`. The whole point of unifying booking-error
2147    // Display into `rustledger-core` (#750) is that there's a single source
2148    // of truth — and a single source of truth with no tests is one refactor
2149    // away from drifting again, which is exactly the failure mode that
2150    // produced #748. Any change to the Display strings below will break
2151    // these tests, forcing the author to consciously re-check pta-standards
2152    // conformance assertions and downstream user tooling.
2153
2154    // =========================================================================
2155    // Regression test for issue #875 / beancount#889
2156    //
2157    // When a sell-without-cost-spec leaves a negative simple position in the
2158    // inventory, a subsequent augmentation WITH a cost spec should NOT be
2159    // misclassified as a reduction. `is_reduced_by` must only consider
2160    // cost-bearing positions when the incoming posting has a cost spec.
2161    // =========================================================================
2162
2163    #[test]
2164    fn test_is_reduced_by_ignores_simple_positions_when_has_cost_spec() {
2165        // Regression test for issue #875 / beancount#889.
2166        //
2167        // Scenario:
2168        //   1. Buy 100 HOOG {1.50 EUR}  -> inventory: [100 HOOG {1.50 EUR}]
2169        //   2. Sell 25 HOOG @ 1.60 EUR   -> inventory: [100 HOOG {1.50 EUR}, -25 HOOG (simple)]
2170        //   3. Buy 50 HOOG {1.70 EUR}    -> should be augmentation, NOT reduction
2171        //
2172        // Before fix: is_reduced_by saw the -25 HOOG simple position and
2173        // incorrectly reported that +50 HOOG would reduce the inventory.
2174        let mut inv = Inventory::new();
2175
2176        // Step 1: buy 100 HOOG with cost
2177        let cost = Cost::new(dec!(1.50), "EUR").with_date(date(2024, 1, 10));
2178        inv.add(Position::with_cost(Amount::new(dec!(100), "HOOG"), cost));
2179
2180        // Step 2: sell 25 HOOG without cost spec (simple position)
2181        inv.add(Position::simple(Amount::new(dec!(-25), "HOOG")));
2182
2183        // Step 3: check if buying 50 HOOG with cost spec would be a reduction
2184        let buy_units = Amount::new(dec!(50), "HOOG");
2185
2186        // With has_cost_spec=true, only cost-bearing positions should be
2187        // considered. The 100 HOOG {1.50 EUR} is positive and so is the
2188        // incoming 50 HOOG -> same sign -> NOT a reduction.
2189        assert!(
2190            !inv.is_reduced_by(&buy_units, ReductionScope::CostBearingOnly),
2191            "augmentation with cost spec should NOT be treated as reduction \
2192             when only a simple (no-cost) position has opposite sign"
2193        );
2194
2195        // With AllPositions, all positions are considered,
2196        // including the -25 HOOG simple position -> IS a reduction.
2197        assert!(
2198            inv.is_reduced_by(&buy_units, ReductionScope::AllPositions),
2199            "without cost spec filter, the -25 HOOG simple position \
2200             should cause is_reduced_by to return true"
2201        );
2202    }
2203
2204    #[test]
2205    fn test_accounted_error_display_insufficient_units() {
2206        let err = BookingError::InsufficientUnits {
2207            currency: "AAPL".into(),
2208            requested: dec!(15),
2209            available: dec!(10),
2210        }
2211        .with_account("Assets:Stock".into());
2212        let rendered = format!("{err}");
2213
2214        // Pinned by pta-standards `reduction-exceeds-inventory`
2215        // (`error_contains: ["not enough"]`). See #748 / #749.
2216        assert!(
2217            rendered.contains("not enough"),
2218            "must contain 'not enough' (pta-standards): {rendered}"
2219        );
2220        assert!(
2221            rendered.contains("Assets:Stock"),
2222            "must contain account name: {rendered}"
2223        );
2224        assert!(
2225            rendered.contains("15") && rendered.contains("10"),
2226            "must contain requested and available amounts: {rendered}"
2227        );
2228    }
2229
2230    #[test]
2231    fn test_accounted_error_display_no_matching_lot() {
2232        let err = BookingError::NoMatchingLot {
2233            currency: "AAPL".into(),
2234            cost_spec: CostSpec::empty(),
2235        }
2236        .with_account("Assets:Stock".into());
2237        let rendered = format!("{err}");
2238
2239        assert!(
2240            rendered.contains("No matching lot"),
2241            "must contain 'No matching lot': {rendered}"
2242        );
2243        assert!(
2244            rendered.contains("AAPL"),
2245            "must contain currency: {rendered}"
2246        );
2247        assert!(
2248            rendered.contains("Assets:Stock"),
2249            "must contain account name: {rendered}"
2250        );
2251    }
2252
2253    #[test]
2254    fn test_accounted_error_display_ambiguous_match() {
2255        let err = BookingError::AmbiguousMatch {
2256            num_matches: 3,
2257            currency: "AAPL".into(),
2258        }
2259        .with_account("Assets:Stock".into());
2260        let rendered = format!("{err}");
2261
2262        assert!(
2263            rendered.contains("Ambiguous"),
2264            "must contain 'Ambiguous': {rendered}"
2265        );
2266        assert!(
2267            rendered.contains("AAPL"),
2268            "must contain currency: {rendered}"
2269        );
2270        assert!(
2271            rendered.contains("Assets:Stock"),
2272            "must contain account name: {rendered}"
2273        );
2274        assert!(
2275            rendered.contains('3'),
2276            "must contain match count: {rendered}"
2277        );
2278    }
2279
2280    #[test]
2281    fn test_accounted_error_display_currency_mismatch_renders_as_no_matching_lot() {
2282        // CurrencyMismatch is semantically a specialization of NoMatchingLot
2283        // (there is no lot for the given currency in this inventory) and the
2284        // canonical Display collapses them into the same user-facing phrasing
2285        // so that consumers filtering on E4001 don't need to special-case it.
2286        // This variant is defensive — no `Inventory::reduce` path currently
2287        // emits it — but we still pin its rendering in case a future emission
2288        // site is added.
2289        let err = BookingError::CurrencyMismatch {
2290            expected: "USD".into(),
2291            got: "EUR".into(),
2292        }
2293        .with_account("Assets:Cash".into());
2294        let rendered = format!("{err}");
2295
2296        assert!(
2297            rendered.contains("No matching lot"),
2298            "CurrencyMismatch must render as 'No matching lot' for E4001 \
2299             consistency: {rendered}"
2300        );
2301        assert!(
2302            rendered.contains("EUR"),
2303            "must contain the mismatched (got) currency: {rendered}"
2304        );
2305        assert!(
2306            rendered.contains("Assets:Cash"),
2307            "must contain account name: {rendered}"
2308        );
2309    }
2310}