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
7// ratchet: fxhash-only — hot path; use FxHashMap/FxHashSet, not std SipHash collections (#1237).
8use imbl::Vector;
9use rust_decimal::Decimal;
10use rustc_hash::FxHashMap;
11use serde::{Deserialize, Serialize};
12use smallvec::SmallVec;
13use std::cmp::Reverse;
14use std::fmt;
15use std::str::FromStr;
16
17use crate::{Account, Amount, CostSpec, Currency, Position, is_subaccount_or_equal};
18
19/// Inline storage for `BookingResult::matched`.
20///
21/// STRICT booking (the default) always produces exactly one matched lot
22/// per posting; FIFO / LIFO frequently match a single lot too. Inline
23/// cap of 1 covers the hot case with zero heap allocation while still
24/// spilling to the heap for multi-lot matches.
25///
26/// **API surface note**: this is `pub(crate)` deliberately — we don't
27/// want to commit downstream consumers to `smallvec` as part of our
28/// public API contract. External code reads `BookingResult.matched` via
29/// the slice deref (`.iter()`, `.len()`, indexing) which works
30/// transparently. The concrete `SmallVec<[Position; 1]>` type is still
31/// reachable via the field type but isn't promoted into the crate root.
32pub(crate) type MatchedLots = SmallVec<[Position; 1]>;
33
34mod booking;
35
36/// Booking method determines how lots are matched when reducing positions.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
38#[cfg_attr(
39    feature = "rkyv",
40    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
41)]
42pub enum BookingMethod {
43    /// Lots must match exactly (unambiguous).
44    /// If multiple lots match the cost spec, an error is raised.
45    #[default]
46    Strict,
47    /// Like STRICT, but exact-size matches accept oldest lot.
48    /// If reduction amount equals total inventory, it's considered unambiguous.
49    StrictWithSize,
50    /// First In, First Out. Oldest lots are reduced first.
51    Fifo,
52    /// Last In, First Out. Newest lots are reduced first.
53    Lifo,
54    /// Highest In, First Out. Highest-cost lots are reduced first.
55    Hifo,
56    /// Average cost booking. All lots of a currency are merged.
57    Average,
58    /// No cost tracking. Units are reduced without matching lots.
59    None,
60}
61
62impl FromStr for BookingMethod {
63    type Err = String;
64
65    fn from_str(s: &str) -> Result<Self, Self::Err> {
66        match s.to_uppercase().as_str() {
67            "STRICT" => Ok(Self::Strict),
68            "STRICT_WITH_SIZE" => Ok(Self::StrictWithSize),
69            "FIFO" => Ok(Self::Fifo),
70            "LIFO" => Ok(Self::Lifo),
71            "HIFO" => Ok(Self::Hifo),
72            "AVERAGE" => Ok(Self::Average),
73            "NONE" => Ok(Self::None),
74            _ => Err(format!("unknown booking method: {s}")),
75        }
76    }
77}
78
79impl fmt::Display for BookingMethod {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            Self::Strict => write!(f, "STRICT"),
83            Self::StrictWithSize => write!(f, "STRICT_WITH_SIZE"),
84            Self::Fifo => write!(f, "FIFO"),
85            Self::Lifo => write!(f, "LIFO"),
86            Self::Hifo => write!(f, "HIFO"),
87            Self::Average => write!(f, "AVERAGE"),
88            Self::None => write!(f, "NONE"),
89        }
90    }
91}
92
93/// Controls which positions are considered when checking whether incoming
94/// units reduce (i.e. have the opposite sign of) an existing inventory.
95///
96/// - [`AllPositions`](ReductionScope::AllPositions): every position is
97///   considered, regardless of whether it carries a cost.
98/// - [`CostBearingOnly`](ReductionScope::CostBearingOnly): only positions
99///   with a cost are considered.  This prevents a negative simple (no-cost)
100///   position — left behind by a sell-without-cost-spec — from causing a
101///   subsequent cost-bearing augmentation to be misclassified as a reduction.
102///   See: issue #875, beancount#889.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
104pub enum ReductionScope {
105    /// Consider all positions (cost-bearing and simple).
106    AllPositions,
107    /// Consider only positions that carry a cost.
108    CostBearingOnly,
109}
110
111/// Result of a booking operation.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct BookingResult {
114    /// Positions that were matched/reduced.
115    ///
116    /// Backed by [`SmallVec<[Position; 1]>`](smallvec::SmallVec) so the
117    /// single-match common case (always true under STRICT, common under
118    /// FIFO/LIFO) doesn't touch the heap. The concrete type derefs to
119    /// `[Position]`, so read-side patterns like `.iter()`,
120    /// `.len()`, `.is_empty()`, and indexing work unchanged.
121    ///
122    /// **Breaking API change in 0.15.0**: prior versions used
123    /// `Vec<Position>`. Downstream code that named the type explicitly
124    /// (`let v: Vec<Position> = result.matched`) or called Vec-specific
125    /// methods (`.capacity()`, `.reserve()`) needs to adapt; reading
126    /// the field through the slice deref keeps working.
127    pub matched: MatchedLots,
128    /// The cost basis of the matched positions (for capital gains).
129    pub cost_basis: Option<Amount>,
130}
131
132/// Error that can occur during booking.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum BookingError {
135    /// Multiple lots match but booking method requires unambiguous match.
136    AmbiguousMatch {
137        /// Number of lots that matched.
138        num_matches: usize,
139        /// The currency being reduced.
140        currency: crate::Currency,
141    },
142    /// No lots match the cost specification.
143    NoMatchingLot {
144        /// The currency being reduced.
145        currency: crate::Currency,
146        /// The cost spec that didn't match.
147        cost_spec: CostSpec,
148    },
149    /// Not enough units in matching lots.
150    InsufficientUnits {
151        /// The currency being reduced.
152        currency: crate::Currency,
153        /// Units requested.
154        requested: Decimal,
155        /// Units available.
156        available: Decimal,
157    },
158    /// Currency mismatch between reduction and inventory.
159    CurrencyMismatch {
160        /// Expected currency.
161        expected: crate::Currency,
162        /// Got currency.
163        got: crate::Currency,
164    },
165    /// A `{*}` merge produced a different pool than booking recorded (#2068).
166    ///
167    /// `{*}` is an OPERATION, not a filter: unlike every other cost spec it
168    /// restructures the lots before selecting from them. Booking therefore
169    /// carries the marker into application rather than resolving it into a
170    /// per-unit cost, because the lot it would name does not exist until the
171    /// merge runs.
172    ///
173    /// The consequence is that a booked posting carrying `{*}` re-executes the
174    /// merge when applied, so it is only meaningful against the state it was
175    /// booked against. Booking also records the pool cost it computed, and
176    /// application checks it here — turning "applied against different state,
177    /// silently different answer" into a reported error.
178    MergeMismatch {
179        /// The commodity being reduced (e.g. `AAPL`).
180        currency: crate::Currency,
181        /// The per-unit pool cost booking recorded, in the cost currency.
182        expected: crate::Amount,
183        /// The per-unit pool cost the merge would produce, in the cost currency.
184        ///
185        /// Carried as an [`Amount`] rather than a bare number
186        /// so the message reads `110.00 USD`: the pool cost is denominated in
187        /// the COST currency, which is not the commodity in `currency`.
188        got: crate::Amount,
189    },
190    /// The arithmetic left `rust_decimal`'s ~±7.9e28 range (#1863).
191    ///
192    /// Reported rather than clamped: `Decimal::MIN == -Decimal::MAX`, so
193    /// clamped debits and credits cancel to a residual of exactly zero and an
194    /// arbitrarily unbalanced ledger certifies as clean. Reported rather than
195    /// panicked because ledger input must never abort the CLI.
196    Overflow(OverflowError),
197}
198
199/// A `Decimal` computation whose result cannot be represented.
200///
201/// `rust_decimal` is a 96-bit type with a hard ~±7.9e28 magnitude ceiling and
202/// its `+`/`*` panic on overflow. There is no in-range answer to substitute,
203/// so the arithmetic reports instead of clamping.
204///
205/// Used where an operation MUTATES an inventory, so a caller that reports and
206/// continues needs to know which currency was left alone. Pure leaf arithmetic
207/// returns a plain `Option` instead and lets its caller supply the context:
208/// [`crate::Cost::total_cost`], [`sum_account_and_subaccounts`], and
209/// `rustledger_booking`'s weight ladder. The split is about who is positioned
210/// to write the diagnostic, not about which failures matter.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct OverflowError {
213    /// The currency whose running total left the range.
214    pub currency: crate::Currency,
215}
216
217impl fmt::Display for OverflowError {
218    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219        write!(
220            f,
221            "{} amount exceeds the representable range (±7.9e28); \
222             split the transaction, or denominate it in larger units \
223             (thousands, millions) so the number is smaller",
224            self.currency
225        )
226    }
227}
228
229impl std::error::Error for OverflowError {}
230
231impl From<OverflowError> for BookingError {
232    fn from(e: OverflowError) -> Self {
233        Self::Overflow(e)
234    }
235}
236
237impl fmt::Display for BookingError {
238    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239        match self {
240            Self::MergeMismatch {
241                currency,
242                expected,
243                got,
244            } => write!(
245                f,
246                "{{*}} merge of {currency} would produce a pool cost of {got}, \
247                 but booking recorded {expected}: this posting is being applied \
248                 against different inventory than it was booked against"
249            ),
250            Self::AmbiguousMatch {
251                num_matches,
252                currency,
253            } => write!(
254                f,
255                "Ambiguous match: {num_matches} lots match for {currency}"
256            ),
257            Self::NoMatchingLot {
258                currency,
259                cost_spec,
260            } => {
261                write!(f, "No matching lot for {currency} with cost {cost_spec}")
262            }
263            Self::InsufficientUnits {
264                currency,
265                requested,
266                available,
267            } => write!(
268                f,
269                "Insufficient units of {currency}: requested {requested}, available {available}"
270            ),
271            Self::CurrencyMismatch { expected, got } => {
272                write!(f, "Currency mismatch: expected {expected}, got {got}")
273            }
274            Self::Overflow(e) => write!(f, "{e}"),
275        }
276    }
277}
278
279impl std::error::Error for BookingError {}
280
281impl BookingError {
282    /// Wrap this booking error with the account context that produced it.
283    ///
284    /// `Inventory` itself doesn't know which account it belongs to, so the
285    /// raw `BookingError` carries no `account` field. The caller (booking
286    /// engine, validator) knows the account and uses this constructor to
287    /// produce the user-facing error.
288    ///
289    /// The resulting [`AccountedBookingError`] is the **single canonical
290    /// rendering** of an inventory failure for user-facing output. Both the
291    /// booking layer and the validator format errors via this type so the
292    /// wording cannot drift between them — the failure mode that produced
293    /// #748.
294    #[must_use]
295    pub const fn with_account(self, account: crate::Account) -> AccountedBookingError {
296        AccountedBookingError {
297            error: self,
298            account,
299        }
300    }
301}
302
303/// A [`BookingError`] paired with the account that produced it.
304///
305/// This is the canonical user-facing inventory error type. Its `Display`
306/// impl is the **single source of truth** for booking-error wording across
307/// `rustledger-booking` and `rustledger-validate`. Conformance assertions
308/// (e.g. pta-standards `reduction-exceeds-inventory` requires the literal
309/// substring `"not enough"`) are pinned by this Display.
310///
311/// Construct via [`BookingError::with_account`].
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct AccountedBookingError {
314    /// The underlying inventory-level error.
315    pub error: BookingError,
316    /// The account whose inventory produced the error.
317    pub account: crate::Account,
318}
319
320impl fmt::Display for AccountedBookingError {
321    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322        match &self.error {
323            // The currency is already named in the inner message; the account
324            // is the context this wrapper exists to add.
325            BookingError::Overflow(e) => write!(f, "{}: {e}", self.account),
326            BookingError::MergeMismatch { .. } => write!(f, "{}: {}", self.account, self.error),
327            BookingError::InsufficientUnits {
328                requested,
329                available,
330                ..
331            } => write!(
332                f,
333                "Not enough units in {}: requested {}, available {}; not enough to reduce",
334                self.account, requested, available
335            ),
336            BookingError::NoMatchingLot { currency, .. } => {
337                write!(f, "No matching lot for {} in {}", currency, self.account)
338            }
339            BookingError::AmbiguousMatch {
340                num_matches,
341                currency,
342            } => write!(
343                f,
344                "Ambiguous lot match for {}: {} lots match in {}",
345                currency, num_matches, self.account
346            ),
347            // Currency mismatch is semantically a specialization of
348            // NoMatchingLot (there is no lot for the given currency in this
349            // inventory), so we render and classify it the same way. Consumers
350            // filtering on E4001 don't need to special-case CurrencyMismatch.
351            //
352            // This variant is defensive: no `Inventory::reduce` path in
353            // `rustledger-core` currently emits it, but we still render it
354            // consistently in case a future emission site is added.
355            BookingError::CurrencyMismatch { got, .. } => {
356                write!(f, "No matching lot for {} in {}", got, self.account)
357            }
358        }
359    }
360}
361
362impl std::error::Error for AccountedBookingError {}
363/// How an [`Inventory`] holds its positions.
364///
365/// The two backings exist because the two uses want opposite things, and a
366/// single choice was measurably wrong for one of them:
367///
368/// * **Booking** mutates an inventory constantly — `add`, and a `reduce` that
369///   filters, sorts and then indexes matched lots — and snapshots it only on
370///   the conditional overflow-rollback path. It wants contiguous storage:
371///   O(1) indexing and cache-friendly iteration.
372/// * **BQL's JOURNAL running balance** is only appended to, and is CLONED
373///   once per output row. It wants structural sharing: N snapshots costing
374///   O(base + sum of deltas) rather than O(N x base) (#1086 — measured at
375///   32.7 MB peak RSS for 2000 lots x 2000 rows; a contiguous clone per row
376///   holds ~2M positions instead).
377///
378/// Holding everything in the persistent vector made every reduction pay RRB
379/// costs: on a lot-heavy workload `imbl::Vector`'s iterator alone was ~13% of
380/// all instructions, and indexed access inside `reduce_ordered` is O(log M)
381/// per lookup rather than O(1). Holding everything contiguously reintroduces
382/// the #1086 blow-up. So the representation follows the use.
383#[derive(Debug, Clone)]
384enum PositionStore {
385    /// Contiguous — booking's working representation.
386    ///
387    /// SPARSE: a slot holding `None` is a lot a reduction drained and removed.
388    /// Removing by shifting renumbers every later lot, and lot indices have to
389    /// survive removals for a cost-keyed match index to be possible at all.
390    /// Tombstoning makes removal O(1) and leaves every other slot untouched.
391    ///
392    /// `None` is NOT the same as a zero-unit position. A zero-unit lot is live
393    /// and visible — cost-less lots can merge through zero — and
394    /// [`Inventory::len`] is documented as counting them. A tombstone is a lot
395    /// that is GONE. Encoding one as the other would make drained lots visible
396    /// to `Serialize`, the FFI and wasm converters, the account validator and
397    /// `report balances`, and would change what `currency_accounts` sees when
398    /// it branches on `inv.len() == 1` to match Python.
399    Owned(Slots),
400    /// Structurally shared — BQL's snapshot representation, and dense: BQL
401    /// clones snapshots but never books against them, so it has no removals to
402    /// keep indices stable across.
403    Shared(Vector<Position>),
404}
405
406/// Iterator over [`PositionStore`], as a stack-allocated enum.
407///
408/// Deliberately NOT `Box<dyn Iterator>`: `iter` is called from `units`,
409/// `merge`, `at_cost`, equality and every reduction pass, so boxing would put
410/// a heap allocation and a dynamic dispatch on paths this change exists to
411/// make cheaper. Copilot's catch on #2056.
412enum PositionStoreIter<'a> {
413    Owned(std::iter::Flatten<std::slice::Iter<'a, Option<Position>>>),
414    Shared(imbl::vector::Iter<'a, Position, imbl::shared_ptr::DefaultSharedPtr>),
415}
416
417impl<'a> Iterator for PositionStoreIter<'a> {
418    type Item = &'a Position;
419
420    fn next(&mut self) -> Option<Self::Item> {
421        match self {
422            Self::Owned(i) => i.next(),
423            Self::Shared(i) => i.next(),
424        }
425    }
426
427    fn size_hint(&self) -> (usize, Option<usize>) {
428        match self {
429            Self::Owned(i) => i.size_hint(),
430            Self::Shared(i) => i.size_hint(),
431        }
432    }
433}
434
435/// The sparse backing: slots plus the number that are live.
436///
437/// `live` is maintained rather than counted because `len`, `is_empty` and the
438/// compaction trigger all run per reduction, and an O(slots) scan there is
439/// exactly the cost tombstones exist to avoid.
440#[derive(Debug, Clone, Default)]
441struct Slots {
442    entries: Vec<Option<Position>>,
443    live: usize,
444    /// Prior contents of every slot this transaction has touched, so a failed
445    /// transaction can be undone without having copied the whole account.
446    ///
447    /// `None` while not recording. Recorded lazily and ONCE per slot — the
448    /// first write captures what to restore; later writes to the same slot are
449    /// already covered.
450    ///
451    /// Recording lives here, in the backing, rather than at the eleven call
452    /// sites that mutate positions. Those all funnel through this type's
453    /// primitives and the field is private, so covering the primitives is
454    /// complete by construction; covering call sites would be complete only
455    /// until someone adds a twelfth.
456    undo: Option<Vec<(usize, Option<Position>)>>,
457    /// Slots already captured in `undo`, for O(1) "have I recorded this?".
458    ///
459    /// This bounds the log's size and cost; it is not what makes rollback
460    /// correct. Restoring in reverse order already makes a duplicate entry
461    /// harmless, because the earliest capture is applied last. Removing this
462    /// therefore does not fail any test — it just lets the log grow.
463    ///
464    /// A linear scan of the log reads fine for a transaction touching one or
465    /// two slots, but `{*}` merge records every matched lot, which made
466    /// recording O(k^2) in the lots merged — a fresh quadratic inside the
467    /// change that removed one. Hashing a `usize` with `FxHash` is a couple of
468    /// instructions, so the common case loses nothing.
469    undo_seen: rustc_hash::FxHashSet<usize>,
470}
471
472impl Slots {
473    fn from_live(positions: Vec<Position>) -> Self {
474        let live = positions.len();
475        Self {
476            entries: positions.into_iter().map(Some).collect(),
477            live,
478            undo: None,
479            undo_seen: rustc_hash::FxHashSet::default(),
480        }
481    }
482
483    /// Capture slot `i`'s current contents, if recording and not already held.
484    ///
485    /// The "already held" check is a linear scan. A transaction touches one or
486    /// two slots, where a scan beats hashing; the worst case is a `{*}` merge,
487    /// which records every matched lot and makes this O(k^2) in the lots
488    /// merged. That is bounded by one transaction and by an operation that is
489    /// rare, which is why it is not a set.
490    fn record(&mut self, i: usize) {
491        if self.undo.is_none() {
492            return;
493        }
494        if !self.undo_seen.insert(i) {
495            return;
496        }
497        let prior = self.entries.get(i).cloned().flatten();
498        if let Some(log) = self.undo.as_mut() {
499            log.push((i, prior));
500        }
501    }
502
503    /// Tombstone slot `i`, keeping `live` in step.
504    ///
505    /// Out of range is a caller bug, not a condition: slot indices come from
506    /// `iter_slots` or `push_slot` and are internal throughout. It cannot
507    /// panic in release — silently skipping is better than aborting on a
508    /// ledger — but it must not pass unnoticed in a test run, or a desynced
509    /// `live` count shows up later as a wrong compaction trigger with nothing
510    /// pointing back here.
511    fn set_dead(&mut self, i: usize) {
512        self.record(i);
513        debug_assert!(
514            i < self.entries.len(),
515            "set_dead called with out-of-range slot {i} (of {})",
516            self.entries.len(),
517        );
518        if let Some(entry) = self.entries.get_mut(i)
519            && entry.take().is_some()
520        {
521            self.live -= 1;
522        }
523    }
524}
525
526/// Iterator over [`PositionStore`] yielding each live position with its SLOT
527/// index — the index [`std::ops::Index`] accepts, not a running count.
528///
529/// A stack-allocated enum for the same reason as [`PositionStoreIter`]: this
530/// runs inside every reduction and must not box.
531enum SlotIter<'a> {
532    Owned(std::iter::Enumerate<std::slice::Iter<'a, Option<Position>>>),
533    Shared(
534        std::iter::Enumerate<imbl::vector::Iter<'a, Position, imbl::shared_ptr::DefaultSharedPtr>>,
535    ),
536}
537
538impl<'a> SlotIter<'a> {
539    fn new(store: &'a PositionStore) -> Self {
540        match store {
541            PositionStore::Owned(v) => Self::Owned(v.entries.iter().enumerate()),
542            PositionStore::Shared(v) => Self::Shared(v.iter().enumerate()),
543        }
544    }
545}
546
547impl<'a> Iterator for SlotIter<'a> {
548    type Item = (usize, &'a Position);
549
550    fn next(&mut self) -> Option<Self::Item> {
551        match self {
552            Self::Owned(i) => {
553                // Skip tombstones, but keep the REAL slot number of what we do
554                // yield: that number is what comes back through `Index`.
555                for (slot, entry) in i.by_ref() {
556                    if let Some(position) = entry {
557                        return Some((slot, position));
558                    }
559                }
560                None
561            }
562            Self::Shared(i) => i.next(),
563        }
564    }
565}
566
567impl Default for PositionStore {
568    fn default() -> Self {
569        Self::Owned(Slots::default())
570    }
571}
572
573impl PositionStore {
574    /// Every live position paired with the index that [`Index`] will accept
575    /// for it.
576    ///
577    /// Today this is exactly `iter().enumerate()`, because every element of
578    /// the backing store is live. It exists as its own method because that
579    /// equivalence is a PROPERTY OF THE CURRENT STORAGE, not a law: the
580    /// reduction paths collect indices here and hand them back through
581    /// `Index`/`IndexMut`, so anything that makes the backing sparse — the
582    /// tombstoned lots that would let a cost-keyed index survive removals —
583    /// silently desynchronises the two unless every such site goes through
584    /// one place. This is that place.
585    ///
586    /// [`Index`]: std::ops::Index
587    fn iter_slots(&self) -> impl Iterator<Item = (usize, &Position)> {
588        // Real slot numbers, skipping tombstones — NOT a running count of live
589        // positions. The reduction paths hand these back to `Index`.
590        SlotIter::new(self)
591    }
592
593    /// Live positions, dropping tombstones. This is what `Serialize`, the FFI
594    /// converters and every external consumer see, so a drained lot stays
595    /// invisible exactly as it was when removal shifted the vector.
596    fn iter(&self) -> PositionStoreIter<'_> {
597        match self {
598            Self::Owned(v) => PositionStoreIter::Owned(v.entries.iter().flatten()),
599            Self::Shared(v) => PositionStoreIter::Shared(v.iter()),
600        }
601    }
602
603    /// Number of LIVE positions. Tombstones are not positions.
604    fn len(&self) -> usize {
605        match self {
606            Self::Owned(v) => v.live,
607            Self::Shared(v) => v.len(),
608        }
609    }
610
611    /// Number of slots, live or not — the exclusive upper bound on a valid
612    /// slot index, and what `push_slot` returns for the slot it fills.
613    fn slot_count(&self) -> usize {
614        match self {
615            Self::Owned(v) => v.entries.len(),
616            Self::Shared(v) => v.len(),
617        }
618    }
619
620    /// How many slots are tombstones.
621    const fn dead(&self) -> usize {
622        match self {
623            Self::Owned(v) => v.entries.len() - v.live,
624            Self::Shared(_) => 0,
625        }
626    }
627
628    fn is_empty(&self) -> bool {
629        self.len() == 0
630    }
631
632    fn get(&self, i: usize) -> Option<&Position> {
633        match self {
634            Self::Owned(v) => v.entries.get(i).and_then(Option::as_ref),
635            Self::Shared(v) => v.get(i),
636        }
637    }
638
639    fn push(&mut self, p: Position) {
640        self.push_slot(p);
641    }
642
643    /// Append a position, returning the slot index it landed in.
644    ///
645    /// The slot is `slot_count()`, not `len()`: with tombstones present those
646    /// differ, and `simple_index` stores slots.
647    fn push_slot(&mut self, p: Position) -> usize {
648        let slot = self.slot_count();
649        match self {
650            Self::Owned(v) => {
651                if let Some(log) = v.undo.as_mut() {
652                    log.push((slot, None));
653                    v.undo_seen.insert(slot);
654                }
655                v.entries.push(Some(p));
656                v.live += 1;
657            }
658            Self::Shared(v) => v.push_back(p),
659        }
660        slot
661    }
662
663    /// Remove the position in slot `i`, leaving a tombstone behind so no other
664    /// slot is renumbered.
665    fn remove(&mut self, i: usize) {
666        match self {
667            Self::Owned(v) => v.set_dead(i),
668            Self::Shared(v) => {
669                v.remove(i);
670            }
671        }
672    }
673
674    /// Drop positions failing `f`. On the sparse backing they become
675    /// tombstones, so no surviving lot is renumbered.
676    fn retain(&mut self, mut f: impl FnMut(&Position) -> bool) {
677        match self {
678            Self::Owned(v) => {
679                for i in 0..v.entries.len() {
680                    if v.entries[i].as_ref().is_some_and(|p| !f(p)) {
681                        v.set_dead(i);
682                    }
683                }
684            }
685            Self::Shared(v) => v.retain(f),
686        }
687    }
688
689    /// Start recording an undo log, discarding any previous one.
690    fn begin_undo(&mut self) {
691        if let Self::Owned(v) = self {
692            v.undo = Some(Vec::new());
693            v.undo_seen.clear();
694        }
695    }
696
697    /// Stop recording and discard the log — the transaction committed.
698    fn commit_undo(&mut self) {
699        if let Self::Owned(v) = self {
700            v.undo = None;
701            v.undo_seen.clear();
702        }
703    }
704
705    /// Restore every slot the log captured, newest first, and stop recording.
706    ///
707    /// Newest first, for two reasons that are easy to conflate.
708    ///
709    /// The visible one: slots CREATED by this transaction are popped from the
710    /// end while they are still last, so failed transactions do not leave dead
711    /// slots behind. Forward order would merely leave tombstones that
712    /// compaction reclaims, so a mutation swapping the order survives the
713    /// suite — expected, not a coverage gap.
714    ///
715    /// The load-bearing one: reverse order is what makes a slot recorded MORE
716    /// THAN ONCE safe. The earliest capture holds the true prior value, and
717    /// reverse iteration applies it last, so it wins. `record` deduplicates, so
718    /// duplicates should not arise — but the two mechanisms are independent,
719    /// and dropping BOTH is what would corrupt a rollback. Changing this to
720    /// forward order is only safe while the deduplication holds.
721    fn rollback_undo(&mut self) {
722        let Self::Owned(v) = self else {
723            return;
724        };
725        let Some(log) = v.undo.take() else {
726            return;
727        };
728        v.undo_seen.clear();
729        for (slot, prior) in log.into_iter().rev() {
730            // The slot existed: put back exactly what was there. Otherwise it
731            // was created by this transaction (or was already a tombstone), so
732            // it must end up not-live — dropped entirely when it is the last
733            // one, so slot numbers do not drift upward across failed
734            // transactions.
735            if let Some(position) = prior {
736                if v.entries[slot].is_none() {
737                    v.live += 1;
738                }
739                v.entries[slot] = Some(position);
740            } else {
741                if v.entries[slot].is_some() {
742                    v.live -= 1;
743                }
744                v.entries[slot] = None;
745                if slot + 1 == v.entries.len() {
746                    v.entries.pop();
747                }
748            }
749        }
750    }
751
752    /// Physically drop tombstones, renumbering the slots that remain.
753    ///
754    /// INVALIDATES every slot index, so callers must hold none and must
755    /// rebuild anything keyed by slot. Reductions never span this: it runs
756    /// before planning, when nothing is held.
757    fn compact_slots(&mut self) {
758        if let Self::Owned(v) = self {
759            v.entries.retain(Option::is_some);
760            debug_assert_eq!(
761                v.entries.len(),
762                v.live,
763                "compaction must leave only live slots"
764            );
765        }
766    }
767
768    /// [`Self::retain`], with each position's slot index — the same index
769    /// [`Self::iter_slots`] reports and [`Index`] accepts.
770    ///
771    /// `reduce_merge` needs this: it selects lots through `iter_slots` and
772    /// then drops exactly those. Written with a plain `retain` and a counter
773    /// incremented per visit, that is only correct while every element the
774    /// store holds is a live position visited in order — the same dense-store
775    /// assumption `iter_slots` exists to keep in one place, arriving by a
776    /// second route that `iter_slots` cannot cover. A sparse store whose
777    /// `retain` skips dead slots would leave the counter numbering live
778    /// positions while the selection numbered real slots, and `{*}` merges
779    /// would delete the wrong lots.
780    ///
781    /// [`Index`]: std::ops::Index
782    fn retain_slots(&mut self, mut f: impl FnMut(usize, &Position) -> bool) {
783        match self {
784            Self::Owned(v) => {
785                for i in 0..v.entries.len() {
786                    if v.entries[i].as_ref().is_some_and(|p| !f(i, p)) {
787                        v.set_dead(i);
788                    }
789                }
790            }
791            // Dense: a running count IS the slot number here.
792            Self::Shared(v) => {
793                let mut slot = 0;
794                v.retain(|position| {
795                    let keep = f(slot, position);
796                    slot += 1;
797                    keep
798                });
799            }
800        }
801    }
802
803    /// Switch to contiguous storage, cloning if not already `Owned`.
804    ///
805    /// `reduce` calls this, which ALSO discharges the uniqueness requirement
806    /// the old unconditional `self.positions.iter().cloned().collect()`
807    /// existed for: mutating a structurally-SHARED `imbl::Vector` in place
808    /// drives `imbl-sized-chunks`' copy-on-write into a use-after-free of the
809    /// interned `Arc<str>` inside `Position`. Materializing into a fresh
810    /// `Vec` leaves nothing shared to corrupt, at the same O(M) cost that
811    /// copy already paid — and every subsequent access in the reduction is
812    /// then contiguous instead of an RRB walk.
813    fn make_owned(&mut self) {
814        if let Self::Shared(v) = self {
815            let slots: Vec<Option<Position>> = v.iter().cloned().map(Some).collect();
816            let live = slots.len();
817            *self = Self::Owned(Slots {
818                entries: slots,
819                live,
820                undo: None,
821                undo_seen: rustc_hash::FxHashSet::default(),
822            });
823        }
824    }
825}
826
827impl std::ops::Index<usize> for PositionStore {
828    type Output = Position;
829    /// # Panics
830    ///
831    /// Panics if `i` names a tombstone. Every index in circulation comes from
832    /// [`PositionStore::iter_slots`] or [`PositionStore::push_slot`], which
833    /// only ever report live slots, and no reduction removes a lot and then
834    /// re-reads it — so reaching a tombstone means slot numbers and the store
835    /// have desynchronised, and a wrong-lot read is worse than a panic.
836    fn index(&self, i: usize) -> &Position {
837        match self {
838            Self::Owned(v) => v.entries[i]
839                .as_ref()
840                .expect("slot index names a live position, not a tombstone"),
841            Self::Shared(v) => &v[i],
842        }
843    }
844}
845
846impl std::ops::IndexMut<usize> for PositionStore {
847    /// # Panics
848    ///
849    /// As [`Index::index`](std::ops::Index::index).
850    fn index_mut(&mut self, i: usize) -> &mut Position {
851        match self {
852            Self::Owned(v) => {
853                // Capture BEFORE the caller writes through the returned
854                // reference — afterwards the prior value is gone.
855                v.record(i);
856                v.entries[i]
857                    .as_mut()
858                    .expect("slot index names a live position, not a tombstone")
859            }
860            Self::Shared(v) => &mut v[i],
861        }
862    }
863}
864
865impl FromIterator<Position> for PositionStore {
866    fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
867        let slots: Vec<Option<Position>> = iter.into_iter().map(Some).collect();
868        let live = slots.len();
869        Self::Owned(Slots {
870            entries: slots,
871            live,
872            undo: None,
873            undo_seen: rustc_hash::FxHashSet::default(),
874        })
875    }
876}
877
878// Serialized as a plain sequence, identical for both backings — the wire
879// format does not encode which representation happens to be in use, and a
880// round-trip always lands in `Owned` (deserialization is followed by
881// `rebuild_index`, and a freshly-loaded inventory is about to be mutated far
882// more often than snapshotted).
883impl Serialize for PositionStore {
884    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
885        serializer.collect_seq(self.iter())
886    }
887}
888
889impl<'de> Deserialize<'de> for PositionStore {
890    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
891        Ok(Self::Owned(Slots::from_live(Vec::<Position>::deserialize(
892            deserializer,
893        )?)))
894    }
895}
896
897/// An inventory is a collection of positions.
898///
899/// It tracks all positions for an account and supports booking operations
900/// for adding and reducing positions.
901///
902/// # Examples
903///
904/// ```
905/// use rustledger_core::{Inventory, Position, Amount, Cost, BookingMethod};
906/// use rust_decimal_macros::dec;
907///
908/// let mut inv = Inventory::new();
909///
910/// // Add a simple position
911/// inv.add(Position::simple(Amount::new(dec!(100), "USD")));
912/// assert_eq!(inv.units("USD"), dec!(100));
913///
914/// // Add a position with cost
915/// let cost = Cost::new(dec!(150.00), "USD");
916/// inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
917/// assert_eq!(inv.units("AAPL"), dec!(10));
918/// ```
919#[derive(Debug, Clone, Default, Serialize, Deserialize)]
920// Deserialization goes through `InventoryWire` so the derived caches are
921// REBUILT rather than left empty.
922//
923// `simple_index` and `units_cache` are `#[serde(skip)]`, so a plain derive
924// produced an inventory holding positions with both caches empty. `units()`
925// recomputes on a miss and `add_headroom_for` refuses to answer, but `add()`
926// trusted them: `units_cache.get(..).unwrap_or_default()` read 0 for an
927// inventory already holding 100 USD, then wrote that back as the new total,
928// while the empty `simple_index` meant a cost-less lot was appended instead of
929// merged. A round-tripped 100 USD inventory answered `units("USD") == 5` after
930// adding 5, with two lots where there should be one.
931//
932// `rebuild_index`'s own doc already claimed it ran "after ... deserialization".
933// It did not — nothing called it on that path, and two comments elsewhere
934// referred to it by a name (`rebuild_caches`) that never existed. Now it does.
935#[serde(try_from = "InventoryWire")]
936pub struct Inventory {
937    /// Positions, in whichever backing suits this inventory's use — see
938    /// [`PositionStore`]. Contiguous (`Owned`) by default, which is what
939    /// booking wants; structurally shared (`Shared`) for the BQL running
940    /// balances that are cloned once per output row.
941    ///
942    /// The notes below describe the SHARED backing, and are why it still
943    /// exists:
944    /// This is the critical property for JOURNAL-style row-per-snapshot
945    /// patterns in BQL (issue #1086): N nested snapshots cost O(base + Σ
946    /// deltas) memory instead of O(N · base), and the per-row clone cost
947    /// drops from O(positions) to O(1).
948    ///
949    /// The trade is real: booking and BQL aggregator mutations pay an
950    /// O(log N) tree walk vs `Vec`'s amortized O(1) push. Measured impact
951    /// scales with inventory size M: +85 ns/op at M=10, +1.6 µs/op at
952    /// M=100, +19 µs/op at M=500 (criterion `reduce_fifo/*`). For typical
953    /// small-M ledgers the overhead is sub-millisecond per `rledger
954    /// check`; the users who feel it are users with very large inventories,
955    /// the same users who hit the JOURNAL OOM today.
956    ///
957    /// `rkyv` derives were dropped because (a) `imbl::Vector` has no `rkyv`
958    /// impl and (b) no code path currently archives an `Inventory`
959    /// (confirmed in the `SmallVec` experiment for #1069). Pre-1.0 break;
960    /// downstream callers archiving `Inventory` directly will need to
961    /// archive `Vec<Position>` themselves. Serde wire format is unchanged
962    /// (sequence-typed, identical for both backings).
963    positions: PositionStore,
964    /// Index for O(1) lookup of simple positions (no cost) by currency.
965    /// Maps currency to position index in the `positions` vector.
966    /// Cache of total units per currency for O(1) `units()` lookups.
967    /// Updated incrementally on `add()` and `reduce()`.
968    /// Not serialized - rebuilt on demand.
969    #[serde(skip)]
970    units_cache: FxHashMap<crate::Currency, CurrencyStats>,
971    /// Cost-bearing lots grouped by what a cost spec matches them on, so a
972    /// reduction naming an explicit per-unit cost finds its candidates instead
973    /// of comparing against every lot.
974    ///
975    /// That comparison was the last superlinear term in the pipeline:
976    /// `CostSpec::matches` ran once per lot per reduction and grew 111x for
977    /// 10x the input. Slots are stable across removals — that is what the
978    /// tombstoned backing buys — so the lists stay valid until compaction,
979    /// which rebuilds them.
980    ///
981    /// Only lots WITH a cost appear. A spec that names no per-unit cost still
982    /// scans, because it can match anything.
983    /// Not serialized - rebuilt on demand, like the caches above.
984    #[serde(skip)]
985    cost_index: FxHashMap<CostKey, smallvec::SmallVec<[usize; 2]>>,
986
987    /// EVERY lot per units-currency, in the order FIFO consumes them: lot date
988    /// ascending, ties broken by slot ascending.
989    ///
990    /// Cost-LESS lots are in here too, and deliberately. An empty cost spec
991    /// matches one (`matches_cost_spec`: `(None, true) => true`), so ordered
992    /// selection can drain one — an index holding only cost-bearing lots chose
993    /// a different lot than the scan it replaced, which is what the
994    /// scan-equivalence test caught. `cost_index` is the map keyed on cost;
995    /// this one is keyed on nothing but the commodity.
996    ///
997    /// `cost_index` cannot serve an under-specified spec — a bare `{}` names
998    /// no cost to key on — so ordered selection scanned every slot instead,
999    /// once per reduction. Walking this list stops as soon as the reduction is
1000    /// covered, so the common single-lot sale touches one entry (#2083).
1001    ///
1002    /// Same safety asymmetry as `cost_index`: a STALE entry is harmless
1003    /// because the walk re-checks liveness, sign and the spec, while a MISSING
1004    /// entry hides a lot the reduction should have seen. Insertion is at the
1005    /// single `add` site; removal rides on `cost_index_remove`.
1006    /// Not serialized - rebuilt on demand, like the caches above.
1007    /// `None` until ordered selection asks for it: boxed so an inventory that
1008    /// never needs one carries a pointer rather than a map. The map itself is
1009    /// three words plus its allocation, on a type that is created per account
1010    /// and cloned per BQL output row.
1011    #[serde(skip)]
1012    ordered_index: Option<Box<OrderedIndex>>,
1013    /// Whether an undo log is open. Not serialized; a transaction never spans
1014    /// a round trip.
1015    #[serde(skip)]
1016    undo_open: bool,
1017    /// Debug-only copy taken at `begin_undo`, compared against the restored
1018    /// inventory to prove the log covered every mutation.
1019    #[cfg(debug_assertions)]
1020    #[serde(skip)]
1021    undo_witness: Option<Box<Self>>,
1022}
1023
1024/// The order a booking method consumes lots in.
1025///
1026/// One inventory needs one ordering, because an account has one booking
1027/// method — so this is stored alongside the index rather than a second index
1028/// being kept in step with the first.
1029/// What makes two lots interchangeable: every attribute the model records.
1030
1031#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1032pub(super) enum LotOrder {
1033    /// Oldest lot first, which is what FIFO takes.
1034    Date,
1035    /// Newest lot first, which is what LIFO takes.
1036    DateDescending,
1037    /// Most expensive lot first, which is what HIFO takes.
1038    CostDescending,
1039}
1040
1041/// The sort key `order_key` produces for a slot.
1042///
1043/// One `LotOrder` drives a whole sort, so keys of different variants are never
1044/// compared with each other; the derived `Ord` comparing discriminants first
1045/// is therefore unreachable, not load-bearing.
1046#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
1047pub(super) enum OrderKey {
1048    /// Oldest first. `None` sorts before `Some`, so date-less lots lead.
1049    Date(Option<crate::NaiveDate>),
1050    /// Newest first, date-less lots last.
1051    DateDescending(Reverse<Option<crate::NaiveDate>>),
1052    /// Most expensive first.
1053    CostDescending(Decimal),
1054}
1055
1056/// Lots per units-currency, in the order some booking method consumes them.
1057#[derive(Debug, Clone)]
1058pub(super) struct OrderedIndex {
1059    /// Which ordering `by_currency` is sorted in.
1060    order: LotOrder,
1061    /// Slots per units-currency, sorted by `order` then slot ascending.
1062    ///
1063    /// No tiebreak beyond the slot is needed: `add` stores interchangeable
1064    /// lots as ONE position (#2118), so a group has one slot rather than
1065    /// several needing to be kept adjacent.
1066    by_currency: FxHashMap<crate::Currency, Vec<usize>>,
1067}
1068
1069/// What [`Inventory::cost_index`] groups lots by: the units they are held in,
1070/// and the per-unit cost a spec would name to select them.
1071type CostKey = (crate::Currency, Decimal, crate::Currency);
1072
1073/// The key for `position`, if it carries a cost.
1074fn cost_key(position: &Position) -> Option<CostKey> {
1075    position.cost.as_ref().map(|cost| {
1076        (
1077            position.units.currency.clone(),
1078            cost.number,
1079            cost.currency.clone(),
1080        )
1081    })
1082}
1083
1084/// Everything cached per currency: the running unit total, and the per-bucket
1085/// position counts that make [`Inventory::is_reduced_by`] O(1).
1086///
1087/// Deliberately ONE map rather than two. `add` already did a `get` plus an
1088/// `insert` on the units cache, and hanging a second map off the same key
1089/// added a third hash of an interned string per posting — which measured as a
1090/// 4% regression on the cost-spec-free `simple` profiling shape, wiping out
1091/// part of what the index bought on `investment`. Folded in here, the counts
1092/// ride along on a lookup that was already happening.
1093#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1094struct CurrencyStats {
1095    /// Running total of units across every lot of this currency.
1096    total: Decimal,
1097    /// Position counts by (sign, cost-bearing) bucket.
1098    counts: SignCounts,
1099    /// Slot of the single cost-less lot of this currency, if there is one —
1100    /// the lot a later cost-less `add` merges into.
1101    ///
1102    /// Folded in here rather than kept in its own map because every reader
1103    /// wants it alongside the totals: `add` looked the currency up once for
1104    /// the total and again for this, and `add_headroom_for` did the same, so
1105    /// each posting hashed the same interned string twice for no reason. The
1106    /// two have identical lifetimes — neither is ever pruned, both are
1107    /// `#[serde(skip)]` and both are rebuilt together by `rebuild_caches` —
1108    /// so there was never a state one could describe and the other could not.
1109    simple_slot: Option<usize>,
1110}
1111
1112/// How many positions of a currency fall in each (sign, cost-bearing) bucket.
1113///
1114/// Buckets keyed on `Decimal::is_sign_positive`, which is the exact predicate
1115/// [`Inventory::is_reduced_by`] uses — note it answers `true` for zero, and
1116/// the scan it replaces counted empty positions too, so this must as well.
1117#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1118struct SignCounts {
1119    /// Cost-bearing lots whose units are sign-positive.
1120    cost_positive: u32,
1121    /// Cost-bearing lots whose units are sign-negative.
1122    cost_negative: u32,
1123    /// Cost-less lots whose units are sign-positive.
1124    simple_positive: u32,
1125    /// Cost-less lots whose units are sign-negative.
1126    simple_negative: u32,
1127}
1128
1129impl SignCounts {
1130    /// Count of positions in the bucket opposite to `units_is_positive`.
1131    const fn opposite(self, units_is_positive: bool, scope: ReductionScope) -> u32 {
1132        let (cost, simple) = if units_is_positive {
1133            (self.cost_negative, self.simple_negative)
1134        } else {
1135            (self.cost_positive, self.simple_positive)
1136        };
1137        match scope {
1138            // Saturating, not `+`: these are counts of lots and a wrap would
1139            // read as "no matching lot", which books a reduction as an
1140            // augmentation and duplicates the lot. Saturation errs the other
1141            // way, and `> 0` is all the caller asks.
1142            ReductionScope::AllPositions => cost.saturating_add(simple),
1143            ReductionScope::CostBearingOnly => cost,
1144        }
1145    }
1146
1147    /// `delta` is `i32` rather than `i64` so it feeds `saturating_add_signed`
1148    /// directly. The earlier `i64` version ended in `try_into().unwrap_or(0)`,
1149    /// which turns a caller mistake into a silent no-op — the one outcome that
1150    /// leaves the counts wrong with nothing to show for it.
1151    fn bump(&mut self, has_cost: bool, is_positive: bool, delta: i32) {
1152        debug_assert!(
1153            delta == 1 || delta == -1,
1154            "counts move one lot at a time; {delta} means a caller lost track",
1155        );
1156        let slot = match (has_cost, is_positive) {
1157            (true, true) => &mut self.cost_positive,
1158            (true, false) => &mut self.cost_negative,
1159            (false, true) => &mut self.simple_positive,
1160            (false, false) => &mut self.simple_negative,
1161        };
1162        // Saturating: an under-count can only make `is_reduced_by` answer
1163        // "not a reduction" and fall back to augmentation, where a wrapped
1164        // u32 would claim billions of matching lots.
1165        *slot = slot.saturating_add_signed(delta);
1166    }
1167}
1168
1169/// Where the positions a cache rebuild is reading came from.
1170///
1171/// Only affects whether the one-cost-less-lot-per-currency invariant is
1172/// ASSERTED. It is a genuine invariant of positions this type built, and a
1173/// `debug_assert` there earns its keep as an internal-bug tripwire — but a
1174/// deserialized payload is input, and input must not be able to panic us.
1175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1176enum CacheSource {
1177    /// Positions this inventory produced; the invariant holds.
1178    Internal,
1179    /// Positions from outside — a deserialized payload.
1180    Untrusted,
1181}
1182
1183/// Deserialization shape for [`Inventory`]: the persisted field only.
1184///
1185/// Exists so `From` can rebuild the derived caches — see the note on
1186/// `Inventory`. Kept private; the wire format is unchanged (a struct with a
1187/// `positions` sequence), so this is not a compatibility break.
1188#[derive(Deserialize)]
1189struct InventoryWire {
1190    // NOT `#[serde(default)]`. The derive this replaces made `positions`
1191    // required, so `{}` was `Err("missing field `positions`")`; defaulting it
1192    // would quietly accept a malformed payload as an empty inventory.
1193    positions: Vector<Position>,
1194}
1195
1196impl TryFrom<InventoryWire> for Inventory {
1197    type Error = OverflowError;
1198
1199    /// `TryFrom`, not `From`: rebuilding the caches sums a currency's positions,
1200    /// and that sum can overflow on a payload nobody sane wrote.
1201    ///
1202    /// `rebuild_index` accumulates with `+=`, which PANICS on `Decimal`
1203    /// overflow — so two `Decimal::MAX` USD lots aborted inside `Deserialize`
1204    /// with "Addition overflowed" rather than returning a serde error. Review
1205    /// catch; a deserialization boundary must not panic on its input, the same
1206    /// rule that applies to the parser. The rebuild now uses `checked_add` and
1207    /// the failure arrives as `Err`, which serde reports as a normal
1208    /// deserialization error.
1209    fn try_from(wire: InventoryWire) -> Result<Self, Self::Error> {
1210        let mut inv = Self {
1211            positions: PositionStore::Owned(Slots::from_live(wire.positions.into_iter().collect())),
1212            units_cache: FxHashMap::default(),
1213            cost_index: FxHashMap::default(),
1214            ordered_index: None,
1215            undo_open: false,
1216            #[cfg(debug_assertions)]
1217            undo_witness: None,
1218        };
1219        // UNTRUSTED: the payload is input, not something this type produced, so
1220        // it may carry two cost-less lots for one currency — a state the
1221        // invariant forbids. `rebuild_index`'s `debug_assert` is there to catch
1222        // an internal bug; reaching it from deserialization would turn a
1223        // malformed document into a panic at the boundary.
1224        inv.try_rebuild_index_from(CacheSource::Untrusted)?;
1225        Ok(inv)
1226    }
1227}
1228
1229impl PartialEq for Inventory {
1230    fn eq(&self, other: &Self) -> bool {
1231        // Only compare positions, not the index (which is derived data)
1232        self.positions.iter().eq(other.positions.iter())
1233    }
1234}
1235
1236impl Eq for Inventory {}
1237
1238impl Inventory {
1239    /// Create an empty inventory.
1240    #[must_use]
1241    pub fn new() -> Self {
1242        Self::default()
1243    }
1244
1245    /// Iterate over all positions.
1246    ///
1247    /// Previously returned `&[Position]`; now returns an iterator
1248    /// because the underlying storage is a tree-based persistent
1249    /// vector (`imbl::Vector`) that doesn't expose a contiguous slice.
1250    /// Most callers already iterate — for callers that need
1251    /// random-access / indexed / `.len()` slice semantics, see
1252    /// [`Self::position_list`].
1253    pub fn positions(&self) -> impl Iterator<Item = &Position> + '_ {
1254        self.positions.iter()
1255    }
1256
1257    /// Materialize all positions as a `Vec<&Position>` for slice-style
1258    /// access (indexing, `.len()`, `.first()`, `.is_empty()`).
1259    ///
1260    /// Allocates `O(N)` pointers per call. Callers that only iterate
1261    /// once should use [`Self::positions`] instead — this is for code
1262    /// paths that need slice semantics.
1263    #[must_use]
1264    pub fn position_list(&self) -> Vec<&Position> {
1265        self.positions.iter().collect()
1266    }
1267
1268    /// Drop tombstones once they outnumber live lots, so slots stay within 2x
1269    /// the real position count and iteration cannot degrade toward "every lot
1270    /// this account ever held".
1271    ///
1272    /// Renumbers slots, so it must not run while an undo log is open or while
1273    /// any caller holds a slot index. The engine calls it after a transaction
1274    /// commits, which is the one moment both hold.
1275    ///
1276    /// Amortized: each compaction is O(slots) but halves them, so the cost per
1277    /// removed lot is constant.
1278    ///
1279    /// # Panics
1280    ///
1281    /// Panics in debug builds if an undo log is open.
1282    pub fn compact_if_sparse(&mut self) {
1283        debug_assert!(
1284            !self.undo_open,
1285            "compact_if_sparse would renumber slots the open undo log refers to",
1286        );
1287        if self.positions.dead() > self.positions.len() {
1288            self.positions.compact_slots();
1289            self.rebuild_index();
1290        }
1291    }
1292
1293    /// Begin recording an undo log so a failed transaction can be reverted
1294    /// without having copied this inventory.
1295    ///
1296    /// `apply` used to snapshot every touched account with `Inventory::clone`.
1297    /// That was written when the backing was `imbl::Vector` and the clone was
1298    /// O(1); since #2056 booking's backing is owned, so it became O(lots) per
1299    /// touched account per transaction — the largest superlinear term left in
1300    /// the pipeline, worth 56% of a 20,000-transaction `investment` run.
1301    ///
1302    /// A reduction touches one or two lots. Recording those is proportional to
1303    /// what changed instead of to what the account holds.
1304    ///
1305    /// # Panics
1306    ///
1307    /// Panics in debug builds if a log is already open — nesting would make
1308    /// "restore to the start" ambiguous.
1309    pub fn begin_undo(&mut self) {
1310        debug_assert!(
1311            !self.undo_open,
1312            "begin_undo called twice without commit or rollback",
1313        );
1314        // Recording only exists on the owned backing. A shared inventory would
1315        // set `undo_open` while capturing nothing, and rollback would then
1316        // restore nothing while reporting success — silent corruption rather
1317        // than a visible failure.
1318        //
1319        // Unreachable today: `BookingEngine` populates its map solely through
1320        // `entry().or_default()`, which is owned, and accepts no inventory from
1321        // outside. Asserted so it stays that way.
1322        debug_assert!(
1323            matches!(self.positions, PositionStore::Owned(_)),
1324            "begin_undo on a shared inventory would record nothing and roll \
1325             back nothing",
1326        );
1327        self.undo_open = true;
1328        #[cfg(debug_assertions)]
1329        {
1330            // The log is only as good as its coverage of the mutation paths.
1331            // Recording lives in the backing's primitives, which is complete by
1332            // construction today — this keeps it honest if that ever stops
1333            // being true, at a cost paid only in debug builds.
1334            self.undo_witness = Some(Box::new(self.clone_for_witness()));
1335        }
1336        self.positions.begin_undo();
1337    }
1338
1339    /// Whether an undo log is currently open.
1340    #[must_use]
1341    pub const fn undo_is_open(&self) -> bool {
1342        self.undo_open
1343    }
1344
1345    /// Discard the log — the transaction committed.
1346    pub fn commit_undo(&mut self) {
1347        self.undo_open = false;
1348        #[cfg(debug_assertions)]
1349        {
1350            self.undo_witness = None;
1351        }
1352        self.positions.commit_undo();
1353    }
1354
1355    /// Restore this inventory to its state at [`Self::begin_undo`].
1356    ///
1357    /// Rebuilds the derived caches wholesale rather than unwinding them: this
1358    /// is the failure path, so being obviously right beats being fast.
1359    ///
1360    /// # Panics
1361    ///
1362    /// Panics in debug builds if the result differs from a witness copy taken
1363    /// at `begin_undo` — that means a mutation path bypassed the log.
1364    pub fn rollback_undo(&mut self) {
1365        self.undo_open = false;
1366        self.positions.rollback_undo();
1367        self.rebuild_index();
1368        #[cfg(debug_assertions)]
1369        {
1370            if let Some(witness) = self.undo_witness.take() {
1371                let restored: Vec<&Position> = self.positions.iter().collect();
1372                let expected: Vec<&Position> = witness.positions.iter().collect();
1373                assert_eq!(
1374                    restored, expected,
1375                    "rollback did not restore the inventory: some mutation path \
1376                     did not go through the backing's primitives, so the undo \
1377                     log missed it",
1378                );
1379            }
1380        }
1381    }
1382
1383    /// A copy for the debug-only rollback witness.
1384    #[cfg(debug_assertions)]
1385    fn clone_for_witness(&self) -> Self {
1386        let mut copy = self.clone();
1387        copy.undo_witness = None;
1388        copy.undo_open = false;
1389        copy
1390    }
1391
1392    /// Rewrite the positions wholesale, then rebuild every derived cache.
1393    ///
1394    /// Replaces the old `positions_mut`, which handed out `&mut Vec<Position>`
1395    /// directly. That is no longer possible — the backing is sparse, so the
1396    /// vector holds `Option<Position>` alongside a live count, and a caller
1397    /// writing through it could desync that count with no way to notice.
1398    /// It also left `units_cache` and `simple_index` describing the OLD
1399    /// contents, which this rebuilds for you.
1400    ///
1401    /// Pre-1.0 break: the closure sees a dense `Vec<Position>` with tombstones
1402    /// already dropped, and whatever it leaves becomes the inventory.
1403    pub fn modify_positions(&mut self, f: impl FnOnce(&mut Vec<Position>)) {
1404        let mut dense: Vec<Position> = self.positions.iter().cloned().collect();
1405        f(&mut dense);
1406        self.positions = PositionStore::Owned(Slots::from_live(dense));
1407        self.rebuild_index();
1408    }
1409
1410    /// An inventory whose positions are structurally SHARED.
1411    ///
1412    /// For accumulators that are cloned far more often than they are mutated
1413    /// — BQL's JOURNAL running balance, which emits one snapshot per output
1414    /// row. Cloning is O(1) and successive snapshots share structure, so N
1415    /// rows cost O(base + sum of deltas) instead of O(N x base) (#1086).
1416    ///
1417    /// Everything else should use [`Inventory::new`]: the default contiguous
1418    /// backing is what makes booking's `reduce` cheap, and `reduce` converts
1419    /// to it anyway.
1420    #[must_use]
1421    pub fn new_shared() -> Self {
1422        Self {
1423            positions: PositionStore::Shared(Vector::new()),
1424            ..Self::default()
1425        }
1426    }
1427
1428    /// Check if inventory is empty.
1429    #[must_use]
1430    pub fn is_empty(&self) -> bool {
1431        self.positions.is_empty()
1432            || self
1433                .positions
1434                .iter()
1435                .all(super::position::Position::is_empty)
1436    }
1437
1438    /// Get the number of positions (including empty ones).
1439    #[must_use]
1440    pub fn len(&self) -> usize {
1441        self.positions.len()
1442    }
1443
1444    /// Get total units of a currency (ignoring cost lots).
1445    ///
1446    /// This sums all positions of the given currency regardless of cost basis.
1447    /// Uses an internal cache for O(1) lookups.
1448    #[must_use]
1449    pub fn units(&self, currency: &str) -> Decimal {
1450        // Use the cache when it is there. A miss is not a bug: the cache is
1451        // `#[serde(skip)]`, and while deserialization rebuilds it (the
1452        // `#[serde(try_from = "InventoryWire"]` on the struct), an inventory
1453        // built some other way may not have one yet. Recomputing is O(lots)
1454        // but always right.
1455        //
1456        // (This used to point callers at `rebuild_caches()`, which has never
1457        // existed under that name — `rebuild_index` is the real one, and
1458        // callers do not need it on the deserialize path any more.)
1459        self.units_cache.get(currency).map_or_else(
1460            || {
1461                // Fallback to computation if cache miss (e.g., after deserialization)
1462                self.positions
1463                    .iter()
1464                    .filter(|p| p.units.currency == currency)
1465                    .map(|p| p.units.number)
1466                    .sum()
1467            },
1468            |stats| stats.total,
1469        )
1470    }
1471
1472    /// Whether every `add` of `currency` totaling at most `needed` in absolute
1473    /// value is guaranteed not to overflow.
1474    ///
1475    /// `add` overflows at exactly two `checked_add`s: the per-currency running
1476    /// total, and — for a cost-less position — the single merged lot that
1477    /// `simple_index` points at. Both operands are bounded here against
1478    /// `needed`, so a `true` answer means no sequence of adds whose magnitudes
1479    /// sum to `needed` can overflow either, at any intermediate step: every
1480    /// partial sum is bounded by the total.
1481    ///
1482    /// Conservative by construction — `false` only ever means "cannot prove
1483    /// it", never "will overflow". Callers use it to skip work that exists
1484    /// solely to recover from overflow (#1897).
1485    #[must_use]
1486    pub fn add_headroom_for(&self, currency: &str, needed: Decimal) -> bool {
1487        // Enforce the magnitude contract rather than trusting it. A negative
1488        // `needed` would make the sums below SMALLER and hand back `true` when
1489        // overflow is possible — and an unsound `true` here means `apply`
1490        // skips the snapshot it needed, so earlier postings of a failing
1491        // transaction cannot be rolled back. Cheap insurance on a `pub` method
1492        // whose failure mode is silent corruption.
1493        let needed = needed.abs();
1494
1495        // `units_cache` and `simple_index` are `#[serde(skip)]`, so a
1496        // deserialized inventory carries its positions with both caches empty
1497        // until `rebuild_caches` runs. Reading them in that state answers
1498        // "plenty of room" for an inventory sitting at the ceiling — an
1499        // unsound `true`, which is the one direction this method must never
1500        // fail in. Refuse to answer instead. `units()` handles the same gap by
1501        // recomputing from `positions`; that is O(positions) and this is meant
1502        // to be O(1), so the conservative answer is the right trade here.
1503        if self.units_cache.is_empty() && !self.positions.is_empty() {
1504            return false;
1505        }
1506
1507        // `checked_add` is the whole test: it returns `None` exactly when the
1508        // sum is not representable, so any `Some` it hands back is already a
1509        // `Decimal` and therefore already `<= Decimal::MAX`. Comparing against
1510        // the ceiling as well was a tautology, and not a free one — `Decimal`'s
1511        // `PartialOrd` aligns the scales of both operands before it can answer,
1512        // and `Decimal::MAX` has scale 0 and a full 96-bit mantissa, so it was
1513        // the most expensive shape that comparison has. This runs twice per
1514        // posting via `overflow_is_possible`.
1515        let fits = |v: Decimal| v.abs().checked_add(needed).is_some();
1516
1517        // One lookup for both halves: the running total and the cost-less lot
1518        // a merge would land in.
1519        //
1520        // No entry means this inventory holds nothing of `currency`, so
1521        // nothing can overflow — and it is the same answer the two-map version
1522        // gave, which fell through to the lot check here. That fall-through
1523        // could never find anything: `add` writes the totals before the slot,
1524        // `rebuild_caches` writes both in one pass, and only the slot is ever
1525        // cleared, so a recorded slot always had a stats entry beside it. The
1526        // wholly-empty cache of a just-deserialized inventory is refused
1527        // above, which is the case where this reasoning would not hold.
1528        let Some(stats) = self.units_cache.get(currency) else {
1529            return true;
1530        };
1531        if !fits(stats.total) {
1532            return false;
1533        }
1534        // Only a cost-less add merges, and `simple_slot` names the one lot it
1535        // would merge into.
1536        stats
1537            .simple_slot
1538            .and_then(|idx| self.positions.get(idx))
1539            .is_none_or(|lot| fits(lot.units.number))
1540    }
1541
1542    /// Get all currencies in this inventory.
1543    #[must_use]
1544    pub fn currencies(&self) -> Vec<&str> {
1545        let mut currencies: Vec<&str> = self
1546            .positions
1547            .iter()
1548            .filter(|p| !p.is_empty())
1549            .map(|p| p.units.currency.as_str())
1550            .collect();
1551        currencies.sort_unstable();
1552        currencies.dedup();
1553        currencies
1554    }
1555
1556    /// Check if the given units would reduce (not augment) this inventory.
1557    ///
1558    /// Returns `true` if there's a position with the same currency but opposite
1559    /// sign, meaning these units would reduce the inventory rather than add to it.
1560    ///
1561    /// When `has_cost_spec` is `true`, only positions **with** a cost basis are
1562    /// considered for reduction matching.  Simple (no-cost) positions are ignored
1563    /// because they live in a different "cost layer" — a sell-without-cost-spec
1564    /// that left a negative simple position should not cause a subsequent
1565    /// cost-bearing augmentation to be misclassified as a reduction.
1566    /// See: issue #875, beancount#889.
1567    ///
1568    /// This is used to determine whether a posting is a sale/reduction or a
1569    /// purchase/augmentation.
1570    #[must_use]
1571    pub fn is_reduced_by(&self, units: &Amount, scope: ReductionScope) -> bool {
1572        // `units_cache` is `#[serde(skip)]` like `simple_index`. An empty
1573        // one over non-empty positions means it has not been built yet, and
1574        // reading it then would answer "not a reduction" for an inventory that
1575        // holds matching lots — booking the posting as an augmentation and
1576        // silently creating a duplicate lot. Fall back to the scan, as
1577        // `units()` does for the same gap.
1578        if self.units_cache.is_empty() && !self.positions.is_empty() {
1579            return self.is_reduced_by_scan(units, scope);
1580        }
1581
1582        let answer = self.units_cache.get(&units.currency).is_some_and(|stats| {
1583            stats
1584                .counts
1585                .opposite(units.number.is_sign_positive(), scope)
1586                > 0
1587        });
1588
1589        // The index is maintained incrementally by `add` and the reduction
1590        // commit paths; a missed update is a wrong answer, not a slow one.
1591        debug_assert_eq!(
1592            answer,
1593            self.is_reduced_by_scan(units, scope),
1594            "the cached sign counts disagree with a scan of positions — some \
1595             mutation path changed a lot without maintaining them",
1596        );
1597        answer
1598    }
1599
1600    /// The scan [`Self::is_reduced_by`] replaced, kept as the definition the
1601    /// index is checked against and as the fallback for an unbuilt index.
1602    fn is_reduced_by_scan(&self, units: &Amount, scope: ReductionScope) -> bool {
1603        self.positions.iter().any(|pos| {
1604            pos.units.currency == units.currency
1605                && pos.units.number.is_sign_positive() != units.number.is_sign_positive()
1606                && match scope {
1607                    ReductionScope::AllPositions => true,
1608                    ReductionScope::CostBearingOnly => pos.cost.is_some(),
1609                }
1610        })
1611    }
1612
1613    /// Whether a posting of `units` carrying `cost` would REDUCE this inventory
1614    /// under `method` — the single source for the reduction-vs-augmentation
1615    /// decision shared by the booking engine (`BookingEngine::apply`) and the
1616    /// Late validator's inventory pass.
1617    ///
1618    /// A posting reduces only when it carries a cost spec (`cost.is_some()` —
1619    /// presence of the spec, which includes an empty/unresolved one like `{}`),
1620    /// the booking method isn't `NONE` (issue #1182 — `NONE` accumulates every
1621    /// posting as an augmentation, with no lot matching), and the inventory holds
1622    /// a cost-bearing position of the opposite sign in the same currency
1623    /// ([`Self::is_reduced_by`] with [`ReductionScope::CostBearingOnly`]). This
1624    /// gate was previously written byte-for-byte in both crates and the #1182 fix
1625    /// had to be applied twice.
1626    #[must_use]
1627    pub fn is_booking_reduction(
1628        &self,
1629        units: &Amount,
1630        cost: Option<&CostSpec>,
1631        method: BookingMethod,
1632    ) -> bool {
1633        method != BookingMethod::None
1634            && cost.is_some()
1635            && self.is_reduced_by(units, ReductionScope::CostBearingOnly)
1636    }
1637
1638    /// Get the total book value (cost basis) for a currency.
1639    ///
1640    /// Returns the sum of all cost bases for positions of the given currency.
1641    ///
1642    /// # Errors
1643    ///
1644    /// [`OverflowError`] when a position's book value, or the running
1645    /// per-currency total, leaves `rust_decimal`'s range.
1646    pub fn book_value(
1647        &self,
1648        units_currency: &str,
1649    ) -> Result<FxHashMap<crate::Currency, Decimal>, OverflowError> {
1650        let mut totals: FxHashMap<crate::Currency, Decimal> = FxHashMap::default();
1651
1652        for pos in self.positions.iter() {
1653            if pos.units.currency == units_currency {
1654                // NOT `pos.book_value()`: its `None` conflates "no cost" with
1655                // "product out of range", and skipping the latter would drop a
1656                // position from the total silently — the same class of bug as
1657                // clamping it (#1863).
1658                let Some(cost) = pos.cost.as_ref() else {
1659                    continue;
1660                };
1661                let overflow = || OverflowError {
1662                    currency: cost.currency.clone(),
1663                };
1664                let book = cost.total_cost(pos.units.number).ok_or_else(overflow)?;
1665                let slot = totals.entry(book.currency.clone()).or_default();
1666                *slot = slot.checked_add(book.number).ok_or_else(overflow)?;
1667            }
1668        }
1669
1670        Ok(totals)
1671    }
1672
1673    /// Add a position to the inventory.
1674    ///
1675    /// For positions without cost, this merges with existing positions
1676    /// of the same currency using O(1) `HashMap` lookup.
1677    ///
1678    /// For positions with cost, this adds as a new lot (O(1)).
1679    /// Lot aggregation for display purposes is handled separately at output time
1680    /// (e.g., in the query result formatter).
1681    ///
1682    /// # TLA+ Specification
1683    ///
1684    /// Implements `AddAmount` action from `Conservation.tla`:
1685    /// - Invariant: `inventory + totalReduced = totalAdded`
1686    /// - After add: `totalAdded' = totalAdded + amount`
1687    ///
1688    /// See: `spec/tla/Conservation.tla`
1689    ///
1690    /// # Errors
1691    ///
1692    /// [`OverflowError`] when the running total for this currency leaves
1693    /// `rust_decimal`'s ~±7.9e28 range. The inventory is left UNCHANGED — the
1694    /// units cache is only committed once the merge is known to fit, so a
1695    /// caller that reports the error and moves on does not carry a
1696    /// half-applied position (#1863).
1697    pub fn add(&mut self, position: Position) -> Result<(), OverflowError> {
1698        if position.is_empty() {
1699            return Ok(());
1700        }
1701
1702        let overflow = || OverflowError {
1703            currency: position.units.currency.clone(),
1704        };
1705
1706        // Compute both running totals BEFORE mutating anything, so an overflow
1707        // leaves the inventory untouched rather than half-updated.
1708        let cached = self
1709            .units_cache
1710            .get(&position.units.currency)
1711            .map(|s| s.total)
1712            .unwrap_or_default();
1713        // Python `decimal` scale semantics, not raw `checked_add` — see
1714        // `crate::decimal::add_python_scale`. `rust_decimal` returns the other
1715        // operand untouched when one side is zero, so a running total that
1716        // passes through zero drops its scale and everything added after it
1717        // renders one scale narrower. That made a coalesced balance
1718        // ORDER-DEPENDENT: the same postings in a different order produced
1719        // `1` or `1.00` for the same money.
1720        let new_cached = crate::decimal::checked_add_python_scale(cached, position.units.number)
1721            .ok_or_else(overflow)?;
1722
1723        // Merge into an existing lot when this acquisition is indistinguishable
1724        // from one already held.
1725        //
1726        // Cost-less positions have always merged, through `simple_slot`. Lots
1727        // agreeing on commodity, cost per unit, cost currency, acquisition
1728        // date and label are indistinguishable in the same way: no attribute
1729        // the model records separates them, so keeping them apart only lets
1730        // the order they were WRITTEN decide which units a reduction consumes
1731        // (#2118). Merging makes the group one position, which is what it
1732        // already behaves as, and is what Python beancount stores.
1733        //
1734        // `cost_index` finds the target: it buckets by
1735        // `(units currency, cost number, cost currency)` in slot order, so
1736        // the first member agreeing on date and label is the one to join.
1737        // Buckets are a `SmallVec<[usize; 2]>`.
1738        let merge_idx = position.cost.as_ref().map_or_else(
1739            || {
1740                self.units_cache
1741                    .get(&position.units.currency)
1742                    .and_then(|s| s.simple_slot)
1743            },
1744            |cost| {
1745                let key = cost_key(&position)?;
1746                self.cost_index.get(&key)?.iter().copied().find(|&i| {
1747                    self.positions
1748                        .get(i)
1749                        .and_then(|p| p.cost.as_ref())
1750                        .is_some_and(|c| c.date == cost.date && c.label == cost.label)
1751                })
1752            },
1753        );
1754        let merged_units = merge_idx
1755            .map(|idx| {
1756                // Same rule as the units cache above — these two must agree,
1757                // or `units()` and the position itself report different scales
1758                // for the same currency.
1759                crate::decimal::checked_add_python_scale(
1760                    self.positions[idx].units.number,
1761                    position.units.number,
1762                )
1763                .ok_or_else(overflow)
1764            })
1765            .transpose()?;
1766
1767        // Bucket changes, worked out before touching the cache so the whole
1768        // update lands in ONE lookup below. A cost-less merge can flip the
1769        // lot's sign (adding -8 to a +3 lot), which moves it between buckets;
1770        // `is_sign_positive` answers true for zero, matching the predicate
1771        // `is_reduced_by` uses.
1772        let vacated = merge_idx.map(|idx| {
1773            let lot = &self.positions[idx];
1774            (lot.cost.is_some(), lot.units.number.is_sign_positive())
1775        });
1776        let occupied = (
1777            position.cost.is_some(),
1778            merged_units
1779                .unwrap_or(position.units.number)
1780                .is_sign_positive(),
1781        );
1782
1783        // ONE mutable lookup for the total AND the counts. `add` runs once per
1784        // posting, and the units cache is keyed by an interned string whose
1785        // `Hash` walks its bytes — this used to be a `get` plus an `insert`,
1786        // and hanging the counts off a second map made it three hashes per
1787        // posting, which measured as a regression on ledgers that book no
1788        // cost specs. `get_mut` first so only a currency's first lot pays for
1789        // an owned key.
1790        if let Some(stats) = self.units_cache.get_mut(&position.units.currency) {
1791            stats.total = new_cached;
1792            if let Some((had_cost, was_positive)) = vacated {
1793                stats.counts.bump(had_cost, was_positive, -1);
1794            }
1795            stats.counts.bump(occupied.0, occupied.1, 1);
1796        } else {
1797            // No entry yet means no lot of this currency has ever been added,
1798            // so there is nothing to vacate: `merge_idx` came from
1799            // `simple_index`, which only names a lot that `add` already
1800            // counted.
1801            debug_assert!(
1802                vacated.is_none(),
1803                "merging into a lot whose currency has no cached entry",
1804            );
1805            let mut counts = SignCounts::default();
1806            counts.bump(occupied.0, occupied.1, 1);
1807            self.units_cache.insert(
1808                position.units.currency.clone(),
1809                CurrencyStats {
1810                    total: new_cached,
1811                    counts,
1812                    simple_slot: None,
1813                },
1814            );
1815        }
1816
1817        // For positions without cost, use index for O(1) lookup
1818        // Apply the merge, for a cost-bearing lot as much as a cost-less one.
1819        // The target already carries the same cost, date and label, so only
1820        // its units move: no index changes, because the slot is already in
1821        // `cost_index` and in the ordered index at the position the group
1822        // occupies. That is what keeps the group's place stable when part of
1823        // it drains.
1824        if let Some(idx) = merge_idx {
1825            debug_assert_eq!(
1826                self.positions[idx].cost.is_none(),
1827                position.cost.is_none(),
1828                "a merge target must match the incoming lot's cost-ness",
1829            );
1830            self.positions[idx].units.number =
1831                merged_units.expect("merged_units is Some whenever merge_idx is");
1832            return Ok(());
1833        }
1834
1835        if position.cost.is_none() {
1836            // No existing position - add new one and index it
1837            // `push_slot`, not `len()`: with tombstones present the live
1838            // count is not the slot the lot lands in, and `simple_index`
1839            // stores slots.
1840            let currency = position.units.currency.clone();
1841            let idx = self.positions.push_slot(position);
1842            // The stats entry exists: the totals above were written before
1843            // this point for every currency that reaches here.
1844            self.units_cache.entry(currency).or_default().simple_slot = Some(idx);
1845            return Ok(());
1846        }
1847
1848        // For positions with cost, just add as a new lot.
1849        // This is O(1) and keeps all lots separate, matching Python beancount behavior.
1850        // Lot aggregation for display purposes is handled separately in query output.
1851        let key = cost_key(&position);
1852        // Every position, not only cost-bearing ones: an empty cost spec
1853        // matches a cost-less lot (`matches_cost_spec`: `(None, true)`), so
1854        // ordered selection can drain one, and an index that omitted them
1855        // picked a different lot than the scan.
1856        let ordering = position.units.currency.clone();
1857        let slot = self.positions.push_slot(position);
1858        if let Some(key) = key {
1859            self.cost_index.entry(key).or_default().push(slot);
1860        }
1861        self.ordered_index_insert(&ordering, slot);
1862        Ok(())
1863    }
1864
1865    /// Adjust `sign_index` for the position currently at `idx` by `delta`.
1866    ///
1867    /// Called with `-1` before changing or removing a lot and `+1` after, so
1868    /// a sign flip lands in the right bucket.
1869    /// Drop `idx` from [`Self::cost_index`]. Called wherever a lot is
1870    /// tombstoned, since the slot stays valid but the lot is gone.
1871    pub(super) fn cost_index_remove(&mut self, idx: usize) {
1872        let Some(position) = self.positions.get(idx) else {
1873            return;
1874        };
1875        // Only pay for the ordered index when one has been built: this runs on
1876        // every drained lot, and cloning the currency to probe a map that is
1877        // not there is pure overhead for a ledger that never reduces with an
1878        // under-specified spec.
1879        let ordered = self
1880            .ordered_index
1881            .is_some()
1882            .then(|| position.units.currency.clone());
1883        if let Some(key) = cost_key(position)
1884            && let Some(slots) = self.cost_index.get_mut(&key)
1885        {
1886            slots.retain(|slot| *slot != idx);
1887            if slots.is_empty() {
1888                self.cost_index.remove(&key);
1889            }
1890        }
1891        // The list is ordered, so find the entry rather than scanning for it:
1892        // a FIFO account drains its oldest lot over and over, and `retain`
1893        // walked every lot each time.
1894        let Some(currency) = ordered else {
1895            return;
1896        };
1897        let Some(index) = self.ordered_index.as_mut() else {
1898            return;
1899        };
1900        if let Some(slots) = index.by_currency.get_mut(&currency) {
1901            // Linear here rather than a binary search: `order_key` needs
1902            // `&self.positions`, which is already borrowed through `index`.
1903            // Removal is off the hot path — the walk is what this index exists
1904            // to speed up — and it keeps the ordering rule in one place.
1905            let at = slots.iter().position(|&existing| existing == idx);
1906            if let Some(at) = at {
1907                slots.remove(at);
1908            }
1909            if slots.is_empty() {
1910                index.by_currency.remove(&currency);
1911            }
1912        }
1913    }
1914
1915    /// Place `slot` under `currency`, keeping the list in (date, slot) order.
1916    ///
1917    /// Ledgers book in date order, so the new lot almost always belongs at the
1918    /// end and the search settles immediately; the binary search is what keeps
1919    /// an out-of-order lot correct rather than fast.
1920    fn ordered_index_insert(&mut self, currency: &crate::Currency, slot: usize) {
1921        // Maintain only an index that has been built. A ledger whose
1922        // reductions all resolve through `cost_index` never builds one and so
1923        // never pays for it: maintaining it from every `add` unconditionally
1924        // cost 6% on the `investment` shape, which never reads it.
1925        if !matches!(self.positions, PositionStore::Owned(_)) {
1926            return;
1927        }
1928        let Some(mut index) = self.ordered_index.take() else {
1929            return;
1930        };
1931        let order = index.order;
1932        let key = (self.order_key(order, slot), slot);
1933        // The index is OUT of `self` for the search, so the binary search can
1934        // read `self.positions` for each probe. Materializing the keys instead
1935        // — the obvious way around the borrow — makes every `add` walk the
1936        // whole currency, which is the quadratic this index exists to remove.
1937        let entry = index.by_currency.entry(currency.clone()).or_default();
1938        let at =
1939            entry.partition_point(|&existing| (self.order_key(order, existing), existing) < key);
1940        entry.insert(at, slot);
1941        self.ordered_index = Some(index);
1942    }
1943
1944    /// The value `order` sorts `slot` by.
1945    ///
1946    /// Ties fall through to the slot number, which preserves source order.
1947    /// That tiebreak is ascending for EVERY ordering, including
1948    /// the descending ones: a descending method reverses its KEY here, and
1949    /// nothing reverses the walk, because reversing the walk reverses the
1950    /// tiebreak with it (#2115).
1951    fn order_key(&self, order: LotOrder, slot: usize) -> OrderKey {
1952        let cost = self.positions.get(slot).and_then(|p| p.cost.as_ref());
1953        match order {
1954            LotOrder::Date => OrderKey::Date(cost.and_then(|c| c.date)),
1955            // Reversed KEY, never a reversed WALK. Reversing the walk reverses
1956            // the slot tiebreak along with it, which is how LIFO came to take
1957            // the LAST of two same-date lots while FIFO and HIFO take the
1958            // first (#2115). `Reverse` keeps the tiebreak ascending like its
1959            // siblings, and it sorts `None` LAST — exactly where the reversed
1960            // walk used to leave date-less lots, so only the tie moves.
1961            LotOrder::DateDescending => {
1962                OrderKey::DateDescending(Reverse(cost.and_then(|c| c.date)))
1963            }
1964            // Negated rather than reversed, for the same reason.
1965            //
1966            // A cost-less lot counts as zero rather than as `None`. `None`
1967            // sorts BEFORE `Some`, which would put cost-less lots at the front
1968            // of a highest-cost-first walk — the opposite of where the
1969            // `map_or(Decimal::ZERO, ..)` this replaces put them. An empty cost
1970            // spec matches a cost-less position, so HIFO can reach one.
1971            LotOrder::CostDescending => {
1972                OrderKey::CostDescending(-cost.map_or(Decimal::ZERO, |c| c.number))
1973            }
1974        }
1975    }
1976
1977    pub(super) fn build_ordered_index(&mut self, order: LotOrder) {
1978        if !matches!(self.positions, PositionStore::Owned(_)) {
1979            return;
1980        }
1981        let mut by_currency: FxHashMap<crate::Currency, Vec<usize>> = FxHashMap::default();
1982        for (idx, pos) in self.positions.iter_slots() {
1983            by_currency
1984                .entry(pos.units.currency.clone())
1985                .or_default()
1986                .push(idx);
1987        }
1988        for slots in by_currency.values_mut() {
1989            slots.sort_by_key(|&idx| (self.order_key(order, idx), idx));
1990        }
1991        self.ordered_index = Some(Box::new(OrderedIndex { order, by_currency }));
1992    }
1993
1994    /// Every slot of `currency` in FIFO order, or `None` when the index cannot
1995    /// answer and the caller must scan.
1996    ///
1997    /// Cost-less slots included — see the field's own note on why.
1998    fn ordered_candidates(&self, currency: &crate::Currency, order: LotOrder) -> Option<&[usize]> {
1999        let index = self.ordered_index.as_ref()?;
2000        // A different ordering answers a different question; scanning is the
2001        // only correct fallback until something rebuilds it.
2002        if index.order != order {
2003            return None;
2004        }
2005        Some(
2006            index
2007                .by_currency
2008                .get(currency)
2009                .map_or(&[][..], Vec::as_slice),
2010        )
2011    }
2012
2013    /// Slots that could satisfy `spec` for `units`, or `None` when the spec
2014    /// names no per-unit cost and therefore every lot is a candidate.
2015    ///
2016    /// Returned ascending so callers see the same order a scan would.
2017    fn cost_candidates(&self, units: &Amount, spec: &CostSpec) -> Option<Vec<usize>> {
2018        // An empty index means it was never built for this inventory — a
2019        // shared snapshot, or one that has not been rebuilt since. Scanning is
2020        // always correct, and answering from an index that is missing entries
2021        // is NOT: the lot would never reach the predicate. Falling back keeps
2022        // the only failure mode the harmless one.
2023        if self.cost_index.is_empty() {
2024            return None;
2025        }
2026        let number = spec.number.and_then(|n| n.per_unit())?;
2027        let currency = spec.currency.clone()?;
2028        let mut slots = self
2029            .cost_index
2030            .get(&(units.currency.clone(), number, currency))
2031            .cloned()
2032            .unwrap_or_default()
2033            .to_vec();
2034        slots.sort_unstable();
2035        Some(slots)
2036    }
2037
2038    pub(super) fn sign_index_bump(&mut self, idx: usize, delta: i32) {
2039        // All three call sites pass an index they just read or wrote, so this
2040        // is defensive only. Returning rather than panicking keeps a future
2041        // caller's off-by-one out of the panic path; the counts then disagree
2042        // with a scan, which `is_reduced_by`'s assertion reports in debug.
2043        debug_assert!(
2044            idx < self.positions.slot_count(),
2045            "sign_index_bump called with out-of-range index {idx}",
2046        );
2047        let Some(position) = self.positions.get(idx) else {
2048            return;
2049        };
2050        // Read the two bits the bucket depends on and drop the borrow. Cloning
2051        // the `Position` here instead — which is what the obvious version does
2052        // to satisfy the borrow checker — costs an `Arc` bump per currency plus
2053        // the lot's label on EVERY add, and this runs on the hot path.
2054        let has_cost = position.cost.is_some();
2055        let is_positive = position.units.number.is_sign_positive();
2056        if let Some(stats) = self.units_cache.get_mut(&position.units.currency) {
2057            stats.counts.bump(has_cost, is_positive, delta);
2058        }
2059        // No entry means no lots of this currency have been counted yet, which
2060        // only happens before `add` records the total. `add` inserts the entry
2061        // before calling this, and the rebuild path fills both together.
2062    }
2063
2064    /// Reduce positions from the inventory using the specified booking method.
2065    ///
2066    /// # Arguments
2067    ///
2068    /// * `units` - The units to reduce (negative for selling)
2069    /// * `cost_spec` - Optional cost specification for matching lots
2070    /// * `method` - The booking method to use
2071    ///
2072    /// # Returns
2073    ///
2074    /// Returns a `BookingResult` with the matched positions and cost basis,
2075    /// or a `BookingError` if the reduction cannot be performed.
2076    ///
2077    /// # TLA+ Specification
2078    ///
2079    /// Implements `ReduceAmount` action from `Conservation.tla`:
2080    /// - Invariant: `inventory + totalReduced = totalAdded`
2081    /// - After reduce: `totalReduced' = totalReduced + amount`
2082    /// - Precondition: `amount <= inventory` (else `InsufficientUnits` error)
2083    ///
2084    /// Lot selection follows these TLA+ specs based on `method`:
2085    /// - `Fifo`: `FIFOCorrect.tla` - Oldest lots first (`selected_date <= all other dates`)
2086    /// - `Lifo`: `LIFOCorrect.tla` - Newest lots first (`selected_date >= all other dates`)
2087    /// - `Hifo`: `HIFOCorrect.tla` - Highest cost first (`selected_cost >= all other costs`)
2088    ///
2089    /// See: `spec/tla/Conservation.tla`, `spec/tla/FIFOCorrect.tla`, etc.
2090    pub fn reduce(
2091        &mut self,
2092        units: &Amount,
2093        cost_spec: Option<&CostSpec>,
2094        method: BookingMethod,
2095    ) -> Result<BookingResult, BookingError> {
2096        let spec = cost_spec.cloned().unwrap_or_default();
2097
2098        // Force a uniquely-owned positions Vector before any reduction mutates
2099        // it. `self.positions` MAY be structurally shared — BQL snapshots build
2100        // `Shared` stores via `Inventory::new_shared` — and every reduction
2101        // method below mutates it in place (via `IndexMut` / `retain`).
2102        //
2103        // The sharing comes from BQL, not from booking. Since #2056 the store
2104        // is a hybrid and `PositionStore::default()` is `Owned(Vec)`, so the
2105        // booking engine's inventories are owned and any copy of one is a
2106        // DEEP O(lots) copy rather than an imbl O(1) one. This comment
2107        // asserted the opposite until #2061, and that wrong claim is a good
2108        // part of why the copy went unexamined for so long — `Position::clone`
2109        // was growing 104x for 10x the input on the `investment` profiling
2110        // shape.
2111        //
2112        // `BookingEngine::book` no longer takes such a copy per transaction —
2113        // it previews through `try_reduce`, which computes from `&self` via
2114        // the `plan_*` halves in `booking.rs`, and copies only for an account
2115        // with more than one reducing posting in the same transaction.
2116        //
2117        // Mutating a SHARED imbl `Vector` in place drives
2118        // `imbl-sized-chunks`' copy-on-write into a use-after-free of the
2119        // interned `Arc<str>` inside `Position` — heap corruption / SIGSEGV on
2120        // large ledgers with many lot reductions (found by the rich-workload
2121        // profiler). Rebuilding from cloned positions restores a refcount-1
2122        // Vector with correct `Arc` refcounting, so in-place mutation below has
2123        // no shared chunk to corrupt.
2124        self.positions.make_owned();
2125
2126        // Compaction does NOT run here. It renumbers slots, which would
2127        // invalidate an open undo log — and `apply` keeps one across the whole
2128        // transaction. `compact_if_sparse` is called by the engine after a
2129        // transaction commits, which is the only moment no slot index is held
2130        // and no rollback can still be required.
2131        // Compact here UNLESS a transaction is in flight. Compaction renumbers
2132        // slots and an open undo log refers to them, so `apply` defers it to
2133        // commit — but every other caller reduces without a log, and tying
2134        // compaction to `apply` alone would leave those inventories growing a
2135        // dead slot per closed lot forever.
2136        //
2137        // That is not hypothetical: the Late validator keeps its own
2138        // inventories across transactions and calls `reduce` directly, so it
2139        // would have accumulated one tombstone per sale for the life of the
2140        // ledger and scanned all of them on every reduction.
2141        //
2142        // No assertion about the tombstone ratio: a `{*}` merge legitimately
2143        // tombstones every matched lot and pushes one, so dead slots CAN
2144        // outnumber live ones mid-transaction.
2145        if !self.undo_open {
2146            self.compact_if_sparse();
2147        }
2148
2149        // Ordered selection walks lots in date order, so give it the index
2150        // that holds them that way — built here, on the first reduction that
2151        // will actually read it, and maintained incrementally afterwards. A
2152        // STRICT account resolves through `cost_index` instead and never
2153        // reaches this, which is why the build is gated rather than
2154        // unconditional (#2083).
2155        // Which ordering this account's method consumes, if any. STRICT
2156        // resolves through `cost_index` instead and never reaches the walk, so
2157        // it builds nothing.
2158        let wanted_order = match method {
2159            BookingMethod::Fifo => Some(LotOrder::Date),
2160            BookingMethod::Lifo => Some(LotOrder::DateDescending),
2161            BookingMethod::Hifo => Some(LotOrder::CostDescending),
2162            _ => None,
2163        };
2164        if let Some(order) = wanted_order
2165            && self.ordered_index.as_ref().is_none_or(|i| i.order != order)
2166        {
2167            self.build_ordered_index(order);
2168        }
2169
2170        // {*} merge operator: merge all lots into a single weighted-average-cost
2171        // lot before reducing, regardless of the account's booking method.
2172        if spec.merge {
2173            return self.reduce_merge(units);
2174        }
2175
2176        match method {
2177            BookingMethod::Strict => self.reduce_strict(units, &spec),
2178            BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
2179            BookingMethod::Fifo => self.reduce_fifo(units, &spec),
2180            BookingMethod::Lifo => self.reduce_lifo(units, &spec),
2181            BookingMethod::Hifo => self.reduce_hifo(units, &spec),
2182            BookingMethod::Average => self.reduce_average(units),
2183            BookingMethod::None => self.reduce_none(units),
2184        }
2185    }
2186
2187    /// Remove all empty positions.
2188    pub fn compact(&mut self) {
2189        self.positions.retain(|p| !p.is_empty());
2190        self.rebuild_index();
2191    }
2192
2193    /// Rebuild all caches (`simple_index` and `units_cache`) from positions.
2194    ///
2195    /// Called after operations that may invalidate them (`compact`'s retain) and
2196    /// on deserialization, which is what [`CacheSource`] distinguishes.
2197    fn rebuild_index(&mut self) {
2198        // Internal positions came through `add`, which already rejected any
2199        // sum that would overflow, so this cannot fail. Asserted rather than
2200        // ignored: a failure here would mean `add`'s check had a hole.
2201        // Call FIRST, assert on the result. Putting the call inside
2202        // `debug_assert!` compiles the rebuild itself out of release builds,
2203        // so `compact` would have left the caches stale — caught by clippy's
2204        // `debug_assert_with_mut_call`.
2205        let rebuilt = self.try_rebuild_index_from(CacheSource::Internal);
2206        debug_assert!(
2207            rebuilt.is_ok(),
2208            "internal positions summed past the Decimal range; `add` should \
2209             have rejected them",
2210        );
2211    }
2212
2213    fn try_rebuild_index_from(&mut self, source: CacheSource) -> Result<(), OverflowError> {
2214        self.units_cache.clear();
2215        self.cost_index.clear();
2216        // Preserve whether the ordered index has been BUILT, rather than
2217        // building it here. A rebuild happens on compaction and on rollback,
2218        // neither of which means ordered selection is in use — repopulating
2219        // unconditionally handed the index (and its maintenance cost) to every
2220        // ledger, including the ones whose reductions all resolve through
2221        // `cost_index`.
2222        let ordered_was_built = self.ordered_index.as_ref().map(|i| i.order);
2223        self.ordered_index = None;
2224
2225        // The cost index is for BOOKING, and only the owned backing books.
2226        //
2227        // Not a micro-optimization: `Inventory` derives `Clone` and BQL clones
2228        // a shared snapshot ONCE PER OUTPUT ROW (`running_balance.clone()` in
2229        // the executor). This map holds roughly an entry per distinct cost, so
2230        // building it for shared inventories would put O(lots) back into every
2231        // per-row clone — the O(rows x lots) blow-up that #1086 is about and
2232        // that the shared backing exists to avoid. Snapshots keep an empty map
2233        // and clone it for free.
2234        let index_costs = matches!(self.positions, PositionStore::Owned(_));
2235
2236        for (idx, pos) in self.positions.iter_slots() {
2237            if index_costs {
2238                if let Some(key) = cost_key(pos) {
2239                    self.cost_index.entry(key).or_default().push(idx);
2240                }
2241                if let Some(order) = ordered_was_built {
2242                    self.ordered_index
2243                        .get_or_insert_with(|| {
2244                            Box::new(OrderedIndex {
2245                                order,
2246                                by_currency: FxHashMap::default(),
2247                            })
2248                        })
2249                        .by_currency
2250                        .entry(pos.units.currency.clone())
2251                        .or_default()
2252                        .push(idx);
2253                }
2254            }
2255            // Update units cache for all positions. Checked, not `+=`:
2256            // `Decimal`'s `+` panics on overflow, and this runs over payloads.
2257            //
2258            // Must apply the SAME Python-scale rule as `add`, not a raw
2259            // `checked_add`. `units_cache` is `#[serde(skip)]`, so this is the
2260            // path that reconstructs it after a round-trip; if the two
2261            // disagreed, an inventory built incrementally and the same
2262            // inventory deserialized would report different scales for the
2263            // same money — measured at `1.00` built vs `1` rebuilt, across
2264            // three cost lots summing through zero. Pinned by
2265            // `a_round_trip_reports_the_same_scale_as_incremental_adds`.
2266            let slot = self
2267                .units_cache
2268                .entry(pos.units.currency.clone())
2269                .or_default();
2270            slot.counts
2271                .bump(pos.cost.is_some(), pos.units.number.is_sign_positive(), 1);
2272            slot.total = crate::decimal::checked_add_python_scale(slot.total, pos.units.number)
2273                .ok_or_else(|| OverflowError {
2274                    currency: pos.units.currency.clone(),
2275                })?;
2276
2277            // Record the cost-less lot only for positions without cost
2278            if pos.cost.is_none() {
2279                debug_assert!(
2280                    source == CacheSource::Untrusted
2281                        || self
2282                            .units_cache
2283                            .get(&pos.units.currency)
2284                            .is_none_or(|s| s.simple_slot.is_none()),
2285                    "Invariant violated: multiple simple positions for currency {}",
2286                    pos.units.currency
2287                );
2288                // Last-wins on a duplicate, matching the pre-existing behavior
2289                // of this write. `units_cache` sums every position either way,
2290                // so the total stays right; only which lot a later cost-less
2291                // `add` merges into is affected.
2292                self.units_cache
2293                    .entry(pos.units.currency.clone())
2294                    .or_default()
2295                    .simple_slot = Some(idx);
2296            }
2297        }
2298
2299        // The walk above pushed in slot order; ordered selection wants date
2300        // order with slot as the tiebreak. `sort_by_key` is stable, so the
2301        // slot order already there survives — the same two-level order
2302        // `plan_ordered` produced when it sorted per call.
2303        if let Some(order) = ordered_was_built {
2304            let keys: Vec<(crate::Currency, Vec<usize>)> = self
2305                .ordered_index
2306                .as_ref()
2307                .map(|i| {
2308                    i.by_currency
2309                        .iter()
2310                        .map(|(c, slots)| (c.clone(), slots.clone()))
2311                        .collect()
2312                })
2313                .unwrap_or_default();
2314            for (currency, mut slots) in keys {
2315                slots.sort_by_key(|&idx| (self.order_key(order, idx), idx));
2316                if let Some(index) = self.ordered_index.as_mut() {
2317                    index.by_currency.insert(currency, slots);
2318                }
2319            }
2320        }
2321        Ok(())
2322    }
2323
2324    /// Merge this inventory with another.
2325    ///
2326    /// # Errors
2327    ///
2328    /// [`OverflowError`] when a merged running total leaves `rust_decimal`'s
2329    /// range. `self` keeps the positions merged before the failure.
2330    pub fn merge(&mut self, other: &Self) -> Result<(), OverflowError> {
2331        for pos in other.positions.iter() {
2332            self.add(pos.clone())?;
2333        }
2334        Ok(())
2335    }
2336
2337    /// Convert inventory to cost basis.
2338    ///
2339    /// Returns a new inventory where all positions are converted to their
2340    /// cost basis. Positions without cost are returned as-is.
2341    ///
2342    /// # Errors
2343    ///
2344    /// [`OverflowError`] when a `units × cost` product, or the running total
2345    /// of those products, leaves `rust_decimal`'s range. Note this can fire on
2346    /// inputs far below the ceiling — the product overflows when neither
2347    /// operand does.
2348    pub fn at_cost(&self) -> Result<Self, OverflowError> {
2349        let mut result = Self::new();
2350
2351        for pos in self.positions.iter() {
2352            if pos.is_empty() {
2353                continue;
2354            }
2355
2356            if let Some(cost) = &pos.cost {
2357                // Convert to cost basis
2358                let total =
2359                    pos.units
2360                        .number
2361                        .checked_mul(cost.number)
2362                        .ok_or_else(|| OverflowError {
2363                            currency: cost.currency.clone(),
2364                        })?;
2365                result.add(Position::simple(Amount::new(total, &cost.currency)))?;
2366            } else {
2367                // No cost, keep as-is
2368                result.add(pos.clone())?;
2369            }
2370        }
2371
2372        Ok(result)
2373    }
2374
2375    /// Convert inventory to units only.
2376    ///
2377    /// Returns a new inventory where all positions have their cost removed,
2378    /// effectively aggregating by currency only.
2379    ///
2380    /// # Errors
2381    ///
2382    /// [`OverflowError`] when stripping costs merges lots whose combined units
2383    /// leave `rust_decimal`'s range.
2384    pub fn at_units(&self) -> Result<Self, OverflowError> {
2385        let mut result = Self::new();
2386
2387        for pos in self.positions.iter() {
2388            if pos.is_empty() {
2389                continue;
2390            }
2391
2392            // Strip cost, keep only units
2393            result.add(Position::simple(pos.units.clone()))?;
2394        }
2395
2396        Ok(result)
2397    }
2398}
2399
2400/// Sum the units of `currency` across `account` AND all of its sub-accounts,
2401/// over a map of per-account inventories.
2402///
2403/// Beancount's `balance Assets:Bank` assertion — and the pad math that targets
2404/// it — includes `Assets:Bank:Checking`, `Assets:Bank:Savings`, etc. (verified
2405/// against `bean-check`: an assertion on a parent passes when the balance is held
2406/// in a sub-account). Sub-account membership uses [`is_subaccount_or_equal`], so
2407/// the segment-boundary rule (`Assets:BankAlias` does NOT match `Assets:Bank`)
2408/// is shared.
2409///
2410/// This is the single source for that sum, used by both the booking pad engine
2411/// and the Late balance validator. They previously computed the pad/assertion
2412/// difference differently — booking summed only the leaf account
2413/// (`Inventory::units`) while the validator summed sub-accounts — so a pad
2414/// targeting a non-leaf account inserted the wrong synthetic amount.
2415///
2416/// Returns `None` when the sum leaves `rust_decimal`'s range (`Decimal`'s
2417/// `Sum` impl panics rather than wrapping). Both callers surface that as a
2418/// diagnostic on the assertion/pad rather than asserting against a clamped
2419/// total (#1863).
2420pub fn sum_account_and_subaccounts<'a, I>(
2421    inventories: I,
2422    account: &str,
2423    currency: &Currency,
2424) -> Option<Decimal>
2425where
2426    I: IntoIterator<Item = (&'a Account, &'a Inventory)>,
2427{
2428    inventories
2429        .into_iter()
2430        .filter(|(inv_account, _)| is_subaccount_or_equal(inv_account.as_str(), account))
2431        .try_fold(Decimal::ZERO, |acc, (_, inv)| {
2432            acc.checked_add(inv.units(currency))
2433        })
2434}
2435
2436impl fmt::Display for Inventory {
2437    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2438        if self.is_empty() {
2439            return write!(f, "(empty)");
2440        }
2441
2442        // Sort positions alphabetically by currency, then by cost for consistency
2443        let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
2444        non_empty.sort_by(|a, b| {
2445            // First by currency
2446            let cmp = a.units.currency.cmp(&b.units.currency);
2447            if cmp != std::cmp::Ordering::Equal {
2448                return cmp;
2449            }
2450            // Then by cost (if present)
2451            match (&a.cost, &b.cost) {
2452                (Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
2453                (Some(_), None) => std::cmp::Ordering::Greater,
2454                (None, Some(_)) => std::cmp::Ordering::Less,
2455                (None, None) => std::cmp::Ordering::Equal,
2456            }
2457        });
2458
2459        for (i, pos) in non_empty.iter().enumerate() {
2460            if i > 0 {
2461                write!(f, ", ")?;
2462            }
2463            write!(f, "{pos}")?;
2464        }
2465        Ok(())
2466    }
2467}
2468
2469impl Inventory {
2470    /// Build an inventory from positions.
2471    ///
2472    /// Replaces the former `FromIterator<Position>` impl, which was removed
2473    /// deliberately: `from_iter` cannot report failure, so it had to swallow
2474    /// the overflow from [`Self::add`] and hand back an inventory holding a
2475    /// wrong total with nothing to indicate it (#1863). A `collect()` that can
2476    /// silently lie is worse than no `collect()`.
2477    ///
2478    /// # Errors
2479    ///
2480    /// [`OverflowError`] when a running total leaves `rust_decimal`'s range.
2481    pub fn try_from_positions<I>(iter: I) -> Result<Self, OverflowError>
2482    where
2483        I: IntoIterator<Item = Position>,
2484    {
2485        let mut inv = Self::new();
2486        for pos in iter {
2487            inv.add(pos)?;
2488        }
2489        Ok(inv)
2490    }
2491}
2492
2493#[cfg(test)]
2494mod tests {
2495
2496    /// A deserialized inventory must not be reported as having headroom it
2497    /// does not have.
2498    ///
2499    /// The caches are `#[serde(skip)]`, so a round-trip once left `positions`
2500    /// populated and both caches empty. Deserialization now rebuilds them, so
2501    /// this passes because the cache is CORRECT rather than because
2502    /// `add_headroom_for` refuses to read an empty one. Both are checked: the
2503    /// defensive refusal stays as the second line of defense for any other way
2504    /// an inventory might reach that state (review catch on #1898).
2505    /// `new_shared` must actually produce the shared backing, and a serde
2506    /// round-trip must land back in `Owned`.
2507    ///
2508    /// Both are load-bearing and neither is visible from the public API: the
2509    /// backing is a private enum, so nothing outside this module can observe
2510    /// which one an inventory holds. Without this test, `new_shared` could
2511    /// quietly return the contiguous backing and the only symptom would be
2512    /// BQL's JOURNAL memory going from 31 MB back to 395 MB on a large
2513    /// ledger — a regression no unit test would catch. Copilot's catch on
2514    /// #2056.
2515    #[test]
2516    fn new_shared_is_shared_and_a_round_trip_is_owned() {
2517        let mut shared = Inventory::new_shared();
2518        assert!(
2519            matches!(shared.positions, PositionStore::Shared(_)),
2520            "new_shared must use the structurally-shared backing",
2521        );
2522
2523        // Adding must not silently convert it — the per-row snapshot in BQL
2524        // adds to this inventory between every clone.
2525        shared
2526            .add(Position::simple(Amount::new(dec!(5), "USD")))
2527            .expect("fits");
2528        assert!(
2529            matches!(shared.positions, PositionStore::Shared(_)),
2530            "add must keep the shared backing; converting here would restore \
2531             the O(rows x lots) blow-up #1086 is about",
2532        );
2533
2534        // ...but a reduction does convert, deliberately: it mutates heavily
2535        // and wants contiguous storage.
2536        let mut reduced = Inventory::new_shared();
2537        reduced
2538            .add(Position::simple(Amount::new(dec!(5), "USD")))
2539            .expect("fits");
2540        let _ = reduced.reduce(&Amount::new(dec!(-2), "USD"), None, BookingMethod::None);
2541        assert!(
2542            matches!(reduced.positions, PositionStore::Owned(_)),
2543            "reduce must switch to the contiguous backing",
2544        );
2545
2546        // The default constructor is contiguous.
2547        assert!(matches!(
2548            Inventory::new().positions,
2549            PositionStore::Owned(_)
2550        ));
2551
2552        // Serde carries a plain sequence and lands in `Owned`.
2553        let json = serde_json::to_string(&shared).expect("serializes");
2554        let back: Inventory = serde_json::from_str(&json).expect("deserializes");
2555        assert!(
2556            matches!(back.positions, PositionStore::Owned(_)),
2557            "a round-trip lands in the contiguous backing",
2558        );
2559        assert_eq!(back.units("USD"), dec!(5), "and preserves the positions");
2560    }
2561
2562    #[test]
2563    fn a_deserialized_inventory_refuses_to_claim_headroom() {
2564        let mut inv = Inventory::new();
2565        inv.add(Position::simple(Amount::new(Decimal::MAX, "USD")))
2566            .expect("one MAX position fits");
2567        assert!(!inv.add_headroom_for("USD", Decimal::ONE));
2568
2569        let round_tripped: Inventory =
2570            serde_json::from_str(&serde_json::to_string(&inv).expect("serialize"))
2571                .expect("deserialize");
2572
2573        assert!(
2574            !round_tripped.positions.is_empty(),
2575            "the positions survive the round-trip"
2576        );
2577        assert!(
2578            !round_tripped.units_cache.is_empty(),
2579            "and so do the caches now — deserialization rebuilds them"
2580        );
2581
2582        assert!(
2583            !round_tripped.add_headroom_for("USD", Decimal::ONE),
2584            "the inventory still holds Decimal::MAX"
2585        );
2586    }
2587
2588    /// A payload the type could not have produced must not panic us.
2589    ///
2590    /// Two cost-less lots for one currency violate the invariant
2591    /// `rebuild_index` asserts. That assert is a worthwhile internal-bug
2592    /// tripwire, but rebuilding on deserialization put it in reach of INPUT:
2593    /// this exact document panicked a debug build with "Invariant violated:
2594    /// multiple simple positions for currency USD". Caught reviewing the
2595    /// rebuild change, not present before it.
2596    ///
2597    /// Behavior matches what the plain derive did — the total is the sum, the
2598    /// lots are preserved — so nothing about malformed input changed except
2599    /// that the caches are now correct for it.
2600    #[test]
2601    fn a_payload_violating_the_lot_invariant_does_not_panic() {
2602        let json = r#"{"positions":[
2603            {"units":{"number":"100","currency":"USD"},"cost":null},
2604            {"units":{"number":"5","currency":"USD"},"cost":null}]}"#;
2605        let inv: Inventory = serde_json::from_str(json).expect("malformed input still loads");
2606        assert_eq!(inv.units("USD"), dec!(105), "the total sums every lot");
2607        assert_eq!(
2608            inv.positions().count(),
2609            2,
2610            "the lots are preserved as given"
2611        );
2612    }
2613
2614    /// A payload whose positions sum past the `Decimal` range is an ERROR,
2615    /// not a panic.
2616    ///
2617    /// Rebuilding the caches sums each currency's positions, and the rebuild
2618    /// used `+=`, which panics on `Decimal` overflow. Running it on
2619    /// deserialization put that inside `Deserialize`: two `Decimal::MAX` USD
2620    /// lots aborted with "Addition overflowed" instead of returning a serde
2621    /// error — a denial of service on any embedder deserializing untrusted input. Review
2622    /// catch on the rebuild change; the deep review that found the
2623    /// `debug_assert` panic missed this second one.
2624    ///
2625    /// Two lots are needed, and the first must carry a cost: a second cost-less
2626    /// lot for the same currency would be a different (also-tested) malformed
2627    /// shape, and the sum is what is being exercised here.
2628    #[test]
2629    fn a_payload_that_overflows_the_total_is_an_error_not_a_panic() {
2630        let max = Decimal::MAX.to_string();
2631        let json = format!(
2632            r#"{{"positions":[
2633                {{"units":{{"number":"{max}","currency":"USD"}},
2634                  "cost":{{"number":"1","currency":"EUR","date":null,"label":null}}}},
2635                {{"units":{{"number":"{max}","currency":"USD"}},"cost":null}}]}}"#
2636        );
2637        let err = serde_json::from_str::<Inventory>(&json)
2638            .expect_err("a total past the Decimal range cannot be represented");
2639        // `OverflowError`'s own wording, which serde surfaces verbatim — so
2640        // this also pins that the error reaching the caller is the domain one
2641        // rather than a generic "invalid value".
2642        assert!(
2643            err.to_string().contains("exceeds the representable range"),
2644            "expected the USD overflow error, got: {err}",
2645        );
2646    }
2647
2648    /// `positions` stays REQUIRED.
2649    ///
2650    /// The derive this replaced made it so, and routing deserialization through
2651    /// a wire struct is exactly the kind of change that silently relaxes it —
2652    /// a stray `#[serde(default)]` turns a malformed document into an empty
2653    /// inventory. It did, in the first draft of this change.
2654    #[test]
2655    fn a_payload_without_positions_is_rejected() {
2656        let err = serde_json::from_str::<Inventory>("{}")
2657            .expect_err("an inventory without positions is malformed");
2658        assert!(
2659            err.to_string().contains("missing field"),
2660            "expected a missing-field error, got: {err}",
2661        );
2662    }
2663
2664    /// Mutating a deserialized inventory must not corrupt it.
2665    ///
2666    /// This is the case the rebuild exists for. `add` trusts both caches: it
2667    /// reads `units_cache.get(..).unwrap_or_default()` as the running total and
2668    /// `simple_index` as the lot to merge into. With both empty it read 0 for an
2669    /// inventory already holding 100 USD, wrote that back as the new total, and
2670    /// appended a second cost-less USD lot instead of merging — so a round-tripped
2671    /// 100 USD inventory answered `units("USD") == 5` after adding 5, holding two
2672    /// lots where the type's own invariant allows one.
2673    ///
2674    /// `units()` and `add_headroom_for` both survived that state on their own —
2675    /// one recomputes, the other refuses — which is exactly why it went
2676    /// unnoticed: the read paths were guarded and the WRITE path was not.
2677    #[test]
2678    fn adding_to_a_deserialized_inventory_keeps_the_running_total() {
2679        let mut inv = Inventory::new();
2680        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
2681            .expect("fits");
2682
2683        let mut round_tripped: Inventory =
2684            serde_json::from_str(&serde_json::to_string(&inv).expect("serialize"))
2685                .expect("deserialize");
2686        assert_eq!(
2687            round_tripped.units("USD"),
2688            dec!(100),
2689            "the round-trip preserves the total"
2690        );
2691
2692        round_tripped
2693            .add(Position::simple(Amount::new(dec!(5), "USD")))
2694            .expect("fits");
2695
2696        assert_eq!(
2697            round_tripped.units("USD"),
2698            dec!(105),
2699            "add must extend the existing total, not replace it"
2700        );
2701        assert_eq!(
2702            round_tripped.positions().count(),
2703            1,
2704            "a cost-less add merges into the existing lot rather than appending"
2705        );
2706    }
2707
2708    /// `add_headroom_for` treats `needed` as a magnitude, whatever sign it
2709    /// arrives with.
2710    ///
2711    /// A negative `needed` would make the internal sums smaller and return
2712    /// `true` where overflow is possible. `apply` would then skip the snapshot
2713    /// it needed, leaving a failing transaction's earlier postings applied —
2714    /// silent corruption. Not reachable from the in-tree caller, which sums
2715    /// absolute values, but this is a `pub` method (review catch on #1898).
2716    #[test]
2717    fn add_headroom_for_reads_needed_as_a_magnitude() {
2718        let mut inv = Inventory::new();
2719        inv.add(Position::simple(Amount::new(Decimal::MAX, "USD")))
2720            .expect("one MAX position fits");
2721
2722        assert!(
2723            !inv.add_headroom_for("USD", Decimal::ONE),
2724            "at the ceiling, there is no room for one more unit"
2725        );
2726        assert!(
2727            !inv.add_headroom_for("USD", -Decimal::ONE),
2728            "and a negatively-signed magnitude must not manufacture room"
2729        );
2730        assert_eq!(
2731            inv.add_headroom_for("USD", Decimal::ONE),
2732            inv.add_headroom_for("USD", -Decimal::ONE),
2733            "the sign of `needed` cannot change the answer"
2734        );
2735    }
2736
2737    use super::*;
2738    use crate::Cost;
2739    use crate::NaiveDate;
2740    use rust_decimal_macros::dec;
2741
2742    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
2743        crate::naive_date(year, month, day).unwrap()
2744    }
2745
2746    #[test]
2747    fn test_empty_inventory() {
2748        let inv = Inventory::new();
2749        assert!(inv.is_empty());
2750        assert_eq!(inv.len(), 0);
2751    }
2752
2753    #[test]
2754    fn test_add_simple() {
2755        let mut inv = Inventory::new();
2756        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
2757            .expect("fixture fits in Decimal");
2758
2759        assert!(!inv.is_empty());
2760        assert_eq!(inv.units("USD"), dec!(100));
2761    }
2762
2763    #[test]
2764    fn test_add_merge_simple() {
2765        let mut inv = Inventory::new();
2766        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
2767            .expect("fixture fits in Decimal");
2768        inv.add(Position::simple(Amount::new(dec!(50), "USD")))
2769            .expect("fixture fits in Decimal");
2770
2771        // Should merge into one position
2772        assert_eq!(inv.len(), 1);
2773        assert_eq!(inv.units("USD"), dec!(150));
2774    }
2775
2776    #[test]
2777    fn test_add_with_cost_no_merge() {
2778        let mut inv = Inventory::new();
2779
2780        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
2781        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
2782
2783        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2784            .expect("fixture fits in Decimal");
2785        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
2786            .expect("fixture fits in Decimal");
2787
2788        // Should NOT merge - different costs
2789        assert_eq!(inv.len(), 2);
2790        assert_eq!(inv.units("AAPL"), dec!(15));
2791    }
2792
2793    #[test]
2794    fn test_currencies() {
2795        let mut inv = Inventory::new();
2796        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
2797            .expect("fixture fits in Decimal");
2798        inv.add(Position::simple(Amount::new(dec!(50), "EUR")))
2799            .expect("fixture fits in Decimal");
2800        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")))
2801            .expect("fixture fits in Decimal");
2802
2803        let currencies = inv.currencies();
2804        assert_eq!(currencies.len(), 3);
2805        assert!(currencies.contains(&"USD"));
2806        assert!(currencies.contains(&"EUR"));
2807        assert!(currencies.contains(&"AAPL"));
2808    }
2809
2810    #[test]
2811    fn test_reduce_strict_unique() {
2812        let mut inv = Inventory::new();
2813        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
2814        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
2815            .expect("fixture fits in Decimal");
2816
2817        let result = inv
2818            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
2819            .unwrap();
2820
2821        assert_eq!(inv.units("AAPL"), dec!(5));
2822        assert!(result.cost_basis.is_some());
2823        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); // 5 * 150
2824    }
2825
2826    #[test]
2827    fn test_reduce_strict_multiple_match_with_different_costs_is_ambiguous() {
2828        let mut inv = Inventory::new();
2829
2830        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
2831        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
2832
2833        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2834            .expect("fixture fits in Decimal");
2835        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
2836            .expect("fixture fits in Decimal");
2837
2838        // Per Python beancount: a wildcard reduction (`-3 AAPL` with no cost
2839        // spec) against an inventory with lots at different costs is
2840        // genuinely ambiguous and must error. Issue #737.
2841        let result = inv.reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict);
2842
2843        assert!(
2844            matches!(result, Err(BookingError::AmbiguousMatch { .. })),
2845            "expected AmbiguousMatch, got {result:?}"
2846        );
2847        // Inventory unchanged after a failed reduction
2848        assert_eq!(inv.units("AAPL"), dec!(15));
2849    }
2850
2851    #[test]
2852    fn test_reduce_strict_multiple_match_with_identical_costs_uses_fifo() {
2853        let mut inv = Inventory::new();
2854
2855        // Two lots with identical cost — interchangeable, so FIFO is fine.
2856        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
2857
2858        inv.add(Position::with_cost(
2859            Amount::new(dec!(10), "AAPL"),
2860            cost.clone(),
2861        ))
2862        .expect("fixture fits in Decimal");
2863        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost))
2864            .expect("fixture fits in Decimal");
2865
2866        let result = inv
2867            .reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
2868            .expect("identical lots should fall back to FIFO without error");
2869
2870        assert_eq!(inv.units("AAPL"), dec!(12));
2871        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00));
2872    }
2873
2874    #[test]
2875    fn test_reduce_strict_same_cost_different_dates_is_ambiguous() {
2876        // #2097. Two lots at the same cost number, differing only in
2877        // acquisition date. This used to drain them FIFO on the grounds that
2878        // the lots were interchangeable. They are not: whichever survives
2879        // carries its own date, and holding period drives the short/long
2880        // split in `report capgains` and per-lot IRR eligibility. Beancount
2881        // rejects it too — `booking_method_STRICT` has no fallback.
2882        let mut inv = Inventory::new();
2883
2884        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
2885        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 15));
2886
2887        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2888            .expect("fixture fits in Decimal");
2889        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
2890            .expect("fixture fits in Decimal");
2891
2892        let err = inv
2893            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
2894            .expect_err("a partial sale cannot choose between two dated lots");
2895        assert!(
2896            matches!(err, BookingError::AmbiguousMatch { num_matches: 2, .. }),
2897            "expected AmbiguousMatch over the two dated lots, got {err:?}"
2898        );
2899
2900        // And it left the inventory alone.
2901        assert_eq!(inv.units("AAPL"), dec!(20));
2902    }
2903
2904    #[test]
2905    fn test_reduce_strict_selling_every_matched_lot_is_not_ambiguous() {
2906        // The total-match exception, which beancount has too: consume every
2907        // matched lot and no lot survives to carry a date, so the choice
2908        // cannot be observed.
2909        let mut inv = Inventory::new();
2910
2911        inv.add(Position::with_cost(
2912            Amount::new(dec!(10), "AAPL"),
2913            Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15)),
2914        ))
2915        .expect("fixture fits in Decimal");
2916        inv.add(Position::with_cost(
2917            Amount::new(dec!(10), "AAPL"),
2918            Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 15)),
2919        ))
2920        .expect("fixture fits in Decimal");
2921
2922        let result = inv
2923            .reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Strict)
2924            .expect("selling the whole matched set names no lot to choose");
2925        assert_eq!(inv.units("AAPL"), dec!(0));
2926        assert_eq!(result.cost_basis.unwrap().number, dec!(3000.00));
2927    }
2928
2929    #[test]
2930    fn test_reduce_strict_multiple_match_total_match_exception() {
2931        let mut inv = Inventory::new();
2932
2933        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
2934        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
2935
2936        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2937            .expect("fixture fits in Decimal");
2938        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
2939            .expect("fixture fits in Decimal");
2940
2941        // Selling exactly the entire inventory (10 + 5 = 15) is unambiguous
2942        // even with mixed costs — the user is liquidating the position.
2943        let result = inv
2944            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Strict)
2945            .expect("total-match exception should accept a full liquidation");
2946
2947        assert_eq!(inv.units("AAPL"), dec!(0));
2948        // Cost basis = 10*150 + 5*160 = 1500 + 800 = 2300
2949        assert_eq!(result.cost_basis.unwrap().number, dec!(2300.00));
2950    }
2951
2952    #[test]
2953    fn test_reduce_strict_with_spec() {
2954        let mut inv = Inventory::new();
2955
2956        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
2957        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
2958
2959        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2960            .expect("fixture fits in Decimal");
2961        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
2962            .expect("fixture fits in Decimal");
2963
2964        // Reducing with cost spec should work
2965        let spec = CostSpec::empty().with_date(date(2024, 1, 1));
2966        let result = inv
2967            .reduce(
2968                &Amount::new(dec!(-3), "AAPL"),
2969                Some(&spec),
2970                BookingMethod::Strict,
2971            )
2972            .unwrap();
2973
2974        assert_eq!(inv.units("AAPL"), dec!(12)); // 7 + 5
2975        assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); // 3 * 150
2976    }
2977
2978    #[test]
2979    fn test_reduce_fifo() {
2980        let mut inv = Inventory::new();
2981
2982        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
2983        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
2984        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
2985
2986        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
2987            .expect("fixture fits in Decimal");
2988        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
2989            .expect("fixture fits in Decimal");
2990        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3))
2991            .expect("fixture fits in Decimal");
2992
2993        // FIFO should reduce from oldest (cost 100) first
2994        let result = inv
2995            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
2996            .unwrap();
2997
2998        assert_eq!(inv.units("AAPL"), dec!(15));
2999        // Cost basis: 10 * 100 + 5 * 150 = 1000 + 750 = 1750
3000        assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
3001    }
3002
3003    #[test]
3004    fn test_reduce_lifo() {
3005        let mut inv = Inventory::new();
3006
3007        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3008        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
3009        let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
3010
3011        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3012            .expect("fixture fits in Decimal");
3013        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
3014            .expect("fixture fits in Decimal");
3015        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3))
3016            .expect("fixture fits in Decimal");
3017
3018        // LIFO should reduce from newest (cost 200) first
3019        let result = inv
3020            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
3021            .unwrap();
3022
3023        assert_eq!(inv.units("AAPL"), dec!(15));
3024        // Cost basis: 10 * 200 + 5 * 150 = 2000 + 750 = 2750
3025        assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
3026    }
3027
3028    #[test]
3029    fn test_reduce_insufficient() {
3030        let mut inv = Inventory::new();
3031        let cost = Cost::new(dec!(150.00), "USD");
3032        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
3033            .expect("fixture fits in Decimal");
3034
3035        let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
3036
3037        assert!(matches!(
3038            result,
3039            Err(BookingError::InsufficientUnits { .. })
3040        ));
3041    }
3042
3043    #[test]
3044    fn test_book_value() {
3045        let mut inv = Inventory::new();
3046
3047        let cost1 = Cost::new(dec!(100.00), "USD");
3048        let cost2 = Cost::new(dec!(150.00), "USD");
3049
3050        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3051            .expect("fixture fits in Decimal");
3052        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
3053            .expect("fixture fits in Decimal");
3054
3055        let book = inv.book_value("AAPL").expect("fixture fits in Decimal");
3056        assert_eq!(book.get("USD"), Some(&dec!(1750.00))); // 10*100 + 5*150
3057    }
3058
3059    #[test]
3060    fn test_display() {
3061        let mut inv = Inventory::new();
3062        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
3063            .expect("fixture fits in Decimal");
3064
3065        let s = format!("{inv}");
3066        assert!(s.contains("100 USD"));
3067    }
3068
3069    #[test]
3070    fn test_display_empty() {
3071        let inv = Inventory::new();
3072        assert_eq!(format!("{inv}"), "(empty)");
3073    }
3074
3075    #[test]
3076    fn test_from_iterator() {
3077        let positions = vec![
3078            Position::simple(Amount::new(dec!(100), "USD")),
3079            Position::simple(Amount::new(dec!(50), "USD")),
3080        ];
3081
3082        let inv = Inventory::try_from_positions(positions).expect("fixture fits in Decimal");
3083        assert_eq!(inv.units("USD"), dec!(150));
3084    }
3085
3086    #[test]
3087    fn test_add_nets_a_negative_of_the_same_identity() {
3088        // Was `test_add_costed_positions_kept_separate`, which asserted that
3089        // costed lots never merge. Interchangeable lots are now one position
3090        // (#2118), so a negative of the same identity nets into it.
3091        let mut inv = Inventory::new();
3092
3093        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
3094
3095        // Buy 10 shares
3096        inv.add(Position::with_cost(
3097            Amount::new(dec!(10), "AAPL"),
3098            cost.clone(),
3099        ))
3100        .expect("fixture fits in Decimal");
3101        assert_eq!(inv.len(), 1);
3102        assert_eq!(inv.units("AAPL"), dec!(10));
3103
3104        // A negative of the same identity nets into the lot: interchangeable
3105        // positions are one position (#2118). The lot is left at zero units
3106        // rather than removed, which `units()` already accounted for.
3107        inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost))
3108            .expect("fixture fits in Decimal");
3109        assert_eq!(inv.len(), 1, "same identity nets into one position");
3110        assert_eq!(inv.units("AAPL"), dec!(0));
3111    }
3112
3113    /// A deserialized inventory must report the same scale as one built by
3114    /// incremental `add`s.
3115    ///
3116    /// `units_cache` is `#[serde(skip)]`, so `try_rebuild_index_from` is what
3117    /// reconstructs it after a round-trip. That rebuild re-sums the positions;
3118    /// if it used a raw `checked_add` while `add` used the Python scale rule,
3119    /// the two would part company the moment the running sum crossed zero —
3120    /// the same money reporting `1.00` from one path and `1` from the other,
3121    /// silently, depending only on whether it had been serialized.
3122    ///
3123    /// Needs COST-BEARING lots. Cost-less positions coalesce into a single
3124    /// position, and one position cannot cross zero during the rebuild, so a
3125    /// simpler fixture passes either way and pins nothing.
3126    #[test]
3127    fn a_round_trip_reports_the_same_scale_as_incremental_adds() {
3128        let mut inv = Inventory::new();
3129        let lots = [
3130            (
3131                dec!(2.00),
3132                Cost::new(dec!(10.00), "USD").with_date(date(2024, 1, 1)),
3133            ),
3134            (
3135                dec!(-2.00),
3136                Cost::new(dec!(11.00), "USD").with_date(date(2024, 1, 2)),
3137            ),
3138            (
3139                dec!(1),
3140                Cost::new(dec!(12.00), "USD").with_date(date(2024, 1, 3)),
3141            ),
3142        ];
3143        for (units, cost) in lots {
3144            inv.add(Position::with_cost(Amount::new(units, "SH"), cost))
3145                .expect("fixture fits in Decimal");
3146        }
3147
3148        let built = inv.units("SH").to_string();
3149        assert_eq!(built, "1.00", "the incrementally-built total");
3150
3151        let json = serde_json::to_string(&inv).expect("serializes");
3152        let round_tripped: Inventory = serde_json::from_str(&json).expect("deserializes");
3153        assert_eq!(
3154            round_tripped.units("SH").to_string(),
3155            built,
3156            "a serde round-trip must not change the reported scale",
3157        );
3158    }
3159
3160    /// Coalescing must not make a balance depend on the order it was built in.
3161    ///
3162    /// `rust_decimal` returns the other operand untouched when one side is
3163    /// zero, so a running total that passes through zero loses its scale and
3164    /// every later addend renders one scale narrower. The two inventories
3165    /// below hold the SAME multiset of amounts in a different order.
3166    ///
3167    /// Asserts on `to_string()`, not on `Decimal` equality: `==` compares
3168    /// value and ignores scale (`dec!(1) == dec!(1.00)`), so a value-level
3169    /// assertion here would pass against the bug it is pinning.
3170    #[test]
3171    fn coalescing_is_independent_of_the_order_amounts_arrive_in() {
3172        // Passes through 0.00 (scale 2), then takes a scale-0 addend.
3173        let zero_crossing_first = [dec!(-2.00), dec!(2.00), dec!(-1)];
3174        // Same amounts, no zero crossing before the scale-0 addend.
3175        let zero_crossing_last = [dec!(-1), dec!(-2.00), dec!(2.00)];
3176
3177        let build = |amounts: &[Decimal]| {
3178            let mut inv = Inventory::new();
3179            for n in amounts {
3180                inv.add(Position::simple(Amount::new(*n, "USD")))
3181                    .expect("fixture fits in Decimal");
3182            }
3183            inv
3184        };
3185
3186        let a = build(&zero_crossing_first);
3187        let b = build(&zero_crossing_last);
3188
3189        // The merged POSITION.
3190        assert_eq!(
3191            a.positions()
3192                .next()
3193                .expect("one position")
3194                .units
3195                .number
3196                .to_string(),
3197            "-1.00",
3198            "a total that passed through zero must keep the widest scale",
3199        );
3200        assert_eq!(
3201            b.positions()
3202                .next()
3203                .expect("one position")
3204                .units
3205                .number
3206                .to_string(),
3207            "-1.00",
3208        );
3209
3210        // And the units CACHE, which is maintained separately and would
3211        // otherwise disagree with the position it summarizes.
3212        assert_eq!(a.units("USD").to_string(), "-1.00");
3213        assert_eq!(b.units("USD").to_string(), "-1.00");
3214    }
3215
3216    #[test]
3217    fn test_add_costed_positions_net_units() {
3218        // Verify that units() correctly sums across all lots
3219        let mut inv = Inventory::new();
3220
3221        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
3222
3223        // Buy 10 shares
3224        inv.add(Position::with_cost(
3225            Amount::new(dec!(10), "AAPL"),
3226            cost.clone(),
3227        ))
3228        .expect("fixture fits in Decimal");
3229
3230        // Adding a NEGATIVE position of the same identity nets into the lot
3231        // rather than sitting beside it. Interchangeable positions are one
3232        // position, and that is as true of a negative one as a positive one.
3233        //
3234        // Note this is `add`, not `reduce`: booking a sale goes through
3235        // `reduce`, which matches a lot and drains it. This path is for
3236        // callers assembling an inventory directly.
3237        inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost))
3238            .expect("fixture fits in Decimal");
3239        assert_eq!(inv.len(), 1, "same identity nets into one position");
3240        assert_eq!(inv.units("AAPL"), dec!(7));
3241    }
3242
3243    #[test]
3244    fn test_add_no_cancel_different_cost() {
3245        // Test that different costs don't cancel
3246        let mut inv = Inventory::new();
3247
3248        let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
3249        let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
3250
3251        // Buy 10 shares at 150
3252        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3253            .expect("fixture fits in Decimal");
3254
3255        // Sell 5 shares at 160 - should NOT cancel (different cost)
3256        inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2))
3257            .expect("fixture fits in Decimal");
3258
3259        // Should have two separate lots
3260        assert_eq!(inv.len(), 2);
3261        assert_eq!(inv.units("AAPL"), dec!(5)); // 10 - 5 = 5 total
3262    }
3263
3264    #[test]
3265    fn test_add_merges_same_identity() {
3266        // Two acquisitions agreeing on commodity, cost, cost currency, date
3267        // and label are INTERCHANGEABLE: no attribute the model records
3268        // separates them, so they are one position.
3269        //
3270        // This test previously asserted the opposite, that they stay separate.
3271        // Keeping them apart let the order they were WRITTEN decide which
3272        // units a later reduction consumed, so an unrelated lot written
3273        // between them moved a reported cost basis (#2118).
3274        let mut inv = Inventory::new();
3275
3276        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
3277
3278        inv.add(Position::with_cost(
3279            Amount::new(dec!(10), "AAPL"),
3280            cost.clone(),
3281        ))
3282        .expect("fixture fits in Decimal");
3283        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost))
3284            .expect("fixture fits in Decimal");
3285
3286        assert_eq!(inv.len(), 1, "interchangeable lots are one position");
3287        assert_eq!(inv.units("AAPL"), dec!(15));
3288    }
3289
3290    /// A LABEL makes otherwise identical lots distinct, so they do not merge.
3291    ///
3292    /// This is the boundary of the rule above: labels exist precisely to make
3293    /// two same-day, same-cost acquisitions addressable apart, and merging
3294    /// them would take that away.
3295    #[test]
3296    fn test_add_keeps_labelled_lots_separate() {
3297        let mut inv = Inventory::new();
3298        let at = |label: &str| {
3299            Cost::new(dec!(150.00), "USD")
3300                .with_date(date(2024, 1, 1))
3301                .with_label(label)
3302        };
3303
3304        inv.add(Position::with_cost(
3305            Amount::new(dec!(10), "AAPL"),
3306            at("morning"),
3307        ))
3308        .expect("fixture fits in Decimal");
3309        inv.add(Position::with_cost(
3310            Amount::new(dec!(5), "AAPL"),
3311            at("afternoon"),
3312        ))
3313        .expect("fixture fits in Decimal");
3314
3315        assert_eq!(inv.len(), 2, "labels keep the lots addressable apart");
3316        assert_eq!(inv.units("AAPL"), dec!(15));
3317    }
3318
3319    #[test]
3320    fn test_merge_nets_the_same_identity() {
3321        // Was `test_merge_keeps_lots_separate`. See #2118: interchangeable
3322        // lots are one position, so merging two inventories holding the same
3323        // identity yields one, not two.
3324        let mut inv1 = Inventory::new();
3325        let mut inv2 = Inventory::new();
3326
3327        let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
3328
3329        // inv1: buy 10 shares
3330        inv1.add(Position::with_cost(
3331            Amount::new(dec!(10), "AAPL"),
3332            cost.clone(),
3333        ))
3334        .expect("fixture fits in Decimal");
3335
3336        // inv2: sell 10 shares
3337        inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost))
3338            .expect("fixture fits in Decimal");
3339
3340        // `merge` adds each of the other inventory's positions, so the same
3341        // identity lands in the same position rather than beside it. Net units
3342        // are unchanged either way; what changes is that the result no longer
3343        // depends on which inventory a lot came from.
3344        inv1.merge(&inv2).expect("fixture fits in Decimal");
3345        assert_eq!(inv1.len(), 1, "the same identity is one position");
3346        assert_eq!(inv1.units("AAPL"), dec!(0));
3347    }
3348
3349    // ====================================================================
3350    // Phase 2: Additional Coverage Tests for Booking Methods
3351    // ====================================================================
3352
3353    #[test]
3354    fn test_hifo_with_tie_breaking() {
3355        // When multiple lots have the same cost, HIFO should use insertion order
3356        let mut inv = Inventory::new();
3357
3358        // Three lots with same cost but different dates
3359        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3360        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
3361        let cost3 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 3, 1));
3362
3363        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3364            .expect("fixture fits in Decimal");
3365        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
3366            .expect("fixture fits in Decimal");
3367        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3))
3368            .expect("fixture fits in Decimal");
3369
3370        // Tied on cost, so only the tiebreak can decide: insertion order,
3371        // oldest slot first.
3372        let result = inv
3373            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
3374            .unwrap();
3375
3376        assert_eq!(inv.units("AAPL"), dec!(15));
3377        // All at same cost, so 15 * 100 = 1500. This says nothing about WHICH
3378        // lots were taken — every lot here has the same cost, so units and
3379        // basis are identical under any tiebreak. Kept as a sanity check, but
3380        // the assertions that give this test its name are the ones below: with
3381        // only these two, it passed with the tiebreak reversed at every sort
3382        // site (#2115).
3383        assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
3384
3385        // 10 from the first lot, then 5 from the second — by lot DATE, which
3386        // is the only thing distinguishing them.
3387        assert_eq!(
3388            result
3389                .matched
3390                .iter()
3391                .map(|p| (p.cost.as_ref().unwrap().date.unwrap(), p.units.number.abs()))
3392                .collect::<Vec<_>>(),
3393            vec![(date(2024, 1, 1), dec!(10)), (date(2024, 2, 1), dec!(5))],
3394            "HIFO must break a cost tie by insertion order, oldest slot first",
3395        );
3396    }
3397
3398    #[test]
3399    fn test_hifo_with_different_costs() {
3400        // HIFO should reduce highest cost lots first
3401        let mut inv = Inventory::new();
3402
3403        let cost_low = Cost::new(dec!(50.00), "USD").with_date(date(2024, 1, 1));
3404        let cost_mid = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
3405        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
3406
3407        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low))
3408            .expect("fixture fits in Decimal");
3409        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid))
3410            .expect("fixture fits in Decimal");
3411        inv.add(Position::with_cost(
3412            Amount::new(dec!(10), "AAPL"),
3413            cost_high,
3414        ))
3415        .expect("fixture fits in Decimal");
3416
3417        // Reduce 15 shares - should take from highest cost (200) first
3418        let result = inv
3419            .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
3420            .unwrap();
3421
3422        assert_eq!(inv.units("AAPL"), dec!(15));
3423        // 10 * 200 + 5 * 100 = 2000 + 500 = 2500
3424        assert_eq!(result.cost_basis.unwrap().number, dec!(2500.00));
3425    }
3426
3427    #[test]
3428    fn test_average_booking_with_pre_existing_positions() {
3429        let mut inv = Inventory::new();
3430
3431        // Add two lots with different costs
3432        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3433        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
3434
3435        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3436            .expect("fixture fits in Decimal");
3437        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
3438            .expect("fixture fits in Decimal");
3439
3440        // Total: 20 shares, total cost = 10*100 + 10*200 = 3000, avg = 150/share
3441        // Reduce 5 shares using AVERAGE
3442        let result = inv
3443            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
3444            .unwrap();
3445
3446        assert_eq!(inv.units("AAPL"), dec!(15));
3447        // Cost basis for 5 shares at average 150 = 750
3448        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
3449    }
3450
3451    #[test]
3452    fn test_average_booking_reduces_all() {
3453        let mut inv = Inventory::new();
3454
3455        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3456        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
3457            .expect("fixture fits in Decimal");
3458
3459        // Reduce all shares
3460        let result = inv
3461            .reduce(
3462                &Amount::new(dec!(-10), "AAPL"),
3463                None,
3464                BookingMethod::Average,
3465            )
3466            .unwrap();
3467
3468        assert!(inv.is_empty() || inv.units("AAPL").is_zero());
3469        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
3470    }
3471
3472    #[test]
3473    fn test_none_booking_augmentation() {
3474        // NONE booking with same-sign amounts should augment, not reduce
3475        let mut inv = Inventory::new();
3476        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
3477            .expect("fixture fits in Decimal");
3478
3479        // Adding more (same sign) - this is an augmentation
3480        let result = inv
3481            .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
3482            .unwrap();
3483
3484        assert_eq!(inv.units("USD"), dec!(150));
3485        assert!(result.matched.is_empty()); // No lots matched for augmentation
3486        assert!(result.cost_basis.is_none());
3487    }
3488
3489    #[test]
3490    fn test_none_booking_reduction() {
3491        // NONE booking with opposite-sign should reduce
3492        let mut inv = Inventory::new();
3493        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
3494            .expect("fixture fits in Decimal");
3495
3496        let result = inv
3497            .reduce(&Amount::new(dec!(-30), "USD"), None, BookingMethod::None)
3498            .unwrap();
3499
3500        assert_eq!(inv.units("USD"), dec!(70));
3501        assert!(!result.matched.is_empty());
3502    }
3503
3504    #[test]
3505    fn test_none_booking_shorts_past_zero() {
3506        let mut inv = Inventory::new();
3507        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
3508            .expect("fixture fits in Decimal");
3509
3510        // NONE performs no booking: reducing past the balance shorts instead
3511        // of erroring (#1686 — previously InsufficientUnits, inconsistent
3512        // with the zero-balance case, NONECorrect.tla, and beancount NONE).
3513        let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
3514
3515        assert!(result.is_ok(), "NONE must allow shorting: {result:?}");
3516        assert_eq!(inv.units("USD"), dec!(-50));
3517    }
3518
3519    #[test]
3520    fn test_booking_error_no_matching_lot() {
3521        let mut inv = Inventory::new();
3522
3523        // Add a lot with specific cost
3524        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3525        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
3526            .expect("fixture fits in Decimal");
3527
3528        // Try to reduce with a cost spec that doesn't match
3529        let wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
3530        let result = inv.reduce(
3531            &Amount::new(dec!(-5), "AAPL"),
3532            Some(&wrong_spec),
3533            BookingMethod::Strict,
3534        );
3535
3536        assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
3537    }
3538
3539    #[test]
3540    fn test_booking_error_insufficient_units() {
3541        let mut inv = Inventory::new();
3542
3543        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3544        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
3545            .expect("fixture fits in Decimal");
3546
3547        // Try to reduce more than available
3548        let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
3549
3550        match result {
3551            Err(BookingError::InsufficientUnits {
3552                requested,
3553                available,
3554                ..
3555            }) => {
3556                assert_eq!(requested, dec!(20));
3557                assert_eq!(available, dec!(10));
3558            }
3559            _ => panic!("Expected InsufficientUnits error"),
3560        }
3561    }
3562
3563    #[test]
3564    fn test_strict_with_size_exact_match() {
3565        let mut inv = Inventory::new();
3566
3567        // Add two lots with same cost but different sizes
3568        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3569        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
3570
3571        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3572            .expect("fixture fits in Decimal");
3573        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
3574            .expect("fixture fits in Decimal");
3575
3576        // Reduce exactly 5 - should match the 5-share lot
3577        let result = inv
3578            .reduce(
3579                &Amount::new(dec!(-5), "AAPL"),
3580                None,
3581                BookingMethod::StrictWithSize,
3582            )
3583            .unwrap();
3584
3585        assert_eq!(inv.units("AAPL"), dec!(10));
3586        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
3587    }
3588
3589    #[test]
3590    fn test_strict_with_size_total_match() {
3591        let mut inv = Inventory::new();
3592
3593        // Add two lots
3594        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3595        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
3596
3597        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3598            .expect("fixture fits in Decimal");
3599        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
3600            .expect("fixture fits in Decimal");
3601
3602        // Reduce exactly 15 (total) - should succeed via total match exception
3603        let result = inv
3604            .reduce(
3605                &Amount::new(dec!(-15), "AAPL"),
3606                None,
3607                BookingMethod::StrictWithSize,
3608            )
3609            .unwrap();
3610
3611        assert_eq!(inv.units("AAPL"), dec!(0));
3612        assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
3613    }
3614
3615    #[test]
3616    fn test_strict_with_size_ambiguous() {
3617        let mut inv = Inventory::new();
3618
3619        // Add two lots of same size and cost
3620        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3621        let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
3622
3623        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3624            .expect("fixture fits in Decimal");
3625        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
3626            .expect("fixture fits in Decimal");
3627
3628        // Reduce 7 shares - doesn't match either lot exactly, not total
3629        let result = inv.reduce(
3630            &Amount::new(dec!(-7), "AAPL"),
3631            None,
3632            BookingMethod::StrictWithSize,
3633        );
3634
3635        assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
3636    }
3637
3638    #[test]
3639    fn test_short_position() {
3640        // Test short selling (negative positions)
3641        let mut inv = Inventory::new();
3642
3643        // Short 10 shares
3644        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3645        inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost))
3646            .expect("fixture fits in Decimal");
3647
3648        assert_eq!(inv.units("AAPL"), dec!(-10));
3649        assert!(!inv.is_empty());
3650    }
3651
3652    #[test]
3653    fn test_at_cost() {
3654        let mut inv = Inventory::new();
3655
3656        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3657        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
3658
3659        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3660            .expect("fixture fits in Decimal");
3661        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
3662            .expect("fixture fits in Decimal");
3663        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
3664            .expect("fixture fits in Decimal");
3665
3666        let at_cost = inv.at_cost().expect("fixture fits in Decimal");
3667
3668        // AAPL converted: 10*100 + 5*150 = 1000 + 750 = 1750 USD
3669        // Plus 100 USD simple position = 1850 USD total
3670        assert_eq!(at_cost.units("USD"), dec!(1850));
3671        assert_eq!(at_cost.units("AAPL"), dec!(0)); // No AAPL in cost view
3672    }
3673
3674    #[test]
3675    fn test_at_units() {
3676        let mut inv = Inventory::new();
3677
3678        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3679        let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
3680
3681        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
3682            .expect("fixture fits in Decimal");
3683        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
3684            .expect("fixture fits in Decimal");
3685
3686        let at_units = inv.at_units().expect("fixture fits in Decimal");
3687
3688        // All AAPL lots merged
3689        assert_eq!(at_units.units("AAPL"), dec!(15));
3690        // Should only have one position after aggregation
3691        assert_eq!(at_units.len(), 1);
3692    }
3693
3694    #[test]
3695    fn test_add_empty_position() {
3696        let mut inv = Inventory::new();
3697        inv.add(Position::simple(Amount::new(dec!(0), "USD")))
3698            .expect("fixture fits in Decimal");
3699
3700        assert!(inv.is_empty());
3701        assert_eq!(inv.len(), 0);
3702    }
3703
3704    #[test]
3705    fn test_compact() {
3706        let mut inv = Inventory::new();
3707
3708        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3709        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
3710            .expect("fixture fits in Decimal");
3711
3712        // Reduce all
3713        inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
3714            .unwrap();
3715
3716        // Compact to remove empty positions
3717        inv.compact();
3718        assert!(inv.is_empty());
3719        assert_eq!(inv.len(), 0);
3720    }
3721
3722    #[test]
3723    fn test_booking_method_from_str() {
3724        assert_eq!(
3725            BookingMethod::from_str("STRICT").unwrap(),
3726            BookingMethod::Strict
3727        );
3728        assert_eq!(
3729            BookingMethod::from_str("fifo").unwrap(),
3730            BookingMethod::Fifo
3731        );
3732        assert_eq!(
3733            BookingMethod::from_str("LIFO").unwrap(),
3734            BookingMethod::Lifo
3735        );
3736        assert_eq!(
3737            BookingMethod::from_str("Hifo").unwrap(),
3738            BookingMethod::Hifo
3739        );
3740        assert_eq!(
3741            BookingMethod::from_str("AVERAGE").unwrap(),
3742            BookingMethod::Average
3743        );
3744        assert_eq!(
3745            BookingMethod::from_str("NONE").unwrap(),
3746            BookingMethod::None
3747        );
3748        assert_eq!(
3749            BookingMethod::from_str("strict_with_size").unwrap(),
3750            BookingMethod::StrictWithSize
3751        );
3752        assert!(BookingMethod::from_str("INVALID").is_err());
3753    }
3754
3755    #[test]
3756    fn test_booking_method_display() {
3757        assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
3758        assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
3759        assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
3760        assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
3761        assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
3762        assert_eq!(format!("{}", BookingMethod::None), "NONE");
3763        assert_eq!(
3764            format!("{}", BookingMethod::StrictWithSize),
3765            "STRICT_WITH_SIZE"
3766        );
3767    }
3768
3769    #[test]
3770    fn test_booking_error_display() {
3771        let err = BookingError::AmbiguousMatch {
3772            num_matches: 3,
3773            currency: "AAPL".into(),
3774        };
3775        assert!(format!("{err}").contains("3 lots match"));
3776
3777        let err = BookingError::NoMatchingLot {
3778            currency: "AAPL".into(),
3779            cost_spec: CostSpec::empty(),
3780        };
3781        assert!(format!("{err}").contains("No matching lot"));
3782
3783        let err = BookingError::InsufficientUnits {
3784            currency: "AAPL".into(),
3785            requested: dec!(100),
3786            available: dec!(50),
3787        };
3788        assert!(format!("{err}").contains("requested 100"));
3789        assert!(format!("{err}").contains("available 50"));
3790
3791        let err = BookingError::CurrencyMismatch {
3792            expected: "USD".into(),
3793            got: "EUR".into(),
3794        };
3795        assert!(format!("{err}").contains("expected USD"));
3796        assert!(format!("{err}").contains("got EUR"));
3797    }
3798
3799    #[test]
3800    fn test_book_value_multiple_currencies() {
3801        let mut inv = Inventory::new();
3802
3803        // Cost in USD
3804        let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3805        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd))
3806            .expect("fixture fits in Decimal");
3807
3808        // Cost in EUR
3809        let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
3810        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur))
3811            .expect("fixture fits in Decimal");
3812
3813        let book = inv.book_value("AAPL").expect("fixture fits in Decimal");
3814        assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
3815        assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
3816    }
3817
3818    #[test]
3819    fn test_reduce_hifo_insufficient_units() {
3820        let mut inv = Inventory::new();
3821
3822        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3823        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
3824            .expect("fixture fits in Decimal");
3825
3826        let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
3827
3828        assert!(matches!(
3829            result,
3830            Err(BookingError::InsufficientUnits { .. })
3831        ));
3832    }
3833
3834    #[test]
3835    fn test_reduce_average_insufficient_units() {
3836        let mut inv = Inventory::new();
3837
3838        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
3839        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
3840            .expect("fixture fits in Decimal");
3841
3842        let result = inv.reduce(
3843            &Amount::new(dec!(-20), "AAPL"),
3844            None,
3845            BookingMethod::Average,
3846        );
3847
3848        assert!(matches!(
3849            result,
3850            Err(BookingError::InsufficientUnits { .. })
3851        ));
3852    }
3853
3854    #[test]
3855    fn test_reduce_average_empty_inventory() {
3856        let mut inv = Inventory::new();
3857
3858        let result = inv.reduce(
3859            &Amount::new(dec!(-10), "AAPL"),
3860            None,
3861            BookingMethod::Average,
3862        );
3863
3864        assert!(matches!(
3865            result,
3866            Err(BookingError::InsufficientUnits { .. })
3867        ));
3868    }
3869
3870    #[test]
3871    fn test_reduce_merge_operator() {
3872        // {*} merge: two lots merged into weighted-average, then reduced
3873        let mut inv = Inventory::new();
3874        inv.add(Position::with_cost(
3875            Amount::new(dec!(10), "AAPL"),
3876            Cost::new(dec!(150), "USD"),
3877        ))
3878        .expect("fixture fits in Decimal");
3879        inv.add(Position::with_cost(
3880            Amount::new(dec!(10), "AAPL"),
3881            Cost::new(dec!(160), "USD"),
3882        ))
3883        .expect("fixture fits in Decimal");
3884
3885        let merge_spec = CostSpec::empty().with_merge();
3886        let result = inv
3887            .reduce(
3888                &Amount::new(dec!(-5), "AAPL"),
3889                Some(&merge_spec),
3890                BookingMethod::Strict,
3891            )
3892            .expect("merge reduction should succeed");
3893
3894        // Cost basis: 5 units * 155 USD average = 775 USD
3895        assert_eq!(result.cost_basis, Some(Amount::new(dec!(775), "USD")));
3896
3897        // Inventory should have a single merged lot with 15 remaining @ 155
3898        assert_eq!(inv.positions.len(), 1);
3899        // Through the iterator, not a raw slot: the merged lot is appended
3900        // after the tombstoned originals, and the iterator is what every
3901        // consumer sees.
3902        let merged = inv.positions().next().expect("one merged lot");
3903        assert_eq!(merged.units.number, dec!(15));
3904        let cost = merged.cost.as_ref().expect("should have cost");
3905        assert_eq!(cost.number, dec!(155));
3906    }
3907
3908    #[test]
3909    fn test_reduce_merge_insufficient_units() {
3910        let mut inv = Inventory::new();
3911        inv.add(Position::with_cost(
3912            Amount::new(dec!(10), "AAPL"),
3913            Cost::new(dec!(150), "USD"),
3914        ))
3915        .expect("fixture fits in Decimal");
3916
3917        let merge_spec = CostSpec::empty().with_merge();
3918        let result = inv.reduce(
3919            &Amount::new(dec!(-20), "AAPL"),
3920            Some(&merge_spec),
3921            BookingMethod::Strict,
3922        );
3923
3924        assert!(matches!(
3925            result,
3926            Err(BookingError::InsufficientUnits { .. })
3927        ));
3928    }
3929
3930    #[test]
3931    fn test_reduce_merge_sells_all() {
3932        // Merge and sell entire position
3933        let mut inv = Inventory::new();
3934        inv.add(Position::with_cost(
3935            Amount::new(dec!(10), "AAPL"),
3936            Cost::new(dec!(150), "USD"),
3937        ))
3938        .expect("fixture fits in Decimal");
3939        inv.add(Position::with_cost(
3940            Amount::new(dec!(10), "AAPL"),
3941            Cost::new(dec!(160), "USD"),
3942        ))
3943        .expect("fixture fits in Decimal");
3944
3945        let merge_spec = CostSpec::empty().with_merge();
3946        let result = inv
3947            .reduce(
3948                &Amount::new(dec!(-20), "AAPL"),
3949                Some(&merge_spec),
3950                BookingMethod::Strict,
3951            )
3952            .expect("merge reduction should succeed");
3953
3954        // Cost basis: 20 * 155 = 3100 USD
3955        assert_eq!(result.cost_basis, Some(Amount::new(dec!(3100), "USD")));
3956
3957        // Inventory should be empty
3958        assert!(inv.positions.is_empty() || inv.positions.iter().all(Position::is_empty));
3959    }
3960
3961    #[test]
3962    fn test_reduce_merge_single_lot() {
3963        // {*} with a single lot should work trivially
3964        let mut inv = Inventory::new();
3965        inv.add(Position::with_cost(
3966            Amount::new(dec!(10), "AAPL"),
3967            Cost::new(dec!(150), "USD"),
3968        ))
3969        .expect("fixture fits in Decimal");
3970
3971        let merge_spec = CostSpec::empty().with_merge();
3972        let result = inv
3973            .reduce(
3974                &Amount::new(dec!(-3), "AAPL"),
3975                Some(&merge_spec),
3976                BookingMethod::Strict,
3977            )
3978            .expect("single-lot merge should succeed");
3979
3980        assert_eq!(result.cost_basis, Some(Amount::new(dec!(450), "USD")));
3981        assert_eq!(inv.positions.len(), 1);
3982        // Iterator, not a raw slot: the merged lot is appended after the
3983        // tombstoned originals.
3984        let merged = inv.positions().next().expect("one merged lot");
3985        assert_eq!(merged.units.number, dec!(7));
3986    }
3987
3988    #[test]
3989    fn test_reduce_merge_three_lots() {
3990        // {*} with three lots at different costs
3991        let mut inv = Inventory::new();
3992        inv.add(Position::with_cost(
3993            Amount::new(dec!(10), "AAPL"),
3994            Cost::new(dec!(100), "USD"),
3995        ))
3996        .expect("fixture fits in Decimal");
3997        inv.add(Position::with_cost(
3998            Amount::new(dec!(10), "AAPL"),
3999            Cost::new(dec!(150), "USD"),
4000        ))
4001        .expect("fixture fits in Decimal");
4002        inv.add(Position::with_cost(
4003            Amount::new(dec!(10), "AAPL"),
4004            Cost::new(dec!(200), "USD"),
4005        ))
4006        .expect("fixture fits in Decimal");
4007
4008        // Average cost: (1000 + 1500 + 2000) / 30 = 150 USD
4009        let merge_spec = CostSpec::empty().with_merge();
4010        let result = inv
4011            .reduce(
4012                &Amount::new(dec!(-6), "AAPL"),
4013                Some(&merge_spec),
4014                BookingMethod::Strict,
4015            )
4016            .expect("three-lot merge should succeed");
4017
4018        assert_eq!(result.cost_basis, Some(Amount::new(dec!(900), "USD")));
4019        assert_eq!(inv.positions.len(), 1);
4020        // Iterator, not a raw slot: the merged lot is appended after the
4021        // tombstoned originals.
4022        let merged = inv.positions().next().expect("one merged lot");
4023        assert_eq!(merged.units.number, dec!(24));
4024        let cost = merged.cost.as_ref().expect("should have cost");
4025        assert_eq!(cost.number, dec!(150));
4026    }
4027
4028    #[test]
4029    fn test_reduce_merge_mixed_cost_currencies_errors() {
4030        // Lots with different cost currencies cannot be merged
4031        let mut inv = Inventory::new();
4032        inv.add(Position::with_cost(
4033            Amount::new(dec!(10), "AAPL"),
4034            Cost::new(dec!(150), "USD"),
4035        ))
4036        .expect("fixture fits in Decimal");
4037        inv.add(Position::with_cost(
4038            Amount::new(dec!(10), "AAPL"),
4039            Cost::new(dec!(130), "EUR"),
4040        ))
4041        .expect("fixture fits in Decimal");
4042
4043        let merge_spec = CostSpec::empty().with_merge();
4044        let result = inv.reduce(
4045            &Amount::new(dec!(-5), "AAPL"),
4046            Some(&merge_spec),
4047            BookingMethod::Strict,
4048        );
4049
4050        assert!(
4051            matches!(result, Err(BookingError::CurrencyMismatch { .. })),
4052            "expected CurrencyMismatch, got {result:?}"
4053        );
4054    }
4055
4056    #[test]
4057    fn test_reduce_merge_empty_inventory() {
4058        let mut inv = Inventory::new();
4059
4060        let merge_spec = CostSpec::empty().with_merge();
4061        let result = inv.reduce(
4062            &Amount::new(dec!(-5), "AAPL"),
4063            Some(&merge_spec),
4064            BookingMethod::Strict,
4065        );
4066
4067        assert!(matches!(
4068            result,
4069            Err(BookingError::InsufficientUnits { .. })
4070        ));
4071    }
4072
4073    #[test]
4074    fn test_inventory_display_sorted() {
4075        let mut inv = Inventory::new();
4076
4077        // Add in non-alphabetical order
4078        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
4079            .expect("fixture fits in Decimal");
4080        inv.add(Position::simple(Amount::new(dec!(50), "EUR")))
4081            .expect("fixture fits in Decimal");
4082        inv.add(Position::simple(Amount::new(dec!(10), "AAPL")))
4083            .expect("fixture fits in Decimal");
4084
4085        let display = format!("{inv}");
4086
4087        // Should be sorted alphabetically: AAPL, EUR, USD
4088        let aapl_pos = display.find("AAPL").unwrap();
4089        let eur_pos = display.find("EUR").unwrap();
4090        let usd_pos = display.find("USD").unwrap();
4091
4092        assert!(aapl_pos < eur_pos);
4093        assert!(eur_pos < usd_pos);
4094    }
4095
4096    #[test]
4097    fn test_inventory_with_cost_display_sorted() {
4098        let mut inv = Inventory::new();
4099
4100        // Add same currency with different costs
4101        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
4102        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
4103
4104        inv.add(Position::with_cost(
4105            Amount::new(dec!(10), "AAPL"),
4106            cost_high,
4107        ))
4108        .expect("fixture fits in Decimal");
4109        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low))
4110            .expect("fixture fits in Decimal");
4111
4112        let display = format!("{inv}");
4113
4114        // Both positions should be in the output
4115        assert!(display.contains("AAPL"));
4116        assert!(display.contains("100"));
4117        assert!(display.contains("200"));
4118    }
4119
4120    #[test]
4121    fn test_reduce_hifo_no_matching_lot() {
4122        let mut inv = Inventory::new();
4123
4124        // No AAPL positions
4125        inv.add(Position::simple(Amount::new(dec!(100), "USD")))
4126            .expect("fixture fits in Decimal");
4127
4128        let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
4129
4130        assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
4131    }
4132
4133    #[test]
4134    fn test_fifo_respects_dates() {
4135        // Ensure FIFO uses acquisition date, not insertion order
4136        let mut inv = Inventory::new();
4137
4138        // Add newer lot first (out of order)
4139        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
4140        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4141
4142        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new))
4143            .expect("fixture fits in Decimal");
4144        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old))
4145            .expect("fixture fits in Decimal");
4146
4147        // FIFO should reduce from oldest (cost 100) first
4148        let result = inv
4149            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
4150            .unwrap();
4151
4152        // Should use cost from oldest lot (100)
4153        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
4154    }
4155
4156    #[test]
4157    fn test_lifo_respects_dates() {
4158        // Ensure LIFO uses acquisition date, not insertion order
4159        let mut inv = Inventory::new();
4160
4161        // Add older lot first
4162        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4163        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
4164
4165        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old))
4166            .expect("fixture fits in Decimal");
4167        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new))
4168            .expect("fixture fits in Decimal");
4169
4170        // LIFO should reduce from newest (cost 200) first
4171        let result = inv
4172            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
4173            .unwrap();
4174
4175        // Should use cost from newest lot (200)
4176        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
4177    }
4178
4179    // =========================================================================
4180    // Booking method coverage tests
4181    //
4182    // These tests cover gaps identified during the spring 2026 audit:
4183    // - STRICT_WITH_SIZE: cost spec + exact-size, multiple exact-size matches
4184    // - HIFO: multi-lot ordering, partial reduction, cost spec filtering
4185    // - AVERAGE: weighted average with different costs, partial reduction preserves cost
4186    // - NONE: with cost positions, short position reduction
4187    // =========================================================================
4188
4189    // --- STRICT_WITH_SIZE ---
4190
4191    #[test]
4192    fn test_strict_with_size_different_costs_exact_match() {
4193        // When lots have different costs but one matches the reduction size exactly,
4194        // STRICT_WITH_SIZE should pick that lot instead of raising AmbiguousMatch
4195        let mut inv = Inventory::new();
4196
4197        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4198        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4199
4200        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
4201            .expect("fixture fits in Decimal");
4202        inv.add(Position::with_cost(Amount::new(dec!(7), "AAPL"), cost2))
4203            .expect("fixture fits in Decimal");
4204
4205        // Reduce exactly 7 - should match the 7-share lot at cost 200
4206        let result = inv
4207            .reduce(
4208                &Amount::new(dec!(-7), "AAPL"),
4209                None,
4210                BookingMethod::StrictWithSize,
4211            )
4212            .unwrap();
4213
4214        assert_eq!(inv.units("AAPL"), dec!(10));
4215        assert_eq!(result.cost_basis.unwrap().number, dec!(1400.00)); // 7 * 200
4216    }
4217
4218    #[test]
4219    fn test_strict_with_size_multiple_exact_matches_picks_oldest() {
4220        // When multiple lots have the exact same size, STRICT_WITH_SIZE should
4221        // pick the oldest one (first in index order)
4222        let mut inv = Inventory::new();
4223
4224        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4225        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 6, 1));
4226
4227        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost1))
4228            .expect("fixture fits in Decimal");
4229        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2))
4230            .expect("fixture fits in Decimal");
4231
4232        // Both lots are size 5 — should pick the first (oldest) one
4233        let result = inv
4234            .reduce(
4235                &Amount::new(dec!(-5), "AAPL"),
4236                None,
4237                BookingMethod::StrictWithSize,
4238            )
4239            .unwrap();
4240
4241        assert_eq!(inv.units("AAPL"), dec!(5));
4242        // Should use cost from the oldest lot (100)
4243        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
4244    }
4245
4246    #[test]
4247    fn test_strict_with_size_with_cost_spec() {
4248        // Cost spec should filter lots before exact-size matching
4249        let mut inv = Inventory::new();
4250
4251        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4252        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4253
4254        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
4255            .expect("fixture fits in Decimal");
4256        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
4257            .expect("fixture fits in Decimal");
4258
4259        // With cost spec filtering to the 200 USD lot, should find unique match
4260        let spec = CostSpec::empty().with_number(crate::CostNumber::PerUnit {
4261            value: dec!(200.00),
4262        });
4263        let result = inv
4264            .reduce(
4265                &Amount::new(dec!(-5), "AAPL"),
4266                Some(&spec),
4267                BookingMethod::StrictWithSize,
4268            )
4269            .unwrap();
4270
4271        assert_eq!(inv.units("AAPL"), dec!(15));
4272        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
4273    }
4274
4275    // --- HIFO ---
4276
4277    #[test]
4278    fn test_hifo_reduces_highest_cost_first() {
4279        // HIFO should reduce the highest-cost lot first, regardless of date
4280        let mut inv = Inventory::new();
4281
4282        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4283        let cost_mid = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
4284        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
4285
4286        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low))
4287            .expect("fixture fits in Decimal");
4288        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid))
4289            .expect("fixture fits in Decimal");
4290        inv.add(Position::with_cost(
4291            Amount::new(dec!(10), "AAPL"),
4292            cost_high,
4293        ))
4294        .expect("fixture fits in Decimal");
4295
4296        // Reduce 5 — should come from highest cost lot (200)
4297        let result = inv
4298            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Hifo)
4299            .unwrap();
4300
4301        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
4302        assert_eq!(inv.units("AAPL"), dec!(25));
4303    }
4304
4305    #[test]
4306    fn test_hifo_spans_multiple_lots() {
4307        // When reducing more than the highest-cost lot holds, HIFO should
4308        // continue to the next highest
4309        let mut inv = Inventory::new();
4310
4311        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4312        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4313
4314        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low))
4315            .expect("fixture fits in Decimal");
4316        inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_high))
4317            .expect("fixture fits in Decimal");
4318
4319        // Reduce 8: 5 from high (200) + 3 from low (100)
4320        let result = inv
4321            .reduce(&Amount::new(dec!(-8), "AAPL"), None, BookingMethod::Hifo)
4322            .unwrap();
4323
4324        // Cost basis: 5*200 + 3*100 = 1300
4325        assert_eq!(result.cost_basis.unwrap().number, dec!(1300.00));
4326        assert_eq!(inv.units("AAPL"), dec!(2));
4327    }
4328
4329    #[test]
4330    fn test_hifo_with_cost_spec_filter() {
4331        // Cost spec should filter lots before HIFO ordering
4332        let mut inv = Inventory::new();
4333
4334        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4335        let cost2 = Cost::new(dec!(200.00), "EUR").with_date(date(2024, 2, 1));
4336
4337        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
4338            .expect("fixture fits in Decimal");
4339        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
4340            .expect("fixture fits in Decimal");
4341
4342        // Filter to USD lots only
4343        let spec = CostSpec::empty().with_currency("USD");
4344        let result = inv
4345            .reduce(
4346                &Amount::new(dec!(-5), "AAPL"),
4347                Some(&spec),
4348                BookingMethod::Hifo,
4349            )
4350            .unwrap();
4351
4352        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); // 5 * 100 USD
4353    }
4354
4355    #[test]
4356    fn test_hifo_short_position() {
4357        // HIFO with short positions: covering shorts should work correctly
4358        let mut inv = Inventory::new();
4359
4360        let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4361        let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4362
4363        // Short positions (negative units)
4364        inv.add(Position::with_cost(
4365            Amount::new(dec!(-10), "AAPL"),
4366            cost_low,
4367        ))
4368        .expect("fixture fits in Decimal");
4369        inv.add(Position::with_cost(
4370            Amount::new(dec!(-10), "AAPL"),
4371            cost_high,
4372        ))
4373        .expect("fixture fits in Decimal");
4374
4375        // Cover 5 shares (positive = reduce short position)
4376        // HIFO should pick the highest-cost short lot (200)
4377        let result = inv
4378            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Hifo)
4379            .unwrap();
4380
4381        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
4382        assert_eq!(inv.units("AAPL"), dec!(-15));
4383    }
4384
4385    // --- AVERAGE ---
4386
4387    #[test]
4388    fn test_average_weighted_cost() {
4389        // AVERAGE should compute weighted average across lots with different costs
4390        let mut inv = Inventory::new();
4391
4392        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4393        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4394
4395        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
4396            .expect("fixture fits in Decimal");
4397        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
4398            .expect("fixture fits in Decimal");
4399
4400        // Average cost = (10*100 + 10*200) / 20 = 150
4401        let result = inv
4402            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
4403            .unwrap();
4404
4405        // Cost basis: 5 * 150 = 750
4406        assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
4407        assert_eq!(inv.units("AAPL"), dec!(15));
4408    }
4409
4410    #[test]
4411    fn test_average_merges_into_single_position() {
4412        // After AVERAGE reduction, inventory should have a single simple position
4413        let mut inv = Inventory::new();
4414
4415        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4416        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4417
4418        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1))
4419            .expect("fixture fits in Decimal");
4420        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
4421            .expect("fixture fits in Decimal");
4422
4423        inv.reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
4424            .unwrap();
4425
4426        // Should have exactly one AAPL position remaining
4427        let aapl_positions: Vec<_> = inv
4428            .positions
4429            .iter()
4430            .filter(|p| p.units.currency.as_ref() == "AAPL")
4431            .collect();
4432        assert_eq!(aapl_positions.len(), 1);
4433        assert_eq!(aapl_positions[0].units.number, dec!(15));
4434    }
4435
4436    #[test]
4437    fn test_average_uneven_lots() {
4438        // Weighted average with unequal lot sizes
4439        let mut inv = Inventory::new();
4440
4441        let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4442        let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
4443
4444        inv.add(Position::with_cost(Amount::new(dec!(30), "AAPL"), cost1))
4445            .expect("fixture fits in Decimal");
4446        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2))
4447            .expect("fixture fits in Decimal");
4448
4449        // Average cost = (30*100 + 10*200) / 40 = 5000/40 = 125
4450        let result = inv
4451            .reduce(
4452                &Amount::new(dec!(-10), "AAPL"),
4453                None,
4454                BookingMethod::Average,
4455            )
4456            .unwrap();
4457
4458        assert_eq!(result.cost_basis.unwrap().number, dec!(1250.00)); // 10 * 125
4459    }
4460
4461    // --- NONE ---
4462
4463    #[test]
4464    fn test_none_booking_with_cost_positions() {
4465        // NONE booking should work even when positions have costs
4466        let mut inv = Inventory::new();
4467
4468        let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4469        inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
4470            .expect("fixture fits in Decimal");
4471
4472        let result = inv
4473            .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::None)
4474            .unwrap();
4475
4476        assert_eq!(inv.units("AAPL"), dec!(5));
4477        // NONE delegates to reduce_ordered (FIFO) internally, so cost basis is computed
4478        assert!(result.cost_basis.is_some());
4479        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
4480    }
4481
4482    #[test]
4483    fn test_none_booking_short_cover() {
4484        // Covering a short position with NONE booking
4485        let mut inv = Inventory::new();
4486        inv.add(Position::simple(Amount::new(dec!(-100), "USD")))
4487            .expect("fixture fits in Decimal");
4488
4489        // Positive amount should reduce the negative position
4490        let result = inv
4491            .reduce(&Amount::new(dec!(30), "USD"), None, BookingMethod::None)
4492            .unwrap();
4493
4494        assert_eq!(inv.units("USD"), dec!(-70));
4495        assert!(!result.matched.is_empty());
4496    }
4497
4498    #[test]
4499    fn test_none_booking_empty_inventory_augments() {
4500        // NONE booking on empty inventory should augment
4501        let mut inv = Inventory::new();
4502
4503        let result = inv
4504            .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
4505            .unwrap();
4506
4507        assert_eq!(inv.units("USD"), dec!(50));
4508        assert!(result.matched.is_empty()); // Augmentation, not reduction
4509    }
4510
4511    // --- Cross-method: short positions ---
4512
4513    #[test]
4514    fn test_fifo_short_position_cover() {
4515        // FIFO: cover short positions (oldest short first)
4516        let mut inv = Inventory::new();
4517
4518        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4519        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
4520
4521        inv.add(Position::with_cost(
4522            Amount::new(dec!(-10), "AAPL"),
4523            cost_old,
4524        ))
4525        .expect("fixture fits in Decimal");
4526        inv.add(Position::with_cost(
4527            Amount::new(dec!(-10), "AAPL"),
4528            cost_new,
4529        ))
4530        .expect("fixture fits in Decimal");
4531
4532        // Cover 5 shares — FIFO should pick oldest short (cost 100)
4533        let result = inv
4534            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Fifo)
4535            .unwrap();
4536
4537        assert_eq!(result.cost_basis.unwrap().number, dec!(500.00)); // 5 * 100
4538        assert_eq!(inv.units("AAPL"), dec!(-15));
4539    }
4540
4541    #[test]
4542    fn test_lifo_short_position_cover() {
4543        // LIFO: cover short positions (newest short first)
4544        let mut inv = Inventory::new();
4545
4546        let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
4547        let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
4548
4549        inv.add(Position::with_cost(
4550            Amount::new(dec!(-10), "AAPL"),
4551            cost_old,
4552        ))
4553        .expect("fixture fits in Decimal");
4554        inv.add(Position::with_cost(
4555            Amount::new(dec!(-10), "AAPL"),
4556            cost_new,
4557        ))
4558        .expect("fixture fits in Decimal");
4559
4560        // Cover 5 shares — LIFO should pick newest short (cost 200)
4561        let result = inv
4562            .reduce(&Amount::new(dec!(5), "AAPL"), None, BookingMethod::Lifo)
4563            .unwrap();
4564
4565        assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00)); // 5 * 200
4566        assert_eq!(inv.units("AAPL"), dec!(-15));
4567    }
4568
4569    // === AccountedBookingError Display tests ===
4570    //
4571    // These tests pin the canonical user-facing wording for every variant
4572    // of `AccountedBookingError`. The whole point of unifying booking-error
4573    // Display into `rustledger-core` (#750) is that there's a single source
4574    // of truth — and a single source of truth with no tests is one refactor
4575    // away from drifting again, which is exactly the failure mode that
4576    // produced #748. Any change to the Display strings below will break
4577    // these tests, forcing the author to consciously re-check pta-standards
4578    // conformance assertions and downstream user tooling.
4579
4580    // =========================================================================
4581    // Regression test for issue #875 / beancount#889
4582    //
4583    // When a sell-without-cost-spec leaves a negative simple position in the
4584    // inventory, a subsequent augmentation WITH a cost spec should NOT be
4585    // misclassified as a reduction. `is_reduced_by` must only consider
4586    // cost-bearing positions when the incoming posting has a cost spec.
4587    // =========================================================================
4588
4589    #[test]
4590    fn test_is_reduced_by_ignores_simple_positions_when_has_cost_spec() {
4591        // Regression test for issue #875 / beancount#889.
4592        //
4593        // Scenario:
4594        //   1. Buy 100 HOOG {1.50 EUR}  -> inventory: [100 HOOG {1.50 EUR}]
4595        //   2. Sell 25 HOOG @ 1.60 EUR   -> inventory: [100 HOOG {1.50 EUR}, -25 HOOG (simple)]
4596        //   3. Buy 50 HOOG {1.70 EUR}    -> should be augmentation, NOT reduction
4597        //
4598        // Before fix: is_reduced_by saw the -25 HOOG simple position and
4599        // incorrectly reported that +50 HOOG would reduce the inventory.
4600        let mut inv = Inventory::new();
4601
4602        // Step 1: buy 100 HOOG with cost
4603        let cost = Cost::new(dec!(1.50), "EUR").with_date(date(2024, 1, 10));
4604        inv.add(Position::with_cost(Amount::new(dec!(100), "HOOG"), cost))
4605            .expect("fixture fits in Decimal");
4606
4607        // Step 2: sell 25 HOOG without cost spec (simple position)
4608        inv.add(Position::simple(Amount::new(dec!(-25), "HOOG")))
4609            .expect("fixture fits in Decimal");
4610
4611        // Step 3: check if buying 50 HOOG with cost spec would be a reduction
4612        let buy_units = Amount::new(dec!(50), "HOOG");
4613
4614        // With has_cost_spec=true, only cost-bearing positions should be
4615        // considered. The 100 HOOG {1.50 EUR} is positive and so is the
4616        // incoming 50 HOOG -> same sign -> NOT a reduction.
4617        assert!(
4618            !inv.is_reduced_by(&buy_units, ReductionScope::CostBearingOnly),
4619            "augmentation with cost spec should NOT be treated as reduction \
4620             when only a simple (no-cost) position has opposite sign"
4621        );
4622
4623        // With AllPositions, all positions are considered,
4624        // including the -25 HOOG simple position -> IS a reduction.
4625        assert!(
4626            inv.is_reduced_by(&buy_units, ReductionScope::AllPositions),
4627            "without cost spec filter, the -25 HOOG simple position \
4628             should cause is_reduced_by to return true"
4629        );
4630    }
4631
4632    #[test]
4633    fn is_booking_reduction_gates_on_method_cost_and_sign() {
4634        // A cost-bearing long position.
4635        let mut inv = Inventory::new();
4636        inv.add(Position::with_cost(
4637            Amount::new(dec!(10), "AAPL"),
4638            Cost::new(dec!(150), "USD").with_date(date(2024, 1, 1)),
4639        ))
4640        .expect("fixture fits in Decimal");
4641
4642        let sell = Amount::new(dec!(-5), "AAPL"); // opposite sign of the held lot
4643        let buy = Amount::new(dec!(5), "AAPL"); // same sign
4644        let spec = CostSpec::empty(); // only spec *presence* (is_some) matters here
4645
4646        // Opposite-sign units carrying a cost spec under a lot-matching method
4647        // is the one combination that reduces.
4648        assert!(inv.is_booking_reduction(&sell, Some(&spec), BookingMethod::Strict));
4649        // NONE never reduces — every posting accumulates (#1182).
4650        assert!(!inv.is_booking_reduction(&sell, Some(&spec), BookingMethod::None));
4651        // No cost spec -> augmentation.
4652        assert!(!inv.is_booking_reduction(&sell, None, BookingMethod::Strict));
4653        // Same sign as the held lot -> augmentation.
4654        assert!(!inv.is_booking_reduction(&buy, Some(&spec), BookingMethod::Strict));
4655    }
4656
4657    #[test]
4658    fn sum_account_and_subaccounts_sums_children_not_prefix_siblings() {
4659        let mut bank = Inventory::new();
4660        bank.add(Position::simple(Amount::new(dec!(10), "USD")))
4661            .expect("fixture fits in Decimal");
4662        let mut checking = Inventory::new(); // sub-account: included
4663        checking
4664            .add(Position::simple(Amount::new(dec!(40), "USD")))
4665            .expect("fixture fits in Decimal");
4666        let mut alias = Inventory::new(); // prefix sibling: excluded
4667        alias
4668            .add(Position::simple(Amount::new(dec!(99), "USD")))
4669            .expect("fixture fits in Decimal");
4670
4671        let mut map: FxHashMap<Account, Inventory> = FxHashMap::default();
4672        map.insert(Account::from("Assets:Bank"), bank);
4673        map.insert(Account::from("Assets:Bank:Checking"), checking);
4674        map.insert(Account::from("Assets:BankAlias"), alias);
4675
4676        let total = sum_account_and_subaccounts(map.iter(), "Assets:Bank", &Currency::from("USD"))
4677            .expect("fixture fits in Decimal");
4678        assert_eq!(
4679            total,
4680            dec!(50),
4681            "parent (10) + sub-account (40), excluding the Assets:BankAlias prefix sibling"
4682        );
4683    }
4684
4685    #[test]
4686    fn test_accounted_error_display_insufficient_units() {
4687        let err = BookingError::InsufficientUnits {
4688            currency: "AAPL".into(),
4689            requested: dec!(15),
4690            available: dec!(10),
4691        }
4692        .with_account("Assets:Stock".into());
4693        let rendered = format!("{err}");
4694
4695        // Pinned by pta-standards `reduction-exceeds-inventory`
4696        // (`error_contains: ["not enough"]`). See #748 / #749.
4697        assert!(
4698            rendered.contains("not enough"),
4699            "must contain 'not enough' (pta-standards): {rendered}"
4700        );
4701        assert!(
4702            rendered.contains("Assets:Stock"),
4703            "must contain account name: {rendered}"
4704        );
4705        assert!(
4706            rendered.contains("15") && rendered.contains("10"),
4707            "must contain requested and available amounts: {rendered}"
4708        );
4709    }
4710
4711    #[test]
4712    fn test_accounted_error_display_no_matching_lot() {
4713        let err = BookingError::NoMatchingLot {
4714            currency: "AAPL".into(),
4715            cost_spec: CostSpec::empty(),
4716        }
4717        .with_account("Assets:Stock".into());
4718        let rendered = format!("{err}");
4719
4720        assert!(
4721            rendered.contains("No matching lot"),
4722            "must contain 'No matching lot': {rendered}"
4723        );
4724        assert!(
4725            rendered.contains("AAPL"),
4726            "must contain currency: {rendered}"
4727        );
4728        assert!(
4729            rendered.contains("Assets:Stock"),
4730            "must contain account name: {rendered}"
4731        );
4732    }
4733
4734    #[test]
4735    fn test_accounted_error_display_ambiguous_match() {
4736        let err = BookingError::AmbiguousMatch {
4737            num_matches: 3,
4738            currency: "AAPL".into(),
4739        }
4740        .with_account("Assets:Stock".into());
4741        let rendered = format!("{err}");
4742
4743        assert!(
4744            rendered.contains("Ambiguous"),
4745            "must contain 'Ambiguous': {rendered}"
4746        );
4747        assert!(
4748            rendered.contains("AAPL"),
4749            "must contain currency: {rendered}"
4750        );
4751        assert!(
4752            rendered.contains("Assets:Stock"),
4753            "must contain account name: {rendered}"
4754        );
4755        assert!(
4756            rendered.contains('3'),
4757            "must contain match count: {rendered}"
4758        );
4759    }
4760
4761    #[test]
4762    fn test_accounted_error_display_currency_mismatch_renders_as_no_matching_lot() {
4763        // CurrencyMismatch is semantically a specialization of NoMatchingLot
4764        // (there is no lot for the given currency in this inventory) and the
4765        // canonical Display collapses them into the same user-facing phrasing
4766        // so that consumers filtering on E4001 don't need to special-case it.
4767        // This variant is defensive — no `Inventory::reduce` path currently
4768        // emits it — but we still pin its rendering in case a future emission
4769        // site is added.
4770        let err = BookingError::CurrencyMismatch {
4771            expected: "USD".into(),
4772            got: "EUR".into(),
4773        }
4774        .with_account("Assets:Cash".into());
4775        let rendered = format!("{err}");
4776
4777        assert!(
4778            rendered.contains("No matching lot"),
4779            "CurrencyMismatch must render as 'No matching lot' for E4001 \
4780             consistency: {rendered}"
4781        );
4782        assert!(
4783            rendered.contains("EUR"),
4784            "must contain the mismatched (got) currency: {rendered}"
4785        );
4786        assert!(
4787            rendered.contains("Assets:Cash"),
4788            "must contain account name: {rendered}"
4789        );
4790    }
4791
4792    /// `sign_index` must agree with a scan after EVERY mutation path, not
4793    /// just the ones a given test happens to follow with an
4794    /// `is_reduced_by` call.
4795    ///
4796    /// `is_reduced_by`'s own `debug_assert` compares the two on every call,
4797    /// which covers the whole suite — but only where something calls it.
4798    /// This walks the mutations that can move a lot between buckets and
4799    /// checks after each: a cost-less merge that flips a lot's sign by adding
4800    /// through zero, a reduction that takes a lot to exactly zero (removing
4801    /// it), and a partial reduction that leaves it. The comparison is
4802    /// explicit rather than leaning on the assertion, so it holds in release
4803    /// builds too.
4804    #[test]
4805    fn the_sign_index_tracks_every_mutation_path() {
4806        let usd = Amount::new(dec!(1), "USD");
4807        let aapl = Amount::new(dec!(1), "AAPL");
4808        let check = |inv: &Inventory, label: &str| {
4809            // The incrementally maintained counts must equal what a fresh
4810            // rebuild computes. This is the invariant that matters, and it is
4811            // strictly stronger than "the answers agree": an empty cache
4812            // still ANSWERS correctly, because `is_reduced_by` falls back to
4813            // the scan — so a path that quietly stopped maintaining the counts
4814            // would restore the O(lots) cost with every test still green.
4815            // Comparing against a rebuild catches that, and catches a broken
4816            // rebuild too, since the two are independent code.
4817            //
4818            // Zero-count entries are filtered from both sides: `units_cache`
4819            // keeps a currency's entry for its running total after the last
4820            // lot closes, which a rebuild has no reason to create.
4821            let counts_of = |inv: &Inventory| {
4822                inv.units_cache
4823                    .iter()
4824                    .filter(|(_, stats)| stats.counts != SignCounts::default())
4825                    .map(|(currency, stats)| (currency.as_str().to_string(), stats.counts))
4826                    .collect::<std::collections::BTreeMap<_, _>>()
4827            };
4828            let mut rebuilt = inv.clone();
4829            rebuilt.rebuild_index();
4830            assert_eq!(
4831                counts_of(inv),
4832                counts_of(&rebuilt),
4833                "the incrementally maintained sign counts diverged from a \
4834                 fresh rebuild after {label}",
4835            );
4836            for units in [&usd, &aapl] {
4837                for signed in [
4838                    units.clone(),
4839                    Amount::new(-units.number, units.currency.clone()),
4840                ] {
4841                    for scope in [
4842                        ReductionScope::AllPositions,
4843                        ReductionScope::CostBearingOnly,
4844                    ] {
4845                        assert_eq!(
4846                            inv.is_reduced_by(&signed, scope),
4847                            inv.is_reduced_by_scan(&signed, scope),
4848                            "the sign counts disagree with a scan after {label} \
4849                         for {signed:?} / {scope:?}",
4850                        );
4851                    }
4852                }
4853            }
4854        };
4855
4856        let mut inv = Inventory::new();
4857        check(&inv, "empty");
4858
4859        // Cost-less lot, then a merge that takes it negative through zero.
4860        inv.add(Position::simple(Amount::new(dec!(3), "USD")))
4861            .expect("fits");
4862        check(&inv, "one simple lot");
4863        inv.add(Position::simple(Amount::new(dec!(-8), "USD")))
4864            .expect("fits");
4865        check(&inv, "simple lot flipped negative by merge");
4866        inv.add(Position::simple(Amount::new(dec!(8), "USD")))
4867            .expect("fits");
4868        check(&inv, "simple lot flipped back positive");
4869
4870        // Cost-bearing lots, then reductions that partially and fully drain.
4871        let cost = Cost::new(dec!(100), "USD");
4872        inv.add(Position::with_cost(
4873            Amount::new(dec!(10), "AAPL"),
4874            cost.clone(),
4875        ))
4876        .expect("fits");
4877        check(&inv, "one cost-bearing lot");
4878
4879        inv.reduce(
4880            &Amount::new(dec!(-4), "AAPL"),
4881            Some(&CostSpec::default()),
4882            BookingMethod::Fifo,
4883        )
4884        .expect("partial reduction");
4885        check(&inv, "partially reduced lot");
4886
4887        inv.reduce(
4888            &Amount::new(dec!(-6), "AAPL"),
4889            Some(&CostSpec::default()),
4890            BookingMethod::Fifo,
4891        )
4892        .expect("full reduction");
4893        check(&inv, "fully drained lot");
4894
4895        // STRICT with a single matching lot takes the OTHER commit path —
4896        // `commit_from_lot`, which maintains the caches incrementally instead
4897        // of rebuilding. A FIFO-only test leaves it completely uncovered.
4898        let mut strict = Inventory::new();
4899        strict
4900            .add(Position::with_cost(
4901                Amount::new(dec!(10), "AAPL"),
4902                cost.clone(),
4903            ))
4904            .expect("fits");
4905        check(&strict, "strict: one lot");
4906        strict
4907            .reduce(
4908                &Amount::new(dec!(-4), "AAPL"),
4909                Some(&CostSpec::default()),
4910                BookingMethod::Strict,
4911            )
4912            .expect("partial strict reduction");
4913        check(&strict, "strict: partially reduced");
4914        strict
4915            .reduce(
4916                &Amount::new(dec!(-6), "AAPL"),
4917                Some(&CostSpec::default()),
4918                BookingMethod::Strict,
4919            )
4920            .expect("draining strict reduction");
4921        check(&strict, "strict: lot drained and removed");
4922        assert!(
4923            strict.positions.is_empty(),
4924            "the fixture must actually remove the lot, or the removal path is \
4925             untested",
4926        );
4927
4928        // A SHORT lot covered to exactly zero. This is the only shape where a
4929        // reduction changes a lot's bucket: `is_sign_positive` answers TRUE
4930        // for zero, so a negative lot reaching 0 moves from the negative
4931        // bucket to the positive one in the instant before it is removed.
4932        // Skipping the reclassify then decrements the wrong bucket and leaves
4933        // the index claiming a short lot that no longer exists. A long lot
4934        // cannot show this — it is capped at zero from above and never leaves
4935        // the positive bucket.
4936        let mut short = Inventory::new();
4937        short
4938            .add(Position::with_cost(
4939                Amount::new(dec!(-5), "AAPL"),
4940                Cost::new(dec!(100), "USD"),
4941            ))
4942            .expect("fits");
4943        check(&short, "short: one negative lot");
4944        short
4945            .reduce(
4946                &Amount::new(dec!(5), "AAPL"),
4947                Some(&CostSpec::default()),
4948                BookingMethod::Strict,
4949            )
4950            .expect("covering the short");
4951        check(&short, "short: covered to zero and removed");
4952        assert!(
4953            short.positions.is_empty(),
4954            "the short must actually close, or the bucket flip is untested",
4955        );
4956
4957        // And a rebuild must land on the same state as the incremental path.
4958        // Captured from an inventory whose last mutation was `commit_from_lot`
4959        // (no rebuild), so the two are genuinely independent here.
4960        let mut incremental_inv = Inventory::new();
4961        incremental_inv
4962            .add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost))
4963            .expect("fits");
4964        incremental_inv
4965            .reduce(
4966                &Amount::new(dec!(-4), "AAPL"),
4967                Some(&CostSpec::default()),
4968                BookingMethod::Strict,
4969            )
4970            .expect("partial strict reduction");
4971        let incremental = incremental_inv.units_cache.clone();
4972        assert!(!incremental.is_empty(), "fixture holds a lot");
4973        incremental_inv.rebuild_index();
4974        assert_eq!(
4975            incremental, incremental_inv.units_cache,
4976            "the incrementally maintained index must equal a fresh rebuild",
4977        );
4978    }
4979
4980    /// An inventory whose caches were never built still answers
4981    /// `is_reduced_by` correctly.
4982    ///
4983    /// The caches are `#[serde(skip)]`. Deserialization rebuilds them, but
4984    /// `positions_mut` hands out the position vector directly, so an
4985    /// inventory CAN hold lots with an empty cache. Reading the counts then
4986    /// would answer "not a reduction" for an inventory that plainly holds a
4987    /// matching lot — booking a sale as a purchase and duplicating the lot,
4988    /// which is the #875-class bug this predicate exists to prevent.
4989    ///
4990    /// So the unbuilt case falls back to the scan.
4991    ///
4992    /// The cache is cleared DIRECTLY here. It used to be reached through
4993    /// `positions_mut`, which handed out the position vector and left the
4994    /// caches describing the old contents — that accessor is gone, and its
4995    /// replacement `modify_positions` rebuilds them, so no public API produces
4996    /// this state any more. The fallback stays because `units_cache` is
4997    /// `#[serde(skip)]` and answering "not a reduction" for an inventory that
4998    /// plainly holds a matching lot is the unsafe direction; this constructs
4999    /// the state the only way left, and says so.
5000    #[test]
5001    fn an_unbuilt_cache_falls_back_to_the_scan_rather_than_answering_no() {
5002        let mut inv = Inventory::new();
5003        inv.add(Position::with_cost(
5004            Amount::new(dec!(10), "AAPL"),
5005            Cost::new(dec!(100), "USD"),
5006        ))
5007        .expect("fits");
5008        inv.units_cache.clear();
5009        assert!(
5010            inv.units_cache.is_empty(),
5011            "the fixture must reach `is_reduced_by` with an unbuilt cache, or \
5012             it is testing the fast path instead",
5013        );
5014
5015        assert!(
5016            inv.is_reduced_by(
5017                &Amount::new(dec!(-4), "AAPL"),
5018                ReductionScope::CostBearingOnly
5019            ),
5020            "a sale against a held lot must be seen as a reduction even with \
5021             no cache built",
5022        );
5023        assert!(
5024            !inv.is_reduced_by(
5025                &Amount::new(dec!(4), "AAPL"),
5026                ReductionScope::CostBearingOnly
5027            ),
5028            "a purchase in the same direction is still an augmentation",
5029        );
5030
5031        // And once the caches are built, the answers are unchanged.
5032        inv.rebuild_index();
5033        assert!(!inv.units_cache.is_empty(), "rebuild populates the cache");
5034        assert!(inv.is_reduced_by(
5035            &Amount::new(dec!(-4), "AAPL"),
5036            ReductionScope::CostBearingOnly
5037        ));
5038        assert!(!inv.is_reduced_by(
5039            &Amount::new(dec!(4), "AAPL"),
5040            ReductionScope::CostBearingOnly
5041        ));
5042    }
5043
5044    /// A cost-less lot sitting after a removed one keeps working.
5045    ///
5046    /// Removal used to shift every later position down one, so `simple_index`
5047    /// — which stores positions BY INDEX — had to be repaired to follow the
5048    /// shift. With tombstones nothing moves, so the entry must be left exactly
5049    /// where it is; repairing it now would point `add`'s merge at the wrong
5050    /// slot. Same test, opposite mechanism, and the consequence it guards is
5051    /// unchanged: a later cost-less deposit must MERGE rather than duplicate.
5052    ///
5053    /// Nothing else in the suite covers it: it needs a cost-bearing lot and a
5054    /// cost-less lot in the same inventory, with the cost-bearing one removed
5055    /// first, and inventories in most tests hold only one kind.
5056    #[test]
5057    fn removing_a_lot_repairs_the_index_of_a_later_cost_less_lot() {
5058        let mut inv = Inventory::new();
5059        // Index 0: cost-bearing. Index 1: cost-less, so `simple_index` says 1.
5060        inv.add(Position::with_cost(
5061            Amount::new(dec!(10), "AAPL"),
5062            Cost::new(dec!(100), "USD"),
5063        ))
5064        .expect("fits");
5065        inv.add(Position::simple(Amount::new(dec!(50), "USD")))
5066            .expect("fits");
5067        assert_eq!(
5068            inv.units_cache
5069                .get(&crate::Currency::new("USD"))
5070                .and_then(|s| s.simple_slot),
5071            Some(1),
5072            "fixture must put the cost-less lot second, or the shift is untested",
5073        );
5074
5075        // Drain the cost-bearing lot. STRICT takes the single-lot commit path,
5076        // which tombstones the slot in place.
5077        inv.reduce(
5078            &Amount::new(dec!(-10), "AAPL"),
5079            Some(&CostSpec::default()),
5080            BookingMethod::Strict,
5081        )
5082        .expect("drains the lot");
5083
5084        assert_eq!(
5085            inv.units_cache
5086                .get(&crate::Currency::new("USD"))
5087                .and_then(|s| s.simple_slot),
5088            Some(1),
5089            "the cost-less lot did not move, so its stored slot must not change",
5090        );
5091
5092        // The consequence a stale index actually has: this must MERGE into the
5093        // existing lot, not append a second one.
5094        inv.add(Position::simple(Amount::new(dec!(25), "USD")))
5095            .expect("fits");
5096        assert_eq!(
5097            inv.positions().count(),
5098            1,
5099            "a stale simple_index appends a duplicate cost-less lot instead of \
5100             merging",
5101        );
5102        assert_eq!(inv.units("USD"), dec!(75));
5103    }
5104
5105    /// Reducing a COST-LESS lot to zero removes it, and `simple_index` points
5106    /// at exactly that lot — so the entry must go, not just shift.
5107    #[test]
5108    fn removing_a_cost_less_lot_drops_its_index_entry() {
5109        let mut inv = Inventory::new();
5110        inv.add(Position::simple(Amount::new(dec!(50), "USD")))
5111            .expect("fits");
5112        assert_eq!(
5113            inv.units_cache
5114                .get(&crate::Currency::new("USD"))
5115                .and_then(|s| s.simple_slot),
5116            Some(0)
5117        );
5118
5119        // An empty spec matches a cost-less lot (`matches_cost_spec`:
5120        // `(None, true) => true`), so STRICT selects it and drains it.
5121        inv.reduce(
5122            &Amount::new(dec!(-50), "USD"),
5123            Some(&CostSpec::default()),
5124            BookingMethod::Strict,
5125        )
5126        .expect("drains the cost-less lot");
5127
5128        assert!(inv.positions().next().is_none(), "the lot is gone");
5129        assert_eq!(
5130            inv.units_cache
5131                .get(&crate::Currency::new("USD"))
5132                .and_then(|s| s.simple_slot),
5133            None,
5134            "a stale entry points at a removed lot; the next cost-less add \
5135             indexes past the end",
5136        );
5137
5138        // The consequence: this must not panic and must create a fresh lot.
5139        inv.add(Position::simple(Amount::new(dec!(20), "USD")))
5140            .expect("fits");
5141        assert_eq!(inv.units("USD"), dec!(20));
5142    }
5143
5144    /// Every index `iter_slots` yields must address, through `Index`, the very
5145    /// position it was yielded with.
5146    ///
5147    /// This is trivially true while the backing store is dense — `iter_slots`
5148    /// is `iter().enumerate()` — and it is the whole reason that method
5149    /// exists. The reduction paths collect indices from it and hand them back
5150    /// through `Index`/`IndexMut` to mutate the lot they selected. If the
5151    /// store ever becomes sparse (tombstoned lots, so a cost-keyed index can
5152    /// survive removals) and `iter_slots` keeps counting from zero instead of
5153    /// reporting real slots, every reduction after the first hole mutates the
5154    /// WRONG LOT — silently, with correct-looking totals.
5155    ///
5156    /// So this pins the contract rather than the current implementation.
5157    #[test]
5158    fn iter_slots_yields_indices_that_address_their_own_position() {
5159        let mut inv = Inventory::new();
5160        for units in [dec!(10), dec!(20), dec!(30)] {
5161            inv.add(Position::with_cost(
5162                Amount::new(units, "AAPL"),
5163                Cost::new(units * dec!(10), "USD"),
5164            ))
5165            .expect("fits");
5166        }
5167        // Two lots equal in UNITS and COST, which is what makes the assertion
5168        // below meaningful: with only distinct lots, comparing by value cannot
5169        // tell "the right slot" from "a slot holding an equal position".
5170        //
5171        // They carry different labels so they stay two lots. `add` merges
5172        // interchangeable lots now (#2118), and lots identical in every
5173        // recorded attribute would collapse into one, taking the duplicate
5174        // this test needs with them. The labels restore the shape without
5175        // weakening the check: the positions still compare equal on units and
5176        // cost, which is the trap being guarded against.
5177        for label in ["first", "second"] {
5178            inv.add(Position::with_cost(
5179                Amount::new(dec!(7), "AAPL"),
5180                Cost::new(dec!(70), "USD").with_label(label),
5181            ))
5182            .expect("fits");
5183        }
5184        inv.add(Position::simple(Amount::new(dec!(99), "USD")))
5185            .expect("fits");
5186
5187        let mut seen = 0;
5188        for (slot, position) in inv.positions.iter_slots() {
5189            // Pointer identity, not `assert_eq!`. `Position: PartialEq`, so a
5190            // value comparison passes when a wrong index happens to land on an
5191            // equal lot — exactly what the duplicate pair above arranges.
5192            // Review catch on #2065.
5193            assert!(
5194                std::ptr::eq(std::ptr::from_ref(&inv.positions[slot]), position),
5195                "slot {slot} addresses a different position than the one it \
5196                 was yielded with",
5197            );
5198            seen += 1;
5199        }
5200        assert_eq!(
5201            seen,
5202            inv.positions().count(),
5203            "iter_slots must visit every live position",
5204        );
5205        assert_eq!(seen, 6, "fixture must hold six lots, two of them equal");
5206    }
5207
5208    /// The point of tombstoning: removing a lot must not renumber the lots
5209    /// after it.
5210    ///
5211    /// Shifting was what made a cost-keyed match index impossible — every
5212    /// removal invalidated every later index. This is the property the whole
5213    /// sparse backing exists to provide, so it is asserted directly rather
5214    /// than inferred from something downstream.
5215    #[test]
5216    fn a_removal_does_not_renumber_the_lots_after_it() {
5217        let mut inv = Inventory::new();
5218        for units in [dec!(10), dec!(20), dec!(30)] {
5219            inv.add(Position::with_cost(
5220                Amount::new(units, "AAPL"),
5221                Cost::new(units * dec!(10), "USD"),
5222            ))
5223            .expect("fits");
5224        }
5225        let before: Vec<usize> = inv.positions.iter_slots().map(|(slot, _)| slot).collect();
5226        assert_eq!(before, vec![0, 1, 2], "fixture must fill three slots");
5227
5228        // Drain the MIDDLE lot, so a shift would move the one after it.
5229        inv.reduce(
5230            &Amount::new(dec!(-20), "AAPL"),
5231            Some(
5232                &CostSpec::empty()
5233                    .with_number(crate::CostNumber::PerUnit { value: dec!(200) })
5234                    .with_currency("USD"),
5235            ),
5236            BookingMethod::Strict,
5237        )
5238        .expect("drains the middle lot");
5239
5240        let after: Vec<(usize, Decimal)> = inv
5241            .positions
5242            .iter_slots()
5243            .map(|(slot, p)| (slot, p.units.number))
5244            .collect();
5245        assert_eq!(
5246            after,
5247            vec![(0, dec!(10)), (2, dec!(30))],
5248            "the surviving lots must keep the slots they had; slot 1 is now a \
5249             tombstone and slot 2 must NOT have become slot 1",
5250        );
5251        assert_eq!(inv.positions.len(), 2, "two live lots");
5252        assert_eq!(inv.positions.slot_count(), 3, "three slots, one dead");
5253    }
5254
5255    /// Tombstones must not pile up without bound.
5256    ///
5257    /// Without compaction a long-lived account accumulates one dead slot per
5258    /// closed lot, and every scan walks all of them — matching would degrade
5259    /// toward "every lot this account ever held", which is far worse than the
5260    /// shifting it replaced.
5261    ///
5262    /// `reduce` compacts on its own whenever no undo log is open, which is the
5263    /// case for every caller except a transaction in flight. This drives it
5264    /// exactly as the Late validator does — no engine, no explicit call —
5265    /// because that is the caller that would otherwise grow a dead slot per
5266    /// closed lot for the life of the ledger.
5267    #[test]
5268    fn tombstones_are_compacted_rather_than_accumulating() {
5269        let mut inv = Inventory::new();
5270        for i in 0..50u32 {
5271            let cost = Decimal::from(100 + i);
5272            inv.add(Position::with_cost(
5273                Amount::new(dec!(1), "AAPL"),
5274                Cost::new(cost, "USD"),
5275            ))
5276            .expect("fits");
5277            inv.reduce(
5278                &Amount::new(dec!(-1), "AAPL"),
5279                Some(
5280                    &CostSpec::empty()
5281                        .with_number(crate::CostNumber::PerUnit { value: cost })
5282                        .with_currency("USD"),
5283                ),
5284                BookingMethod::Strict,
5285            )
5286            .expect("drains it again");
5287        }
5288        assert_eq!(inv.positions.len(), 0, "every lot was closed");
5289        assert!(
5290            inv.positions.slot_count() <= 4,
5291            "50 open-and-close cycles left {} slots; compaction is not running, \
5292             and every later scan pays for all of them",
5293            inv.positions.slot_count(),
5294        );
5295    }
5296
5297    /// Draining a lot must take it out of the cost index.
5298    ///
5299    /// A stale entry is not merely wasteful: the slot it names is a tombstone,
5300    /// and it would be handed to the reduction path as a candidate. The lookup
5301    /// tolerates that by design, but the index still has to be maintained —
5302    /// otherwise the lists grow without bound as lots close, and the whole
5303    /// point of the index erodes. Asserted directly, because the tolerant
5304    /// lookup means no behavioral test can see the difference.
5305    #[test]
5306    fn draining_a_lot_removes_it_from_the_cost_index() {
5307        let spec = || {
5308            CostSpec::empty()
5309                .with_number(crate::CostNumber::PerUnit { value: dec!(100) })
5310                .with_currency("USD")
5311        };
5312        let mut inv = Inventory::new();
5313        inv.add(Position::with_cost(
5314            Amount::new(dec!(10), "AAPL"),
5315            Cost::new(dec!(100), "USD"),
5316        ))
5317        .expect("fits");
5318        assert_eq!(inv.cost_index.len(), 1, "the lot is indexed");
5319
5320        inv.reduce(
5321            &Amount::new(dec!(-10), "AAPL"),
5322            Some(&spec()),
5323            BookingMethod::Strict,
5324        )
5325        .expect("drains the lot");
5326
5327        assert!(
5328            inv.cost_index.is_empty(),
5329            "the drained lot is still indexed: {:?}",
5330            inv.cost_index,
5331        );
5332
5333        // And the same cost can be re-used afterwards without tripping over
5334        // the old slot — the case a stale entry would reach.
5335        inv.add(Position::with_cost(
5336            Amount::new(dec!(5), "AAPL"),
5337            Cost::new(dec!(100), "USD"),
5338        ))
5339        .expect("fits");
5340        let result = inv
5341            .reduce(
5342                &Amount::new(dec!(-5), "AAPL"),
5343                Some(&spec()),
5344                BookingMethod::Strict,
5345            )
5346            .expect("re-buying at the same cost and selling must work");
5347        assert_eq!(result.matched.len(), 1);
5348        assert_eq!(inv.positions.len(), 0);
5349    }
5350
5351    /// `modify_positions` hands over a DENSE vector and rebuilds every cache.
5352    ///
5353    /// It replaced `positions_mut`, which returned the backing vector
5354    /// directly. Two promises to keep: the closure must never see tombstones
5355    /// (the sparse backing is an implementation detail), and everything
5356    /// derived — units totals, the cost-less merge index, the cost index and
5357    /// the sign counts — must describe what the closure left, not what was
5358    /// there before. The old accessor kept none of that, which its own docs
5359    /// warned about.
5360    #[test]
5361    fn modify_positions_hands_over_a_dense_vector_and_rebuilds_the_caches() {
5362        let mut inv = Inventory::new();
5363        for units in [dec!(10), dec!(20)] {
5364            inv.add(Position::with_cost(
5365                Amount::new(units, "AAPL"),
5366                Cost::new(units * dec!(10), "USD"),
5367            ))
5368            .expect("fits");
5369        }
5370        // Drain the first lot so a tombstone exists before the handover.
5371        inv.reduce(
5372            &Amount::new(dec!(-10), "AAPL"),
5373            Some(
5374                &CostSpec::empty()
5375                    .with_number(crate::CostNumber::PerUnit { value: dec!(100) })
5376                    .with_currency("USD"),
5377            ),
5378            BookingMethod::Strict,
5379        )
5380        .expect("drains the first lot");
5381        assert_eq!(inv.positions.slot_count(), 2, "one live lot, one tombstone");
5382
5383        inv.modify_positions(|positions| {
5384            assert_eq!(
5385                positions.len(),
5386                1,
5387                "the closure must see only live lots; tombstones are ours, not \
5388                 the caller's",
5389            );
5390            positions.push(Position::simple(Amount::new(dec!(5), "USD")));
5391        });
5392
5393        // Every derived structure now describes what the closure left.
5394        assert_eq!(inv.units("USD"), dec!(5), "units_cache rebuilt");
5395        assert_eq!(inv.units("AAPL"), dec!(20));
5396        assert_eq!(inv.positions.len(), 2);
5397
5398        // simple_index rebuilt: a further cost-less add MERGES.
5399        inv.add(Position::simple(Amount::new(dec!(2), "USD")))
5400            .expect("fits");
5401        assert_eq!(inv.positions.len(), 2, "merged rather than appended");
5402        assert_eq!(inv.units("USD"), dec!(7));
5403
5404        // cost_index rebuilt: the surviving lot is still findable by its cost.
5405        inv.reduce(
5406            &Amount::new(dec!(-20), "AAPL"),
5407            Some(
5408                &CostSpec::empty()
5409                    .with_number(crate::CostNumber::PerUnit { value: dec!(200) })
5410                    .with_currency("USD"),
5411            ),
5412            BookingMethod::Strict,
5413        )
5414        .expect("the surviving lot is still reachable through the cost index");
5415        assert_eq!(inv.units("AAPL"), dec!(0));
5416    }
5417
5418    /// A shared snapshot must not carry the cost index.
5419    ///
5420    /// `Inventory` derives `Clone`, and BQL clones a shared running balance
5421    /// ONCE PER OUTPUT ROW — the executor says so directly above the call.
5422    /// The shared backing makes the positions O(1) to clone, which is what
5423    /// #1086 needed; a per-inventory map holding roughly an entry per distinct
5424    /// cost would put O(lots) straight back into every one of those clones and
5425    /// undo it.
5426    ///
5427    /// Nothing else in the suite would notice: the index is invisible in
5428    /// results, and no instruction profile here runs BQL. So it is asserted
5429    /// directly, on the representation.
5430    #[test]
5431    fn a_shared_snapshot_carries_no_cost_index() {
5432        let mut shared = Inventory::new_shared();
5433        for units in [dec!(10), dec!(20), dec!(30)] {
5434            shared
5435                .add(Position::with_cost(
5436                    Amount::new(units, "AAPL"),
5437                    Cost::new(units * dec!(10), "USD"),
5438                ))
5439                .expect("fits");
5440        }
5441        shared.rebuild_index();
5442        assert!(
5443            shared.cost_index.is_empty(),
5444            "a shared snapshot built an index of {} entries; every per-row \
5445             clone now pays for it",
5446            shared.cost_index.len(),
5447        );
5448
5449        // The owned backing — the one that books — still gets it.
5450        let mut owned = Inventory::new();
5451        for units in [dec!(10), dec!(20), dec!(30)] {
5452            owned
5453                .add(Position::with_cost(
5454                    Amount::new(units, "AAPL"),
5455                    Cost::new(units * dec!(10), "USD"),
5456                ))
5457                .expect("fits");
5458        }
5459        assert_eq!(
5460            owned.cost_index.len(),
5461            3,
5462            "the owned backing must still index its lots, or the fast path is \
5463             dead everywhere",
5464        );
5465
5466        // And a snapshot still books CORRECTLY, by scanning: an inventory with
5467        // no index must never answer "no matching lot" for a lot it holds.
5468        let result = shared
5469            .reduce(
5470                &Amount::new(dec!(-20), "AAPL"),
5471                Some(
5472                    &CostSpec::empty()
5473                        .with_number(crate::CostNumber::PerUnit { value: dec!(200) })
5474                        .with_currency("USD"),
5475                ),
5476                BookingMethod::Strict,
5477            )
5478            .expect("a snapshot with no cost index must fall back to scanning");
5479        assert_eq!(result.matched.len(), 1);
5480    }
5481
5482    /// Tombstones must not reach the wire, and a round trip must come back
5483    /// dense.
5484    ///
5485    /// Every other round-trip test builds its inventory with `add` alone, so
5486    /// none of them has a tombstone in it — the sparse backing was entirely
5487    /// untested through serde. It matters twice over: the wire format is
5488    /// pinned by downstream snapshots, and a leaked `null` would both break
5489    /// them and deserialize into a lot that does not exist.
5490    #[test]
5491    fn a_drained_lot_does_not_reach_the_wire() {
5492        let mut inv = Inventory::new();
5493        for units in [dec!(10), dec!(20)] {
5494            inv.add(Position::with_cost(
5495                Amount::new(units, "AAPL"),
5496                Cost::new(units * dec!(10), "USD"),
5497            ))
5498            .expect("fits");
5499        }
5500        inv.reduce(
5501            &Amount::new(dec!(-10), "AAPL"),
5502            Some(
5503                &CostSpec::empty()
5504                    .with_number(crate::CostNumber::PerUnit { value: dec!(100) })
5505                    .with_currency("USD"),
5506            ),
5507            BookingMethod::Strict,
5508        )
5509        .expect("drains the first lot");
5510        assert_eq!(
5511            inv.positions.slot_count(),
5512            2,
5513            "the fixture must actually hold a tombstone, or this proves nothing",
5514        );
5515        assert_eq!(inv.positions.len(), 1, "one live lot");
5516
5517        let json = serde_json::to_string(&inv).expect("serializes");
5518        // Check the positions ARRAY, not the whole string: `Cost`'s optional
5519        // `date` and `label` serialize as `null` legitimately, so a bare
5520        // "contains null" search reports a leak that is not there.
5521        let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json");
5522        let wire_positions = parsed
5523            .get("positions")
5524            .and_then(serde_json::Value::as_array)
5525            .expect("positions is an array");
5526        assert_eq!(
5527            wire_positions.len(),
5528            1,
5529            "the wire must carry only the live lot, not the tombstone: {json}",
5530        );
5531        assert!(
5532            !wire_positions.iter().any(serde_json::Value::is_null),
5533            "a tombstone leaked onto the wire as a null element: {json}",
5534        );
5535
5536        let round_tripped: Inventory = serde_json::from_str(&json).expect("deserializes");
5537        assert_eq!(
5538            round_tripped.positions.slot_count(),
5539            1,
5540            "the round trip must come back dense, not carrying the hole",
5541        );
5542        assert_eq!(round_tripped.positions.len(), 1);
5543        assert_eq!(round_tripped.units("AAPL"), dec!(20));
5544        assert_eq!(
5545            round_tripped
5546                .positions()
5547                .next()
5548                .expect("one lot")
5549                .units
5550                .number,
5551            dec!(20),
5552        );
5553
5554        // The rebuilt caches must work: the surviving lot is still bookable.
5555        let mut round_tripped = round_tripped;
5556        round_tripped
5557            .reduce(
5558                &Amount::new(dec!(-20), "AAPL"),
5559                Some(
5560                    &CostSpec::empty()
5561                        .with_number(crate::CostNumber::PerUnit { value: dec!(200) })
5562                        .with_currency("USD"),
5563                ),
5564                BookingMethod::Strict,
5565            )
5566            .expect("the deserialized lot is reachable");
5567        assert_eq!(round_tripped.units("AAPL"), dec!(0));
5568    }
5569}