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(¤cy) {
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(¤cy);
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}