Skip to main content

rustledger_core/inventory/
booking.rs

1//! Booking method implementations for Inventory.
2//!
3//! This module contains the implementation of all booking methods (STRICT, FIFO,
4//! LIFO, HIFO, AVERAGE, NONE) used to reduce positions from an inventory.
5
6use rust_decimal::Decimal;
7use rust_decimal::prelude::Signed;
8
9use super::{BookingError, BookingMethod, BookingResult, Inventory};
10use crate::{Amount, CostSpec, Position};
11
12impl Inventory {
13    /// Try reducing positions without modifying the inventory.
14    ///
15    /// This is a read-only version of `reduce()` that returns what would be matched
16    /// without actually modifying the inventory. Useful for previewing booking results
17    /// before committing.
18    ///
19    /// # Arguments
20    ///
21    /// * `units` - The units to reduce (negative for selling)
22    /// * `cost_spec` - Optional cost specification for matching lots
23    /// * `method` - The booking method to use
24    ///
25    /// # Returns
26    ///
27    /// Returns a `BookingResult` with the positions that would be matched and cost basis,
28    /// or a `BookingError` if the reduction cannot be performed.
29    pub fn try_reduce(
30        &self,
31        units: &Amount,
32        cost_spec: Option<&CostSpec>,
33        method: BookingMethod,
34    ) -> Result<BookingResult, BookingError> {
35        let spec = cost_spec.cloned().unwrap_or_default();
36
37        match method {
38            BookingMethod::Strict | BookingMethod::StrictWithSize => {
39                self.try_reduce_strict(units, &spec, method == BookingMethod::StrictWithSize)
40            }
41            BookingMethod::Fifo => self.try_reduce_ordered(units, &spec, false),
42            BookingMethod::Lifo => self.try_reduce_ordered(units, &spec, true),
43            BookingMethod::Hifo => self.try_reduce_hifo(units, &spec),
44            BookingMethod::Average => self.try_reduce_average(units),
45            BookingMethod::None => self.try_reduce_ordered(units, &CostSpec::default(), false),
46        }
47    }
48
49    /// Try `STRICT`/`STRICT_WITH_SIZE` booking without modifying inventory.
50    fn try_reduce_strict(
51        &self,
52        units: &Amount,
53        spec: &CostSpec,
54        with_size: bool,
55    ) -> Result<BookingResult, BookingError> {
56        let matching_indices: Vec<usize> = self
57            .positions
58            .iter()
59            .enumerate()
60            .filter(|(_, p)| {
61                p.units.currency == units.currency
62                    && !p.is_empty()
63                    && p.can_reduce(units)
64                    && p.matches_cost_spec(spec)
65            })
66            .map(|(i, _)| i)
67            .collect();
68
69        match matching_indices.len() {
70            0 => Err(BookingError::NoMatchingLot {
71                currency: units.currency.clone(),
72                cost_spec: spec.clone(),
73            }),
74            1 => {
75                let idx = matching_indices[0];
76                self.try_reduce_from_lot(idx, units)
77            }
78            n => {
79                if with_size {
80                    // Check for exact-size match with any lot
81                    let exact_matches: Vec<usize> = matching_indices
82                        .iter()
83                        .filter(|&&i| self.positions[i].units.number.abs() == units.number.abs())
84                        .copied()
85                        .collect();
86
87                    if exact_matches.is_empty() {
88                        // Total match exception
89                        let total_units: Decimal = matching_indices
90                            .iter()
91                            .map(|&i| self.positions[i].units.number.abs())
92                            .sum();
93                        if total_units == units.number.abs() {
94                            self.try_reduce_ordered(units, spec, false)
95                        } else {
96                            Err(BookingError::AmbiguousMatch {
97                                num_matches: n,
98                                currency: units.currency.clone(),
99                            })
100                        }
101                    } else {
102                        let idx = exact_matches[0];
103                        self.try_reduce_from_lot(idx, units)
104                    }
105                } else {
106                    // STRICT: fall back to FIFO when multiple match
107                    self.try_reduce_ordered(units, spec, false)
108                }
109            }
110        }
111    }
112
113    /// Try ordered (FIFO/LIFO) booking without modifying inventory.
114    fn try_reduce_ordered(
115        &self,
116        units: &Amount,
117        spec: &CostSpec,
118        reverse: bool,
119    ) -> Result<BookingResult, BookingError> {
120        let mut remaining = units.number.abs();
121        let mut matched = Vec::new();
122        let mut cost_basis = Decimal::ZERO;
123        let mut cost_currency = None;
124
125        // Get indices of matching positions
126        let mut indices: Vec<usize> = self
127            .positions
128            .iter()
129            .enumerate()
130            .filter(|(_, p)| {
131                p.units.currency == units.currency
132                    && !p.is_empty()
133                    && p.units.number.signum() != units.number.signum()
134                    && p.matches_cost_spec(spec)
135            })
136            .map(|(i, _)| i)
137            .collect();
138
139        // Sort by date for correct FIFO/LIFO ordering
140        indices.sort_by_key(|&i| self.positions[i].cost.as_ref().and_then(|c| c.date));
141
142        if reverse {
143            indices.reverse();
144        }
145
146        if indices.is_empty() {
147            return Err(BookingError::NoMatchingLot {
148                currency: units.currency.clone(),
149                cost_spec: spec.clone(),
150            });
151        }
152
153        for idx in indices {
154            if remaining.is_zero() {
155                break;
156            }
157
158            let pos = &self.positions[idx];
159            let available = pos.units.number.abs();
160            let take = remaining.min(available);
161
162            // Calculate cost basis for this portion
163            if let Some(cost) = &pos.cost {
164                cost_basis += take * cost.number;
165                cost_currency = Some(cost.currency.clone());
166            }
167
168            // Record what we would match (using split which is read-only)
169            let (taken, _) = pos.split(take * pos.units.number.signum());
170            matched.push(taken);
171
172            remaining -= take;
173        }
174
175        if !remaining.is_zero() {
176            let available = units.number.abs() - remaining;
177            return Err(BookingError::InsufficientUnits {
178                currency: units.currency.clone(),
179                requested: units.number.abs(),
180                available,
181            });
182        }
183
184        Ok(BookingResult {
185            matched,
186            cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
187        })
188    }
189
190    /// Try HIFO booking without modifying inventory.
191    fn try_reduce_hifo(
192        &self,
193        units: &Amount,
194        spec: &CostSpec,
195    ) -> Result<BookingResult, BookingError> {
196        let mut remaining = units.number.abs();
197        let mut matched = Vec::new();
198        let mut cost_basis = Decimal::ZERO;
199        let mut cost_currency = None;
200
201        // Get matching positions with their costs
202        let mut matching: Vec<(usize, Decimal)> = self
203            .positions
204            .iter()
205            .enumerate()
206            .filter(|(_, p)| {
207                p.units.currency == units.currency
208                    && !p.is_empty()
209                    && p.units.number.signum() != units.number.signum()
210                    && p.matches_cost_spec(spec)
211            })
212            .map(|(i, p)| {
213                let cost = p.cost.as_ref().map_or(Decimal::ZERO, |c| c.number);
214                (i, cost)
215            })
216            .collect();
217
218        if matching.is_empty() {
219            return Err(BookingError::NoMatchingLot {
220                currency: units.currency.clone(),
221                cost_spec: spec.clone(),
222            });
223        }
224
225        // Sort by cost descending (highest first)
226        matching.sort_by(|a, b| b.1.cmp(&a.1));
227
228        let indices: Vec<usize> = matching.into_iter().map(|(i, _)| i).collect();
229
230        for idx in indices {
231            if remaining.is_zero() {
232                break;
233            }
234
235            let pos = &self.positions[idx];
236            let available = pos.units.number.abs();
237            let take = remaining.min(available);
238
239            // Calculate cost basis for this portion
240            if let Some(cost) = &pos.cost {
241                cost_basis += take * cost.number;
242                cost_currency = Some(cost.currency.clone());
243            }
244
245            // Record what we would match
246            let (taken, _) = pos.split(take * pos.units.number.signum());
247            matched.push(taken);
248
249            remaining -= take;
250        }
251
252        if !remaining.is_zero() {
253            let available = units.number.abs() - remaining;
254            return Err(BookingError::InsufficientUnits {
255                currency: units.currency.clone(),
256                requested: units.number.abs(),
257                available,
258            });
259        }
260
261        Ok(BookingResult {
262            matched,
263            cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
264        })
265    }
266
267    /// Try AVERAGE booking without modifying inventory.
268    fn try_reduce_average(&self, units: &Amount) -> Result<BookingResult, BookingError> {
269        let total_units: Decimal = self
270            .positions
271            .iter()
272            .filter(|p| p.units.currency == units.currency && !p.is_empty())
273            .map(|p| p.units.number)
274            .sum();
275
276        if total_units.is_zero() {
277            return Err(BookingError::InsufficientUnits {
278                currency: units.currency.clone(),
279                requested: units.number.abs(),
280                available: Decimal::ZERO,
281            });
282        }
283
284        let reduction = units.number.abs();
285        if reduction > total_units.abs() {
286            return Err(BookingError::InsufficientUnits {
287                currency: units.currency.clone(),
288                requested: reduction,
289                available: total_units.abs(),
290            });
291        }
292
293        let book_values = self.book_value(&units.currency);
294        let cost_basis = if let Some((curr, &total)) = book_values.iter().next() {
295            let per_unit_cost = total / total_units;
296            Some(Amount::new(reduction * per_unit_cost, curr.clone()))
297        } else {
298            None
299        };
300
301        let matched: Vec<Position> = self
302            .positions
303            .iter()
304            .filter(|p| p.units.currency == units.currency && !p.is_empty())
305            .cloned()
306            .collect();
307
308        Ok(BookingResult {
309            matched,
310            cost_basis,
311        })
312    }
313
314    /// Try reducing from a specific lot without modifying inventory.
315    fn try_reduce_from_lot(
316        &self,
317        idx: usize,
318        units: &Amount,
319    ) -> Result<BookingResult, BookingError> {
320        let pos = &self.positions[idx];
321        let available = pos.units.number.abs();
322        let requested = units.number.abs();
323
324        if requested > available {
325            return Err(BookingError::InsufficientUnits {
326                currency: units.currency.clone(),
327                requested,
328                available,
329            });
330        }
331
332        let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
333        let (matched, _) = pos.split(requested * pos.units.number.signum());
334
335        Ok(BookingResult {
336            matched: vec![matched],
337            cost_basis,
338        })
339    }
340}
341
342impl Inventory {
343    /// STRICT booking: require exactly one matching lot.
344    /// Also allows "total match exception": if reduction equals total inventory, accept.
345    pub(super) fn reduce_strict(
346        &mut self,
347        units: &Amount,
348        spec: &CostSpec,
349    ) -> Result<BookingResult, BookingError> {
350        let matching_indices: Vec<usize> = self
351            .positions
352            .iter()
353            .enumerate()
354            .filter(|(_, p)| {
355                p.units.currency == units.currency
356                    && !p.is_empty()
357                    && p.can_reduce(units)
358                    && p.matches_cost_spec(spec)
359            })
360            .map(|(i, _)| i)
361            .collect();
362
363        match matching_indices.len() {
364            0 => Err(BookingError::NoMatchingLot {
365                currency: units.currency.clone(),
366                cost_spec: spec.clone(),
367            }),
368            1 => {
369                let idx = matching_indices[0];
370                self.reduce_from_lot(idx, units)
371            }
372            _n => {
373                // When multiple lots match the same cost spec, Python beancount falls back to FIFO
374                // order rather than erroring. This is consistent with how beancount handles
375                // identical lots - if the cost spec is specified, it's considered "matched"
376                // and we just pick by insertion order.
377                self.reduce_ordered(units, spec, false)
378            }
379        }
380    }
381
382    /// `STRICT_WITH_SIZE` booking: like STRICT, but exact-size matches accept oldest lot.
383    pub(super) fn reduce_strict_with_size(
384        &mut self,
385        units: &Amount,
386        spec: &CostSpec,
387    ) -> Result<BookingResult, BookingError> {
388        let matching_indices: Vec<usize> = self
389            .positions
390            .iter()
391            .enumerate()
392            .filter(|(_, p)| {
393                p.units.currency == units.currency
394                    && !p.is_empty()
395                    && p.can_reduce(units)
396                    && p.matches_cost_spec(spec)
397            })
398            .map(|(i, _)| i)
399            .collect();
400
401        match matching_indices.len() {
402            0 => Err(BookingError::NoMatchingLot {
403                currency: units.currency.clone(),
404                cost_spec: spec.clone(),
405            }),
406            1 => {
407                let idx = matching_indices[0];
408                self.reduce_from_lot(idx, units)
409            }
410            n => {
411                // Check for exact-size match with any lot
412                let exact_matches: Vec<usize> = matching_indices
413                    .iter()
414                    .filter(|&&i| self.positions[i].units.number.abs() == units.number.abs())
415                    .copied()
416                    .collect();
417
418                if exact_matches.is_empty() {
419                    // Total match exception
420                    let total_units: Decimal = matching_indices
421                        .iter()
422                        .map(|&i| self.positions[i].units.number.abs())
423                        .sum();
424                    if total_units == units.number.abs() {
425                        self.reduce_ordered(units, spec, false)
426                    } else {
427                        Err(BookingError::AmbiguousMatch {
428                            num_matches: n,
429                            currency: units.currency.clone(),
430                        })
431                    }
432                } else {
433                    // Use oldest (first) exact-size match
434                    let idx = exact_matches[0];
435                    self.reduce_from_lot(idx, units)
436                }
437            }
438        }
439    }
440
441    /// FIFO booking: reduce from oldest lots first.
442    pub(super) fn reduce_fifo(
443        &mut self,
444        units: &Amount,
445        spec: &CostSpec,
446    ) -> Result<BookingResult, BookingError> {
447        self.reduce_ordered(units, spec, false)
448    }
449
450    /// LIFO booking: reduce from newest lots first.
451    pub(super) fn reduce_lifo(
452        &mut self,
453        units: &Amount,
454        spec: &CostSpec,
455    ) -> Result<BookingResult, BookingError> {
456        self.reduce_ordered(units, spec, true)
457    }
458
459    /// HIFO booking: reduce from highest-cost lots first.
460    pub(super) fn reduce_hifo(
461        &mut self,
462        units: &Amount,
463        spec: &CostSpec,
464    ) -> Result<BookingResult, BookingError> {
465        let mut remaining = units.number.abs();
466        let mut matched = Vec::new();
467        let mut cost_basis = Decimal::ZERO;
468        let mut cost_currency = None;
469
470        // Get matching positions with their costs
471        let mut matching: Vec<(usize, Decimal)> = self
472            .positions
473            .iter()
474            .enumerate()
475            .filter(|(_, p)| {
476                p.units.currency == units.currency
477                    && !p.is_empty()
478                    && p.units.number.signum() != units.number.signum()
479                    && p.matches_cost_spec(spec)
480            })
481            .map(|(i, p)| {
482                let cost = p.cost.as_ref().map_or(Decimal::ZERO, |c| c.number);
483                (i, cost)
484            })
485            .collect();
486
487        if matching.is_empty() {
488            return Err(BookingError::NoMatchingLot {
489                currency: units.currency.clone(),
490                cost_spec: spec.clone(),
491            });
492        }
493
494        // Sort by cost descending (highest first)
495        matching.sort_by(|a, b| b.1.cmp(&a.1));
496
497        let indices: Vec<usize> = matching.into_iter().map(|(i, _)| i).collect();
498
499        for idx in indices {
500            if remaining.is_zero() {
501                break;
502            }
503
504            let pos = &self.positions[idx];
505            let available = pos.units.number.abs();
506            let take = remaining.min(available);
507
508            // Calculate cost basis for this portion
509            if let Some(cost) = &pos.cost {
510                cost_basis += take * cost.number;
511                cost_currency = Some(cost.currency.clone());
512            }
513
514            // Record what we matched
515            let (taken, _) = pos.split(take * pos.units.number.signum());
516            matched.push(taken);
517
518            // Reduce the lot
519            let reduction = if units.number.is_sign_negative() {
520                -take
521            } else {
522                take
523            };
524
525            let new_pos = Position {
526                units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
527                cost: pos.cost.clone(),
528            };
529            self.positions[idx] = new_pos;
530
531            remaining -= take;
532        }
533
534        if !remaining.is_zero() {
535            let available = units.number.abs() - remaining;
536            return Err(BookingError::InsufficientUnits {
537                currency: units.currency.clone(),
538                requested: units.number.abs(),
539                available,
540            });
541        }
542
543        // Clean up empty positions
544        self.positions.retain(|p| !p.is_empty());
545        self.rebuild_index();
546
547        Ok(BookingResult {
548            matched,
549            cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
550        })
551    }
552
553    /// Reduce in order (FIFO or LIFO).
554    pub(super) fn reduce_ordered(
555        &mut self,
556        units: &Amount,
557        spec: &CostSpec,
558        reverse: bool,
559    ) -> Result<BookingResult, BookingError> {
560        let mut remaining = units.number.abs();
561        let mut matched = Vec::new();
562        let mut cost_basis = Decimal::ZERO;
563        let mut cost_currency = None;
564
565        // Get indices of matching positions
566        let mut indices: Vec<usize> = self
567            .positions
568            .iter()
569            .enumerate()
570            .filter(|(_, p)| {
571                p.units.currency == units.currency
572                    && !p.is_empty()
573                    && p.units.number.signum() != units.number.signum()
574                    && p.matches_cost_spec(spec)
575            })
576            .map(|(i, _)| i)
577            .collect();
578
579        // Sort by date for correct FIFO/LIFO ordering (oldest first)
580        // This ensures we select by acquisition date, not insertion order
581        indices.sort_by_key(|&i| self.positions[i].cost.as_ref().and_then(|c| c.date));
582
583        if reverse {
584            indices.reverse();
585        }
586
587        if indices.is_empty() {
588            return Err(BookingError::NoMatchingLot {
589                currency: units.currency.clone(),
590                cost_spec: spec.clone(),
591            });
592        }
593
594        // Get cost currency from first lot (all lots of same commodity have same cost currency)
595        if let Some(&first_idx) = indices.first() {
596            if let Some(cost) = &self.positions[first_idx].cost {
597                cost_currency = Some(cost.currency.clone());
598            }
599        }
600
601        for idx in indices {
602            if remaining.is_zero() {
603                break;
604            }
605
606            let pos = &mut self.positions[idx];
607            let available = pos.units.number.abs();
608            let take = remaining.min(available);
609
610            // Calculate cost basis for this portion
611            if let Some(cost) = &pos.cost {
612                cost_basis += take * cost.number;
613            }
614
615            // Record what we matched
616            let (taken, _) = pos.split(take * pos.units.number.signum());
617            matched.push(taken);
618
619            // Reduce the lot - modify in place to avoid cloning
620            let reduction = if units.number.is_sign_negative() {
621                -take
622            } else {
623                take
624            };
625            pos.units.number += reduction;
626
627            remaining -= take;
628        }
629
630        if !remaining.is_zero() {
631            let available = units.number.abs() - remaining;
632            return Err(BookingError::InsufficientUnits {
633                currency: units.currency.clone(),
634                requested: units.number.abs(),
635                available,
636            });
637        }
638
639        // Clean up empty positions
640        self.positions.retain(|p| !p.is_empty());
641        self.rebuild_index();
642
643        Ok(BookingResult {
644            matched,
645            cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
646        })
647    }
648
649    /// AVERAGE booking: merge all lots of the currency.
650    pub(super) fn reduce_average(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
651        // Calculate average cost
652        let total_units: Decimal = self
653            .positions
654            .iter()
655            .filter(|p| p.units.currency == units.currency && !p.is_empty())
656            .map(|p| p.units.number)
657            .sum();
658
659        if total_units.is_zero() {
660            return Err(BookingError::InsufficientUnits {
661                currency: units.currency.clone(),
662                requested: units.number.abs(),
663                available: Decimal::ZERO,
664            });
665        }
666
667        // Check sufficient units
668        let reduction = units.number.abs();
669        if reduction > total_units.abs() {
670            return Err(BookingError::InsufficientUnits {
671                currency: units.currency.clone(),
672                requested: reduction,
673                available: total_units.abs(),
674            });
675        }
676
677        // Calculate total cost basis
678        let book_values = self.book_value(&units.currency);
679        let cost_basis = if let Some((curr, &total)) = book_values.iter().next() {
680            let per_unit_cost = total / total_units;
681            Some(Amount::new(reduction * per_unit_cost, curr.clone()))
682        } else {
683            None
684        };
685
686        // Create merged position
687        let new_units = total_units + units.number;
688
689        // Remove all positions of this currency
690        let matched: Vec<Position> = self
691            .positions
692            .iter()
693            .filter(|p| p.units.currency == units.currency && !p.is_empty())
694            .cloned()
695            .collect();
696
697        self.positions
698            .retain(|p| p.units.currency != units.currency);
699
700        // Add back the remainder if non-zero
701        if !new_units.is_zero() {
702            self.positions.push(Position::simple(Amount::new(
703                new_units,
704                units.currency.clone(),
705            )));
706        }
707
708        // Rebuild index after modifications
709        self.rebuild_index();
710
711        Ok(BookingResult {
712            matched,
713            cost_basis,
714        })
715    }
716
717    /// NONE booking: reduce without matching lots.
718    pub(super) fn reduce_none(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
719        // For NONE booking, we just reduce the total without caring about lots
720        let total_units = self.units(&units.currency);
721
722        // Check we have enough in the right direction
723        if total_units.signum() == units.number.signum() || total_units.is_zero() {
724            // This is an augmentation, not a reduction - just add it
725            self.add(Position::simple(units.clone()));
726            return Ok(BookingResult {
727                matched: vec![],
728                cost_basis: None,
729            });
730        }
731
732        let available = total_units.abs();
733        let requested = units.number.abs();
734
735        if requested > available {
736            return Err(BookingError::InsufficientUnits {
737                currency: units.currency.clone(),
738                requested,
739                available,
740            });
741        }
742
743        // Reduce positions proportionally (simplified: just reduce first matching)
744        self.reduce_ordered(units, &CostSpec::default(), false)
745    }
746
747    /// Reduce from a specific lot.
748    pub(super) fn reduce_from_lot(
749        &mut self,
750        idx: usize,
751        units: &Amount,
752    ) -> Result<BookingResult, BookingError> {
753        let pos = &self.positions[idx];
754        let available = pos.units.number.abs();
755        let requested = units.number.abs();
756
757        if requested > available {
758            return Err(BookingError::InsufficientUnits {
759                currency: units.currency.clone(),
760                requested,
761                available,
762            });
763        }
764
765        // Calculate cost basis
766        let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
767
768        // Record matched
769        let (matched, _) = pos.split(requested * pos.units.number.signum());
770
771        // Update the position
772        let currency = pos.units.currency.clone();
773        let new_units = pos.units.number + units.number;
774        let new_pos = Position {
775            units: Amount::new(new_units, currency.clone()),
776            cost: pos.cost.clone(),
777        };
778        self.positions[idx] = new_pos;
779
780        // Update units cache incrementally (units.number is negative for reductions)
781        if let Some(cached) = self.units_cache.get_mut(&currency) {
782            *cached += units.number;
783        }
784
785        // Remove if empty and rebuild simple_index
786        if self.positions[idx].is_empty() {
787            self.positions.remove(idx);
788            // Only rebuild simple_index when position is removed
789            self.simple_index.clear();
790            for (i, p) in self.positions.iter().enumerate() {
791                if p.cost.is_none() {
792                    self.simple_index.insert(p.units.currency.clone(), i);
793                }
794            }
795        }
796
797        Ok(BookingResult {
798            matched: vec![matched],
799            cost_basis,
800        })
801    }
802}