1use rust_decimal::Decimal;
8use rust_decimal::prelude::Signed;
9
10use smallvec::{SmallVec, smallvec};
11
12use super::{BookingError, BookingMethod, BookingResult, Inventory, MatchedLots};
13use crate::{Amount, Cost, CostSpec, Currency, Position};
14
15fn average_cost_from_positions(
20 positions: &[&Position],
21 total_units: Decimal,
22) -> Result<Option<(Decimal, Currency)>, BookingError> {
23 let mut total_cost = Decimal::ZERO;
24 let mut cost_currency: Option<Currency> = None;
25 let mut has_any_cost = false;
26
27 for pos in positions {
28 if let Some(cost) = &pos.cost {
29 has_any_cost = true;
30 if let Some(ref cc) = cost_currency {
31 if *cc != cost.currency {
32 return Err(BookingError::CurrencyMismatch {
33 expected: cc.clone(),
34 got: cost.currency.clone(),
35 });
36 }
37 } else {
38 cost_currency = Some(cost.currency.clone());
39 }
40 total_cost += pos.units.number * cost.number;
41 }
42 }
43
44 if !has_any_cost || cost_currency.is_none() {
45 return Ok(None);
46 }
47
48 Ok(Some((total_cost / total_units, cost_currency.unwrap())))
49}
50
51impl Inventory {
52 pub fn try_reduce(
69 &self,
70 units: &Amount,
71 cost_spec: Option<&CostSpec>,
72 method: BookingMethod,
73 ) -> Result<BookingResult, BookingError> {
74 let spec = cost_spec.cloned().unwrap_or_default();
75
76 if spec.merge {
78 return self.try_reduce_average(units);
79 }
80
81 match method {
82 BookingMethod::Strict | BookingMethod::StrictWithSize => {
83 self.try_reduce_strict(units, &spec, method == BookingMethod::StrictWithSize)
84 }
85 BookingMethod::Fifo => self.try_reduce_ordered(units, &spec, false),
86 BookingMethod::Lifo => self.try_reduce_ordered(units, &spec, true),
87 BookingMethod::Hifo => self.try_reduce_hifo(units, &spec),
88 BookingMethod::Average => self.try_reduce_average(units),
89 BookingMethod::None => self.try_reduce_ordered(units, &CostSpec::default(), false),
90 }
91 }
92
93 fn try_reduce_strict(
95 &self,
96 units: &Amount,
97 spec: &CostSpec,
98 with_size: bool,
99 ) -> Result<BookingResult, BookingError> {
100 let matching_indices: Vec<usize> = self
101 .positions
102 .iter()
103 .enumerate()
104 .filter(|(_, p)| {
105 p.units.currency == units.currency
106 && !p.is_empty()
107 && p.can_reduce(units)
108 && p.matches_cost_spec(spec)
109 })
110 .map(|(i, _)| i)
111 .collect();
112
113 match matching_indices.len() {
114 0 => Err(BookingError::NoMatchingLot {
115 currency: units.currency.clone(),
116 cost_spec: spec.clone(),
117 }),
118 1 => {
119 let idx = matching_indices[0];
120 self.try_reduce_from_lot(idx, units)
121 }
122 n => {
123 if with_size {
124 let exact_matches: Vec<usize> = matching_indices
126 .iter()
127 .filter(|&&i| self.positions[i].units.number.abs() == units.number.abs())
128 .copied()
129 .collect();
130
131 if exact_matches.is_empty() {
132 let total_units: Decimal = matching_indices
134 .iter()
135 .map(|&i| self.positions[i].units.number.abs())
136 .sum();
137 if total_units == units.number.abs() {
138 self.try_reduce_ordered(units, spec, false)
139 } else {
140 Err(BookingError::AmbiguousMatch {
141 num_matches: n,
142 currency: units.currency.clone(),
143 })
144 }
145 } else {
146 let idx = exact_matches[0];
147 self.try_reduce_from_lot(idx, units)
148 }
149 } else {
150 self.try_reduce_ordered(units, spec, false)
152 }
153 }
154 }
155 }
156
157 fn try_reduce_ordered(
159 &self,
160 units: &Amount,
161 spec: &CostSpec,
162 reverse: bool,
163 ) -> Result<BookingResult, BookingError> {
164 let mut remaining = units.number.abs();
165 let mut matched: MatchedLots = SmallVec::new();
166 let mut cost_basis = Decimal::ZERO;
167 let mut cost_currency = None;
168
169 let mut indices: Vec<usize> = self
171 .positions
172 .iter()
173 .enumerate()
174 .filter(|(_, p)| {
175 p.units.currency == units.currency
176 && !p.is_empty()
177 && p.units.number.signum() != units.number.signum()
178 && p.matches_cost_spec(spec)
179 })
180 .map(|(i, _)| i)
181 .collect();
182
183 indices.sort_by_key(|&i| self.positions[i].cost.as_ref().and_then(|c| c.date));
185
186 if reverse {
187 indices.reverse();
188 }
189
190 if indices.is_empty() {
191 return Err(BookingError::NoMatchingLot {
192 currency: units.currency.clone(),
193 cost_spec: spec.clone(),
194 });
195 }
196
197 for idx in indices {
198 if remaining.is_zero() {
199 break;
200 }
201
202 let pos = &self.positions[idx];
203 let available = pos.units.number.abs();
204 let take = remaining.min(available);
205
206 if let Some(cost) = &pos.cost {
208 cost_basis += take * cost.number;
209 cost_currency = Some(cost.currency.clone());
210 }
211
212 let (taken, _) = pos.split(take * pos.units.number.signum());
214 matched.push(taken);
215
216 remaining -= take;
217 }
218
219 if !remaining.is_zero() {
220 let available = units.number.abs() - remaining;
221 return Err(BookingError::InsufficientUnits {
222 currency: units.currency.clone(),
223 requested: units.number.abs(),
224 available,
225 });
226 }
227
228 Ok(BookingResult {
229 matched,
230 cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
231 })
232 }
233
234 fn try_reduce_hifo(
236 &self,
237 units: &Amount,
238 spec: &CostSpec,
239 ) -> Result<BookingResult, BookingError> {
240 let mut remaining = units.number.abs();
241 let mut matched: MatchedLots = SmallVec::new();
242 let mut cost_basis = Decimal::ZERO;
243 let mut cost_currency = None;
244
245 let mut matching: Vec<(usize, Decimal)> = self
247 .positions
248 .iter()
249 .enumerate()
250 .filter(|(_, p)| {
251 p.units.currency == units.currency
252 && !p.is_empty()
253 && p.units.number.signum() != units.number.signum()
254 && p.matches_cost_spec(spec)
255 })
256 .map(|(i, p)| {
257 let cost = p.cost.as_ref().map_or(Decimal::ZERO, |c| c.number);
258 (i, cost)
259 })
260 .collect();
261
262 if matching.is_empty() {
263 return Err(BookingError::NoMatchingLot {
264 currency: units.currency.clone(),
265 cost_spec: spec.clone(),
266 });
267 }
268
269 matching.sort_by_key(|(_, cost)| std::cmp::Reverse(*cost));
271
272 let indices: Vec<usize> = matching.into_iter().map(|(i, _)| i).collect();
273
274 for idx in indices {
275 if remaining.is_zero() {
276 break;
277 }
278
279 let pos = &self.positions[idx];
280 let available = pos.units.number.abs();
281 let take = remaining.min(available);
282
283 if let Some(cost) = &pos.cost {
285 cost_basis += take * cost.number;
286 cost_currency = Some(cost.currency.clone());
287 }
288
289 let (taken, _) = pos.split(take * pos.units.number.signum());
291 matched.push(taken);
292
293 remaining -= take;
294 }
295
296 if !remaining.is_zero() {
297 let available = units.number.abs() - remaining;
298 return Err(BookingError::InsufficientUnits {
299 currency: units.currency.clone(),
300 requested: units.number.abs(),
301 available,
302 });
303 }
304
305 Ok(BookingResult {
306 matched,
307 cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
308 })
309 }
310
311 fn try_reduce_average(&self, units: &Amount) -> Result<BookingResult, BookingError> {
313 let matching: Vec<&Position> = self
314 .positions
315 .iter()
316 .filter(|p| p.units.currency == units.currency && !p.is_empty())
317 .collect();
318
319 let total_units: Decimal = matching.iter().map(|p| p.units.number).sum();
320
321 if total_units.is_zero() {
322 return Err(BookingError::InsufficientUnits {
323 currency: units.currency.clone(),
324 requested: units.number.abs(),
325 available: Decimal::ZERO,
326 });
327 }
328
329 let reduction = units.number.abs();
330 if reduction > total_units.abs() {
331 return Err(BookingError::InsufficientUnits {
332 currency: units.currency.clone(),
333 requested: reduction,
334 available: total_units.abs(),
335 });
336 }
337
338 let cost_basis = average_cost_from_positions(&matching, total_units)?
339 .map(|(avg_cost, currency)| Amount::new(reduction * avg_cost, currency));
340
341 let matched: MatchedLots = matching.into_iter().cloned().collect();
342
343 Ok(BookingResult {
344 matched,
345 cost_basis,
346 })
347 }
348
349 fn try_reduce_from_lot(
351 &self,
352 idx: usize,
353 units: &Amount,
354 ) -> Result<BookingResult, BookingError> {
355 let pos = &self.positions[idx];
356 let available = pos.units.number.abs();
357 let requested = units.number.abs();
358
359 if requested > available {
360 return Err(BookingError::InsufficientUnits {
361 currency: units.currency.clone(),
362 requested,
363 available,
364 });
365 }
366
367 let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
368 let (matched, _) = pos.split(requested * pos.units.number.signum());
369
370 Ok(BookingResult {
371 matched: smallvec![matched],
372 cost_basis,
373 })
374 }
375}
376
377impl Inventory {
378 pub(super) fn reduce_strict(
412 &mut self,
413 units: &Amount,
414 spec: &CostSpec,
415 ) -> Result<BookingResult, BookingError> {
416 let matching_indices: Vec<usize> = self
417 .positions
418 .iter()
419 .enumerate()
420 .filter(|(_, p)| {
421 p.units.currency == units.currency
422 && !p.is_empty()
423 && p.can_reduce(units)
424 && p.matches_cost_spec(spec)
425 })
426 .map(|(i, _)| i)
427 .collect();
428
429 match matching_indices.len() {
430 0 => Err(BookingError::NoMatchingLot {
431 currency: units.currency.clone(),
432 cost_spec: spec.clone(),
433 }),
434 1 => {
435 let idx = matching_indices[0];
436 self.reduce_from_lot(idx, units)
437 }
438 n => {
439 let first_key = self.positions[matching_indices[0]]
446 .cost
447 .as_ref()
448 .map(|c| (c.number, c.currency.clone()));
449 let all_same_value = matching_indices.iter().skip(1).all(|&i| {
450 let key = self.positions[i]
451 .cost
452 .as_ref()
453 .map(|c| (c.number, c.currency.clone()));
454 key == first_key
455 });
456
457 if all_same_value {
458 return self.reduce_ordered(units, spec, false);
459 }
460
461 let total_units: Decimal = matching_indices
465 .iter()
466 .map(|&i| self.positions[i].units.number.abs())
467 .sum();
468 if total_units == units.number.abs() {
469 return self.reduce_ordered(units, spec, false);
470 }
471
472 Err(BookingError::AmbiguousMatch {
473 num_matches: n,
474 currency: units.currency.clone(),
475 })
476 }
477 }
478 }
479
480 pub(super) fn reduce_strict_with_size(
482 &mut self,
483 units: &Amount,
484 spec: &CostSpec,
485 ) -> Result<BookingResult, BookingError> {
486 let matching_indices: Vec<usize> = self
487 .positions
488 .iter()
489 .enumerate()
490 .filter(|(_, p)| {
491 p.units.currency == units.currency
492 && !p.is_empty()
493 && p.can_reduce(units)
494 && p.matches_cost_spec(spec)
495 })
496 .map(|(i, _)| i)
497 .collect();
498
499 match matching_indices.len() {
500 0 => Err(BookingError::NoMatchingLot {
501 currency: units.currency.clone(),
502 cost_spec: spec.clone(),
503 }),
504 1 => {
505 let idx = matching_indices[0];
506 self.reduce_from_lot(idx, units)
507 }
508 n => {
509 let exact_matches: Vec<usize> = matching_indices
511 .iter()
512 .filter(|&&i| self.positions[i].units.number.abs() == units.number.abs())
513 .copied()
514 .collect();
515
516 if exact_matches.is_empty() {
517 let total_units: Decimal = matching_indices
519 .iter()
520 .map(|&i| self.positions[i].units.number.abs())
521 .sum();
522 if total_units == units.number.abs() {
523 self.reduce_ordered(units, spec, false)
524 } else {
525 Err(BookingError::AmbiguousMatch {
526 num_matches: n,
527 currency: units.currency.clone(),
528 })
529 }
530 } else {
531 let idx = exact_matches[0];
533 self.reduce_from_lot(idx, units)
534 }
535 }
536 }
537 }
538
539 pub(super) fn reduce_fifo(
541 &mut self,
542 units: &Amount,
543 spec: &CostSpec,
544 ) -> Result<BookingResult, BookingError> {
545 self.reduce_ordered(units, spec, false)
546 }
547
548 pub(super) fn reduce_lifo(
550 &mut self,
551 units: &Amount,
552 spec: &CostSpec,
553 ) -> Result<BookingResult, BookingError> {
554 self.reduce_ordered(units, spec, true)
555 }
556
557 pub(super) fn reduce_hifo(
559 &mut self,
560 units: &Amount,
561 spec: &CostSpec,
562 ) -> Result<BookingResult, BookingError> {
563 let mut remaining = units.number.abs();
564 let mut matched: MatchedLots = SmallVec::new();
565 let mut cost_basis = Decimal::ZERO;
566 let mut cost_currency = None;
567
568 let mut matching: Vec<(usize, Decimal)> = self
570 .positions
571 .iter()
572 .enumerate()
573 .filter(|(_, p)| {
574 p.units.currency == units.currency
575 && !p.is_empty()
576 && p.units.number.signum() != units.number.signum()
577 && p.matches_cost_spec(spec)
578 })
579 .map(|(i, p)| {
580 let cost = p.cost.as_ref().map_or(Decimal::ZERO, |c| c.number);
581 (i, cost)
582 })
583 .collect();
584
585 if matching.is_empty() {
586 return Err(BookingError::NoMatchingLot {
587 currency: units.currency.clone(),
588 cost_spec: spec.clone(),
589 });
590 }
591
592 matching.sort_by_key(|(_, cost)| std::cmp::Reverse(*cost));
594
595 let indices: Vec<usize> = matching.into_iter().map(|(i, _)| i).collect();
596
597 for idx in indices {
598 if remaining.is_zero() {
599 break;
600 }
601
602 let pos = &self.positions[idx];
603 let available = pos.units.number.abs();
604 let take = remaining.min(available);
605
606 if let Some(cost) = &pos.cost {
608 cost_basis += take * cost.number;
609 cost_currency = Some(cost.currency.clone());
610 }
611
612 let (taken, _) = pos.split(take * pos.units.number.signum());
614 matched.push(taken);
615
616 let reduction = if units.number.is_sign_negative() {
618 -take
619 } else {
620 take
621 };
622
623 let new_pos = Position {
624 units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
625 cost: pos.cost.clone(),
626 };
627 self.positions[idx] = new_pos;
628
629 remaining -= take;
630 }
631
632 if !remaining.is_zero() {
633 let available = units.number.abs() - remaining;
634 return Err(BookingError::InsufficientUnits {
635 currency: units.currency.clone(),
636 requested: units.number.abs(),
637 available,
638 });
639 }
640
641 self.positions.retain(|p| !p.is_empty());
643 self.rebuild_index();
644
645 Ok(BookingResult {
646 matched,
647 cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
648 })
649 }
650
651 pub(super) fn reduce_ordered(
653 &mut self,
654 units: &Amount,
655 spec: &CostSpec,
656 reverse: bool,
657 ) -> Result<BookingResult, BookingError> {
658 let mut remaining = units.number.abs();
659 let mut matched: MatchedLots = SmallVec::new();
660 let mut cost_basis = Decimal::ZERO;
661 let mut cost_currency = None;
662
663 let mut indices: Vec<usize> = self
665 .positions
666 .iter()
667 .enumerate()
668 .filter(|(_, p)| {
669 p.units.currency == units.currency
670 && !p.is_empty()
671 && p.units.number.signum() != units.number.signum()
672 && p.matches_cost_spec(spec)
673 })
674 .map(|(i, _)| i)
675 .collect();
676
677 indices.sort_by_key(|&i| self.positions[i].cost.as_ref().and_then(|c| c.date));
680
681 if reverse {
682 indices.reverse();
683 }
684
685 if indices.is_empty() {
686 return Err(BookingError::NoMatchingLot {
687 currency: units.currency.clone(),
688 cost_spec: spec.clone(),
689 });
690 }
691
692 if let Some(&first_idx) = indices.first()
694 && let Some(cost) = &self.positions[first_idx].cost
695 {
696 cost_currency = Some(cost.currency.clone());
697 }
698
699 for idx in indices {
700 if remaining.is_zero() {
701 break;
702 }
703
704 let pos = &mut self.positions[idx];
705 let available = pos.units.number.abs();
706 let take = remaining.min(available);
707
708 if let Some(cost) = &pos.cost {
710 cost_basis += take * cost.number;
711 }
712
713 let (taken, _) = pos.split(take * pos.units.number.signum());
715 matched.push(taken);
716
717 let reduction = if units.number.is_sign_negative() {
719 -take
720 } else {
721 take
722 };
723 pos.units.number += reduction;
724
725 remaining -= take;
726 }
727
728 if !remaining.is_zero() {
729 let available = units.number.abs() - remaining;
730 return Err(BookingError::InsufficientUnits {
731 currency: units.currency.clone(),
732 requested: units.number.abs(),
733 available,
734 });
735 }
736
737 self.positions.retain(|p| !p.is_empty());
739 self.rebuild_index();
740
741 Ok(BookingResult {
742 matched,
743 cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
744 })
745 }
746
747 pub(super) fn reduce_average(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
749 let matching: Vec<&Position> = self
750 .positions
751 .iter()
752 .filter(|p| p.units.currency == units.currency && !p.is_empty())
753 .collect();
754
755 let total_units: Decimal = matching.iter().map(|p| p.units.number).sum();
756
757 if total_units.is_zero() {
758 return Err(BookingError::InsufficientUnits {
759 currency: units.currency.clone(),
760 requested: units.number.abs(),
761 available: Decimal::ZERO,
762 });
763 }
764
765 let reduction = units.number.abs();
766 if reduction > total_units.abs() {
767 return Err(BookingError::InsufficientUnits {
768 currency: units.currency.clone(),
769 requested: reduction,
770 available: total_units.abs(),
771 });
772 }
773
774 let avg = average_cost_from_positions(&matching, total_units)?;
775 let cost_basis = avg
776 .as_ref()
777 .map(|(avg_cost, currency)| Amount::new(reduction * *avg_cost, currency.clone()));
778
779 let at_avg_cost = |number: Decimal| -> Position {
782 let amount = Amount::new(number, units.currency.clone());
783 match &avg {
784 Some((avg_cost, currency)) => {
785 Position::with_cost(amount, Cost::new(*avg_cost, currency.clone()))
786 }
787 None => Position::simple(amount),
788 }
789 };
790
791 let matched: MatchedLots = smallvec![at_avg_cost(reduction * total_units.signum())];
799
800 let new_units = total_units + units.number;
801
802 self.positions
804 .retain(|p| p.units.currency != units.currency);
805
806 if !new_units.is_zero() {
809 self.positions.push_back(at_avg_cost(new_units));
810 }
811
812 self.rebuild_index();
813
814 Ok(BookingResult {
815 matched,
816 cost_basis,
817 })
818 }
819
820 pub fn merge_average(&mut self) {
829 let currencies: std::collections::BTreeSet<Currency> = self
830 .positions
831 .iter()
832 .filter(|p| p.cost.is_some())
833 .map(|p| p.units.currency.clone())
834 .collect();
835
836 for currency in currencies {
837 let (total_units, avg) = {
838 let matching: Vec<&Position> = self
839 .positions
840 .iter()
841 .filter(|p| p.units.currency == currency && p.cost.is_some())
842 .collect();
843 let total_units: Decimal = matching.iter().map(|p| p.units.number).sum();
844 let avg = if total_units.is_zero() {
845 None
846 } else {
847 average_cost_from_positions(&matching, total_units)
848 .ok()
849 .flatten()
850 };
851 (total_units, avg)
852 };
853
854 if !total_units.is_zero() && avg.is_none() {
857 continue;
858 }
859
860 self.positions
861 .retain(|p| !(p.units.currency == currency && p.cost.is_some()));
862 if let Some((avg_cost, cost_currency)) = avg {
863 self.positions.push_back(Position::with_cost(
864 Amount::new(total_units, currency.clone()),
865 Cost::new(avg_cost, cost_currency),
866 ));
867 }
868 }
869 self.rebuild_index();
870 }
871
872 pub(super) fn reduce_merge(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
878 let matching: Vec<(usize, &Position)> = self
881 .positions
882 .iter()
883 .enumerate()
884 .filter(|(_, p)| {
885 p.units.currency == units.currency
886 && !p.is_empty()
887 && p.units.number.is_sign_positive() != units.number.is_sign_positive()
888 })
889 .collect();
890
891 if matching.is_empty() {
892 return Err(BookingError::InsufficientUnits {
893 currency: units.currency.clone(),
894 requested: units.number.abs(),
895 available: Decimal::ZERO,
896 });
897 }
898
899 let total_units: Decimal = matching.iter().map(|(_, p)| p.units.number).sum();
900 let reduction = units.number.abs();
901
902 if reduction > total_units.abs() {
903 return Err(BookingError::InsufficientUnits {
904 currency: units.currency.clone(),
905 requested: reduction,
906 available: total_units.abs(),
907 });
908 }
909
910 let matching_refs: Vec<&Position> = matching.iter().map(|(_, p)| *p).collect();
912 let (avg_cost, cost_currency) =
913 match average_cost_from_positions(&matching_refs, total_units)? {
914 Some(result) => result,
915 None => return self.reduce_average(units),
916 };
917
918 let cost_basis = Some(Amount::new(reduction * avg_cost, cost_currency.clone()));
919
920 let make_avg_cost = || Cost {
924 number: avg_cost,
925 currency: cost_currency.clone(),
926 date: None,
927 label: None,
928 };
929
930 let matched: MatchedLots = smallvec![Position::with_cost(
931 Amount::new(units.number.abs(), units.currency.clone()),
932 make_avg_cost(),
933 )];
934
935 let matching_indices: std::collections::HashSet<usize> =
937 matching.iter().map(|(i, _)| *i).collect();
938 let mut idx = 0;
939 self.positions.retain(|_| {
940 let keep = !matching_indices.contains(&idx);
941 idx += 1;
942 keep
943 });
944
945 let remaining = total_units + units.number; if !remaining.is_zero() {
948 self.positions.push_back(Position::with_cost(
949 Amount::new(remaining, units.currency.clone()),
950 make_avg_cost(),
951 ));
952 }
953
954 self.rebuild_index();
955
956 Ok(BookingResult {
957 matched,
958 cost_basis,
959 })
960 }
961
962 pub(super) fn reduce_none(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
964 let total_units = self.units(&units.currency);
966
967 if total_units.signum() == units.number.signum() || total_units.is_zero() {
969 self.add(Position::simple(units.clone()));
971 return Ok(BookingResult {
972 matched: SmallVec::new(),
973 cost_basis: None,
974 });
975 }
976
977 let available = total_units.abs();
978 let requested = units.number.abs();
979
980 if requested > available {
981 return Err(BookingError::InsufficientUnits {
982 currency: units.currency.clone(),
983 requested,
984 available,
985 });
986 }
987
988 self.reduce_ordered(units, &CostSpec::default(), false)
990 }
991
992 pub(super) fn reduce_from_lot(
994 &mut self,
995 idx: usize,
996 units: &Amount,
997 ) -> Result<BookingResult, BookingError> {
998 let pos = &self.positions[idx];
999 let available = pos.units.number.abs();
1000 let requested = units.number.abs();
1001
1002 if requested > available {
1003 return Err(BookingError::InsufficientUnits {
1004 currency: units.currency.clone(),
1005 requested,
1006 available,
1007 });
1008 }
1009
1010 let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
1012
1013 let (matched, _) = pos.split(requested * pos.units.number.signum());
1015
1016 let currency = pos.units.currency.clone();
1018 let new_units = pos.units.number + units.number;
1019 let new_pos = Position {
1020 units: Amount::new(new_units, currency.clone()),
1021 cost: pos.cost.clone(),
1022 };
1023 self.positions[idx] = new_pos;
1024
1025 if let Some(cached) = self.units_cache.get_mut(¤cy) {
1027 *cached += units.number;
1028 }
1029
1030 if self.positions[idx].is_empty() {
1032 self.positions.remove(idx);
1033 self.simple_index.clear();
1035 for (i, p) in self.positions.iter().enumerate() {
1036 if p.cost.is_none() {
1037 self.simple_index.insert(p.units.currency.clone(), i);
1038 }
1039 }
1040 }
1041
1042 Ok(BookingResult {
1043 matched: smallvec![matched],
1044 cost_basis,
1045 })
1046 }
1047}
1048
1049#[cfg(test)]
1050mod reduction_tests {
1051 use crate::{Amount, BookingMethod, Cost, CostSpec, Inventory, Position, naive_date};
1058 use rust_decimal::Decimal;
1059 use rust_decimal_macros::dec;
1060
1061 fn d(n: i64) -> Decimal {
1062 Decimal::from(n)
1063 }
1064
1065 fn lot(units: i64, cost: i64, day: u32) -> Position {
1067 Position::with_cost(
1068 Amount::new(d(units), "STK"),
1069 Cost::new(d(cost), "USD").with_date(naive_date(2024, 1, day).unwrap()),
1070 )
1071 }
1072
1073 fn mk(lots: impl IntoIterator<Item = Position>) -> Inventory {
1074 let mut i = Inventory::new();
1075 for l in lots {
1076 i.add(l);
1077 }
1078 i
1079 }
1080
1081 fn sell_stk(n: i64) -> Amount {
1082 Amount::new(d(-n), "STK")
1083 }
1084
1085 fn try_reduce(inv: &Inventory, units: &Amount, method: BookingMethod) -> super::BookingResult {
1086 inv.try_reduce(units, Some(&CostSpec::default()), method)
1087 .expect("reduction should succeed")
1088 }
1089
1090 fn basis(r: &super::BookingResult) -> Decimal {
1091 r.cost_basis.as_ref().expect("cost basis present").number
1092 }
1093
1094 #[test]
1097 fn fifo_partial_multilot_cost_basis_and_order() {
1098 let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1100 let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Fifo);
1101 assert_eq!(basis(&r), dec!(2000));
1103 assert_eq!(r.matched.len(), 2);
1104 assert_eq!(r.matched[0].units.number.abs(), dec!(10));
1105 assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(100));
1106 assert_eq!(r.matched[1].units.number.abs(), dec!(5));
1107 assert_eq!(r.matched[1].cost.as_ref().unwrap().number, dec!(200));
1108 }
1109
1110 #[test]
1111 fn lifo_takes_newest_lot_first() {
1112 let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1113 let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Lifo);
1114 assert_eq!(basis(&r), dec!(2500));
1117 assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(200));
1118 }
1119
1120 #[test]
1121 fn fifo_single_lot_partial_cost_basis() {
1122 let inv = mk([lot(10, 100, 1)]);
1123 let r = try_reduce(&inv, &sell_stk(3), BookingMethod::Fifo);
1124 assert_eq!(basis(&r), dec!(300)); }
1126
1127 #[test]
1130 fn hifo_takes_highest_cost_lot_first() {
1131 let inv = mk([lot(10, 100, 1), lot(10, 300, 2), lot(10, 200, 3)]);
1133 let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Hifo);
1134 assert_eq!(basis(&r), dec!(4000));
1136 assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(300));
1137 assert_eq!(r.matched[1].cost.as_ref().unwrap().number, dec!(200));
1138 }
1139
1140 #[test]
1143 fn average_cost_basis_partial() {
1144 let inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
1146 let r = try_reduce(&inv, &sell_stk(20), BookingMethod::Average);
1147 assert_eq!(basis(&r), dec!(3500)); }
1149
1150 #[test]
1151 fn average_reduce_exact_total_succeeds() {
1152 let inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
1155 let r = try_reduce(&inv, &sell_stk(40), BookingMethod::Average);
1156 assert_eq!(basis(&r), dec!(7000)); }
1158
1159 #[test]
1160 fn average_over_reduction_errors() {
1161 let inv = mk([lot(10, 100, 1)]);
1163 let err = inv
1164 .try_reduce(
1165 &sell_stk(20),
1166 Some(&CostSpec::default()),
1167 BookingMethod::Average,
1168 )
1169 .unwrap_err();
1170 assert!(matches!(err, super::BookingError::InsufficientUnits { .. }));
1171 }
1172
1173 fn isolation_inv() -> Inventory {
1183 let mut i = Inventory::new();
1184 i.add(Position::with_cost(
1185 Amount::new(dec!(10), "OTH"), Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 1).unwrap()),
1187 ));
1188 i.add(lot(10, 100, 2)); i
1190 }
1191
1192 fn assert_isolated(method: BookingMethod) {
1193 let inv = isolation_inv();
1194 let r = try_reduce(&inv, &sell_stk(5), method);
1195 assert_eq!(
1196 basis(&r),
1197 dec!(500),
1198 "must reduce only the real STK lot (5 * 100)"
1199 );
1200 assert!(
1201 r.matched.iter().all(|p| p.units.currency.as_ref() == "STK"),
1202 "no non-STK lot should be matched"
1203 );
1204 }
1205
1206 #[test]
1207 fn fifo_filters_currency() {
1208 assert_isolated(BookingMethod::Fifo);
1209 }
1210
1211 #[test]
1212 fn hifo_filters_currency() {
1213 assert_isolated(BookingMethod::Hifo);
1214 }
1215
1216 #[test]
1217 fn strict_filters_currency() {
1218 assert_isolated(BookingMethod::Strict);
1219 }
1220
1221 #[test]
1222 fn average_filters_currency() {
1223 let inv = isolation_inv();
1225 let r = try_reduce(&inv, &sell_stk(5), BookingMethod::Average);
1226 assert_eq!(basis(&r), dec!(500));
1228 }
1229
1230 #[test]
1233 fn does_not_match_same_sign_lot() {
1234 let mut i = Inventory::new();
1239 i.add(lot(-10, 50, 1)); i.add(lot(10, 100, 2)); let r = try_reduce(&i, &sell_stk(5), BookingMethod::Fifo);
1242 assert_eq!(basis(&r), dec!(500)); assert!(r.matched.iter().all(|p| p.units.number.is_sign_positive()));
1244 }
1245
1246 #[test]
1247 fn strict_rejects_when_only_same_sign_lot_present() {
1248 let mut i = Inventory::new();
1257 i.add(lot(-10, 100, 1)); let res = i.try_reduce(
1259 &sell_stk(5),
1260 Some(&CostSpec::default()),
1261 BookingMethod::Strict,
1262 );
1263 assert!(
1264 matches!(res, Err(super::BookingError::NoMatchingLot { .. })),
1265 "strict reduction against a same-sign-only inventory must not match; got {res:?}"
1266 );
1267 }
1268
1269 #[test]
1272 fn fifo_insufficient_reports_available() {
1273 let inv = mk([lot(10, 100, 1)]);
1276 let err = inv
1277 .try_reduce(
1278 &sell_stk(15),
1279 Some(&CostSpec::default()),
1280 BookingMethod::Fifo,
1281 )
1282 .unwrap_err();
1283 match err {
1284 super::BookingError::InsufficientUnits {
1285 requested,
1286 available,
1287 ..
1288 } => {
1289 assert_eq!(requested, dec!(15));
1290 assert_eq!(available, dec!(10)); }
1292 other => panic!("expected InsufficientUnits, got {other:?}"),
1293 }
1294 }
1295
1296 #[test]
1299 fn strict_single_lot_partial_cost_basis() {
1300 let inv = mk([lot(10, 100, 1)]);
1302 let r = try_reduce(&inv, &sell_stk(4), BookingMethod::Strict);
1303 assert_eq!(basis(&r), dec!(400)); }
1305
1306 #[test]
1307 fn strict_single_lot_over_reduction_errors() {
1308 let inv = mk([lot(10, 100, 1)]);
1310 let err = inv
1311 .try_reduce(
1312 &sell_stk(11),
1313 Some(&CostSpec::default()),
1314 BookingMethod::Strict,
1315 )
1316 .unwrap_err();
1317 assert!(matches!(err, super::BookingError::InsufficientUnits { .. }));
1318 }
1319
1320 #[test]
1321 fn strict_single_lot_exact_full_reduction_succeeds() {
1322 let inv = mk([lot(10, 100, 1)]);
1324 let r = try_reduce(&inv, &sell_stk(10), BookingMethod::Strict);
1325 assert_eq!(basis(&r), dec!(1000));
1326 }
1327
1328 #[test]
1331 fn hifo_matched_units_and_insufficient_available() {
1332 let inv = mk([lot(10, 100, 1), lot(10, 300, 2)]);
1333 let r = try_reduce(&inv, &sell_stk(8), BookingMethod::Hifo);
1334 assert_eq!(r.matched[0].units.number.abs(), dec!(8));
1336 let err = inv
1337 .try_reduce(
1338 &sell_stk(25),
1339 Some(&CostSpec::default()),
1340 BookingMethod::Hifo,
1341 )
1342 .unwrap_err();
1343 match err {
1344 super::BookingError::InsufficientUnits { available, .. } => {
1345 assert_eq!(available, dec!(20)); }
1347 other => panic!("expected InsufficientUnits, got {other:?}"),
1348 }
1349 }
1350
1351 #[test]
1352 fn strict_from_lot_matched_units() {
1353 let inv = mk([lot(10, 100, 1)]);
1354 let r = try_reduce(&inv, &sell_stk(4), BookingMethod::Strict);
1355 assert_eq!(r.matched[0].units.number.abs(), dec!(4)); }
1357
1358 #[test]
1361 fn strict_with_size_picks_exact_size_lot() {
1362 let inv = mk([lot(10, 100, 1), lot(5, 200, 2)]);
1363 let r = try_reduce(&inv, &sell_stk(5), BookingMethod::StrictWithSize);
1364 assert_eq!(basis(&r), dec!(1000)); }
1366
1367 #[test]
1368 fn strict_with_size_ambiguous_without_exact_or_total() {
1369 let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1370 let err = inv
1371 .try_reduce(
1372 &sell_stk(5),
1373 Some(&CostSpec::default()),
1374 BookingMethod::StrictWithSize,
1375 )
1376 .unwrap_err();
1377 assert!(matches!(err, super::BookingError::AmbiguousMatch { .. }));
1378 }
1379
1380 #[test]
1381 fn strict_with_size_total_match_falls_back_to_fifo() {
1382 let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1383 let r = try_reduce(&inv, &sell_stk(20), BookingMethod::StrictWithSize);
1384 assert_eq!(basis(&r), dec!(3000)); }
1386
1387 #[test]
1390 fn reduce_fifo_commits_and_basis() {
1391 let mut inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1392 let r = inv
1393 .reduce(
1394 &sell_stk(15),
1395 Some(&CostSpec::default()),
1396 BookingMethod::Fifo,
1397 )
1398 .unwrap();
1399 assert_eq!(r.cost_basis.unwrap().number, dec!(2000));
1400 assert_eq!(inv.units("STK"), dec!(5)); }
1402
1403 #[test]
1404 fn reduce_on_large_shared_inventory_does_not_corrupt() {
1405 let mut inv = mk((0i64..100).map(|i| lot(10, 100 + i, ((i % 28) + 1) as u32)));
1418 let snapshot = inv.clone(); inv.reduce(
1420 &sell_stk(700),
1421 Some(&CostSpec::default()),
1422 BookingMethod::Fifo,
1423 )
1424 .unwrap();
1425 assert_eq!(inv.units("STK"), dec!(300)); assert_eq!(snapshot.units("STK"), dec!(1000));
1429 }
1430
1431 #[test]
1432 fn reduce_hifo_commits_basis_units_insufficient() {
1433 let mut inv = mk([lot(10, 100, 1), lot(10, 300, 2)]);
1434 let r = inv
1435 .reduce(
1436 &sell_stk(15),
1437 Some(&CostSpec::default()),
1438 BookingMethod::Hifo,
1439 )
1440 .unwrap();
1441 assert_eq!(r.cost_basis.unwrap().number, dec!(3500)); assert_eq!(r.matched[0].units.number.abs(), dec!(10)); let mut inv2 = mk([lot(10, 100, 1)]);
1444 let err = inv2
1445 .reduce(
1446 &sell_stk(25),
1447 Some(&CostSpec::default()),
1448 BookingMethod::Hifo,
1449 )
1450 .unwrap_err();
1451 match err {
1452 super::BookingError::InsufficientUnits { available, .. } => {
1453 assert_eq!(available, dec!(10));
1454 }
1455 other => panic!("expected InsufficientUnits, got {other:?}"),
1456 }
1457 }
1458
1459 #[test]
1460 fn reduce_average_only_matching_currency() {
1461 let mut i = Inventory::new();
1462 i.add(lot(10, 100, 2));
1463 i.add(Position::with_cost(
1464 Amount::new(dec!(10), "OTH"),
1465 Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 1).unwrap()),
1466 ));
1467 let r = i
1468 .reduce(
1469 &sell_stk(5),
1470 Some(&CostSpec::default()),
1471 BookingMethod::Average,
1472 )
1473 .unwrap();
1474 assert_eq!(r.cost_basis.unwrap().number, dec!(500)); }
1476
1477 #[test]
1478 fn reduce_average_partial_multi_lot_matches_single_synthetic_lot() {
1479 let mut i = Inventory::new();
1485 i.add(lot(10, 150, 1));
1486 i.add(lot(10, 170, 2));
1487 let r = i
1488 .reduce(
1489 &sell_stk(5),
1490 Some(&CostSpec::default()),
1491 BookingMethod::Average,
1492 )
1493 .unwrap();
1494
1495 assert_eq!(r.matched.len(), 1);
1498 assert_eq!(r.cost_basis.as_ref().unwrap().number, dec!(800));
1499 assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(160));
1500 assert_eq!(r.matched[0].units.number, dec!(5));
1501
1502 assert_eq!(i.units("STK"), dec!(15));
1504 let remaining: Vec<&Position> = i
1505 .positions()
1506 .filter(|p| p.units.currency == "STK")
1507 .collect();
1508 assert_eq!(remaining.len(), 1);
1509 assert_eq!(remaining[0].cost.as_ref().unwrap().number, dec!(160));
1510 }
1511
1512 #[test]
1513 fn reduce_average_short_cover_matched_lot_carries_inventory_sign() {
1514 let mut i = Inventory::new();
1517 i.add(Position::with_cost(
1518 Amount::new(dec!(-10), "STK"),
1519 Cost::new(dec!(150), "USD"),
1520 ));
1521 let r = i
1522 .reduce(
1523 &Amount::new(dec!(5), "STK"),
1524 Some(&CostSpec::default()),
1525 BookingMethod::Average,
1526 )
1527 .unwrap();
1528 assert_eq!(r.matched.len(), 1);
1529 assert_eq!(r.matched[0].units.number, dec!(-5));
1530 assert_eq!(i.units("STK"), dec!(-5));
1532 }
1533
1534 #[test]
1535 fn merge_average_collapses_lots_to_single_weighted_lot() {
1536 let mut i = Inventory::new();
1539 i.add(lot(10, 150, 1));
1540 i.add(lot(10, 170, 2));
1541 i.add(Position::with_cost(
1542 Amount::new(dec!(-5), "STK"),
1543 Cost::new(dec!(160), "USD"),
1544 ));
1545 i.merge_average();
1546 let stk: Vec<&Position> = i
1547 .positions()
1548 .filter(|p| p.units.currency == "STK")
1549 .collect();
1550 assert_eq!(stk.len(), 1);
1551 assert_eq!(stk[0].units.number, dec!(15));
1552 assert_eq!(stk[0].cost.as_ref().unwrap().number, dec!(160));
1553 }
1554
1555 #[test]
1556 fn merge_average_net_zero_removes_lots() {
1557 let mut i = Inventory::new();
1558 i.add(lot(10, 150, 1));
1559 i.add(Position::with_cost(
1560 Amount::new(dec!(-10), "STK"),
1561 Cost::new(dec!(160), "USD"),
1562 ));
1563 i.merge_average();
1564 assert_eq!(
1565 i.positions().filter(|p| p.units.currency == "STK").count(),
1566 0
1567 );
1568 }
1569
1570 #[test]
1571 fn merge_average_leaves_costless_positions_untouched() {
1572 let mut i = Inventory::new();
1573 i.add(Position::simple(Amount::new(dec!(100), "USD")));
1574 i.add(lot(10, 150, 1));
1575 i.merge_average();
1576 assert_eq!(i.units("USD"), dec!(100));
1578 assert_eq!(
1579 i.positions().filter(|p| p.units.currency == "STK").count(),
1580 1
1581 );
1582 }
1583
1584 #[test]
1585 fn reduce_from_lot_matched_and_remaining_units() {
1586 let mut inv = mk([lot(10, 100, 1)]);
1587 let r = inv
1588 .reduce(
1589 &sell_stk(4),
1590 Some(&CostSpec::default()),
1591 BookingMethod::Strict,
1592 )
1593 .unwrap();
1594 assert_eq!(r.matched[0].units.number.abs(), dec!(4)); let remaining: Vec<_> = inv.position_list();
1599 assert_eq!(remaining.len(), 1);
1600 assert_eq!(remaining[0].units.number, dec!(6)); assert_eq!(inv.units("STK"), dec!(6)); }
1603
1604 #[test]
1605 fn reduce_merge_filters_currency_sign_and_preserves_other_lots() {
1606 let mut inv = Inventory::new();
1610 inv.add(lot(10, 100, 1)); inv.add(lot(30, 200, 2)); inv.add(lot(-5, 999, 3)); inv.add(Position::with_cost(
1614 Amount::new(dec!(10), "OTH"), Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 4).unwrap()),
1616 ));
1617 let spec = CostSpec {
1618 merge: true,
1619 ..CostSpec::default()
1620 };
1621 let r = inv
1622 .reduce(&sell_stk(20), Some(&spec), BookingMethod::Strict)
1623 .unwrap();
1624 assert_eq!(r.cost_basis.unwrap().number, dec!(3500));
1627 assert!(
1629 inv.position_list()
1630 .iter()
1631 .any(|p| p.units.currency.as_ref() == "OTH" && p.units.number == dec!(10)),
1632 "OTH lot must survive the merge"
1633 );
1634 assert!(
1635 inv.position_list()
1636 .iter()
1637 .any(|p| p.units.currency.as_ref() == "STK" && p.units.number == dec!(-5)),
1638 "short STK lot must survive the merge"
1639 );
1640 }
1641
1642 #[test]
1643 fn reduce_none_exact_succeeds_over_reduction_errors() {
1644 let mut inv = Inventory::new();
1645 inv.add(Position::simple(Amount::new(dec!(10), "STK")));
1646 assert!(
1647 inv.reduce(&sell_stk(10), None, BookingMethod::None).is_ok(),
1648 "exact NONE reduction should succeed"
1649 );
1650 let mut inv2 = Inventory::new();
1651 inv2.add(Position::simple(Amount::new(dec!(10), "STK")));
1652 let err = inv2
1653 .reduce(&sell_stk(15), None, BookingMethod::None)
1654 .unwrap_err();
1655 assert!(matches!(err, super::BookingError::InsufficientUnits { .. }));
1656 }
1657
1658 #[test]
1659 fn reduce_merge_uses_weighted_average() {
1660 let mut inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
1661 let spec = CostSpec {
1662 merge: true,
1663 ..CostSpec::default()
1664 };
1665 let r = inv
1666 .reduce(&sell_stk(20), Some(&spec), BookingMethod::Strict)
1667 .unwrap();
1668 assert_eq!(r.cost_basis.unwrap().number, dec!(3500)); assert_eq!(inv.units("STK"), dec!(20)); }
1671}