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 cost_basis = average_cost_from_positions(&matching, total_units)?
775 .map(|(avg_cost, currency)| Amount::new(reduction * avg_cost, currency));
776
777 let matched: MatchedLots = matching.into_iter().cloned().collect();
778 let new_units = total_units + units.number;
779
780 self.positions
782 .retain(|p| p.units.currency != units.currency);
783
784 if !new_units.is_zero() {
786 self.positions.push_back(Position::simple(Amount::new(
787 new_units,
788 units.currency.clone(),
789 )));
790 }
791
792 self.rebuild_index();
793
794 Ok(BookingResult {
795 matched,
796 cost_basis,
797 })
798 }
799
800 pub(super) fn reduce_merge(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
806 let matching: Vec<(usize, &Position)> = self
809 .positions
810 .iter()
811 .enumerate()
812 .filter(|(_, p)| {
813 p.units.currency == units.currency
814 && !p.is_empty()
815 && p.units.number.is_sign_positive() != units.number.is_sign_positive()
816 })
817 .collect();
818
819 if matching.is_empty() {
820 return Err(BookingError::InsufficientUnits {
821 currency: units.currency.clone(),
822 requested: units.number.abs(),
823 available: Decimal::ZERO,
824 });
825 }
826
827 let total_units: Decimal = matching.iter().map(|(_, p)| p.units.number).sum();
828 let reduction = units.number.abs();
829
830 if reduction > total_units.abs() {
831 return Err(BookingError::InsufficientUnits {
832 currency: units.currency.clone(),
833 requested: reduction,
834 available: total_units.abs(),
835 });
836 }
837
838 let matching_refs: Vec<&Position> = matching.iter().map(|(_, p)| *p).collect();
840 let (avg_cost, cost_currency) =
841 match average_cost_from_positions(&matching_refs, total_units)? {
842 Some(result) => result,
843 None => return self.reduce_average(units),
844 };
845
846 let cost_basis = Some(Amount::new(reduction * avg_cost, cost_currency.clone()));
847
848 let make_avg_cost = || Cost {
852 number: avg_cost,
853 currency: cost_currency.clone(),
854 date: None,
855 label: None,
856 };
857
858 let matched: MatchedLots = smallvec![Position::with_cost(
859 Amount::new(units.number.abs(), units.currency.clone()),
860 make_avg_cost(),
861 )];
862
863 let matching_indices: std::collections::HashSet<usize> =
865 matching.iter().map(|(i, _)| *i).collect();
866 let mut idx = 0;
867 self.positions.retain(|_| {
868 let keep = !matching_indices.contains(&idx);
869 idx += 1;
870 keep
871 });
872
873 let remaining = total_units + units.number; if !remaining.is_zero() {
876 self.positions.push_back(Position::with_cost(
877 Amount::new(remaining, units.currency.clone()),
878 make_avg_cost(),
879 ));
880 }
881
882 self.rebuild_index();
883
884 Ok(BookingResult {
885 matched,
886 cost_basis,
887 })
888 }
889
890 pub(super) fn reduce_none(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
892 let total_units = self.units(&units.currency);
894
895 if total_units.signum() == units.number.signum() || total_units.is_zero() {
897 self.add(Position::simple(units.clone()));
899 return Ok(BookingResult {
900 matched: SmallVec::new(),
901 cost_basis: None,
902 });
903 }
904
905 let available = total_units.abs();
906 let requested = units.number.abs();
907
908 if requested > available {
909 return Err(BookingError::InsufficientUnits {
910 currency: units.currency.clone(),
911 requested,
912 available,
913 });
914 }
915
916 self.reduce_ordered(units, &CostSpec::default(), false)
918 }
919
920 pub(super) fn reduce_from_lot(
922 &mut self,
923 idx: usize,
924 units: &Amount,
925 ) -> Result<BookingResult, BookingError> {
926 let pos = &self.positions[idx];
927 let available = pos.units.number.abs();
928 let requested = units.number.abs();
929
930 if requested > available {
931 return Err(BookingError::InsufficientUnits {
932 currency: units.currency.clone(),
933 requested,
934 available,
935 });
936 }
937
938 let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
940
941 let (matched, _) = pos.split(requested * pos.units.number.signum());
943
944 let currency = pos.units.currency.clone();
946 let new_units = pos.units.number + units.number;
947 let new_pos = Position {
948 units: Amount::new(new_units, currency.clone()),
949 cost: pos.cost.clone(),
950 };
951 self.positions[idx] = new_pos;
952
953 if let Some(cached) = self.units_cache.get_mut(¤cy) {
955 *cached += units.number;
956 }
957
958 if self.positions[idx].is_empty() {
960 self.positions.remove(idx);
961 self.simple_index.clear();
963 for (i, p) in self.positions.iter().enumerate() {
964 if p.cost.is_none() {
965 self.simple_index.insert(p.units.currency.clone(), i);
966 }
967 }
968 }
969
970 Ok(BookingResult {
971 matched: smallvec![matched],
972 cost_basis,
973 })
974 }
975}
976
977#[cfg(test)]
978mod reduction_tests {
979 use crate::{Amount, BookingMethod, Cost, CostSpec, Inventory, Position, naive_date};
986 use rust_decimal::Decimal;
987 use rust_decimal_macros::dec;
988
989 fn d(n: i64) -> Decimal {
990 Decimal::from(n)
991 }
992
993 fn lot(units: i64, cost: i64, day: u32) -> Position {
995 Position::with_cost(
996 Amount::new(d(units), "STK"),
997 Cost::new(d(cost), "USD").with_date(naive_date(2024, 1, day).unwrap()),
998 )
999 }
1000
1001 fn mk(lots: impl IntoIterator<Item = Position>) -> Inventory {
1002 let mut i = Inventory::new();
1003 for l in lots {
1004 i.add(l);
1005 }
1006 i
1007 }
1008
1009 fn sell_stk(n: i64) -> Amount {
1010 Amount::new(d(-n), "STK")
1011 }
1012
1013 fn try_reduce(inv: &Inventory, units: &Amount, method: BookingMethod) -> super::BookingResult {
1014 inv.try_reduce(units, Some(&CostSpec::default()), method)
1015 .expect("reduction should succeed")
1016 }
1017
1018 fn basis(r: &super::BookingResult) -> Decimal {
1019 r.cost_basis.as_ref().expect("cost basis present").number
1020 }
1021
1022 #[test]
1025 fn fifo_partial_multilot_cost_basis_and_order() {
1026 let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1028 let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Fifo);
1029 assert_eq!(basis(&r), dec!(2000));
1031 assert_eq!(r.matched.len(), 2);
1032 assert_eq!(r.matched[0].units.number.abs(), dec!(10));
1033 assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(100));
1034 assert_eq!(r.matched[1].units.number.abs(), dec!(5));
1035 assert_eq!(r.matched[1].cost.as_ref().unwrap().number, dec!(200));
1036 }
1037
1038 #[test]
1039 fn lifo_takes_newest_lot_first() {
1040 let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1041 let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Lifo);
1042 assert_eq!(basis(&r), dec!(2500));
1045 assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(200));
1046 }
1047
1048 #[test]
1049 fn fifo_single_lot_partial_cost_basis() {
1050 let inv = mk([lot(10, 100, 1)]);
1051 let r = try_reduce(&inv, &sell_stk(3), BookingMethod::Fifo);
1052 assert_eq!(basis(&r), dec!(300)); }
1054
1055 #[test]
1058 fn hifo_takes_highest_cost_lot_first() {
1059 let inv = mk([lot(10, 100, 1), lot(10, 300, 2), lot(10, 200, 3)]);
1061 let r = try_reduce(&inv, &sell_stk(15), BookingMethod::Hifo);
1062 assert_eq!(basis(&r), dec!(4000));
1064 assert_eq!(r.matched[0].cost.as_ref().unwrap().number, dec!(300));
1065 assert_eq!(r.matched[1].cost.as_ref().unwrap().number, dec!(200));
1066 }
1067
1068 #[test]
1071 fn average_cost_basis_partial() {
1072 let inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
1074 let r = try_reduce(&inv, &sell_stk(20), BookingMethod::Average);
1075 assert_eq!(basis(&r), dec!(3500)); }
1077
1078 #[test]
1079 fn average_reduce_exact_total_succeeds() {
1080 let inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
1083 let r = try_reduce(&inv, &sell_stk(40), BookingMethod::Average);
1084 assert_eq!(basis(&r), dec!(7000)); }
1086
1087 #[test]
1088 fn average_over_reduction_errors() {
1089 let inv = mk([lot(10, 100, 1)]);
1091 let err = inv
1092 .try_reduce(
1093 &sell_stk(20),
1094 Some(&CostSpec::default()),
1095 BookingMethod::Average,
1096 )
1097 .unwrap_err();
1098 assert!(matches!(err, super::BookingError::InsufficientUnits { .. }));
1099 }
1100
1101 fn isolation_inv() -> Inventory {
1111 let mut i = Inventory::new();
1112 i.add(Position::with_cost(
1113 Amount::new(dec!(10), "OTH"), Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 1).unwrap()),
1115 ));
1116 i.add(lot(10, 100, 2)); i
1118 }
1119
1120 fn assert_isolated(method: BookingMethod) {
1121 let inv = isolation_inv();
1122 let r = try_reduce(&inv, &sell_stk(5), method);
1123 assert_eq!(
1124 basis(&r),
1125 dec!(500),
1126 "must reduce only the real STK lot (5 * 100)"
1127 );
1128 assert!(
1129 r.matched.iter().all(|p| p.units.currency.as_ref() == "STK"),
1130 "no non-STK lot should be matched"
1131 );
1132 }
1133
1134 #[test]
1135 fn fifo_filters_currency() {
1136 assert_isolated(BookingMethod::Fifo);
1137 }
1138
1139 #[test]
1140 fn hifo_filters_currency() {
1141 assert_isolated(BookingMethod::Hifo);
1142 }
1143
1144 #[test]
1145 fn strict_filters_currency() {
1146 assert_isolated(BookingMethod::Strict);
1147 }
1148
1149 #[test]
1150 fn average_filters_currency() {
1151 let inv = isolation_inv();
1153 let r = try_reduce(&inv, &sell_stk(5), BookingMethod::Average);
1154 assert_eq!(basis(&r), dec!(500));
1156 }
1157
1158 #[test]
1161 fn does_not_match_same_sign_lot() {
1162 let mut i = Inventory::new();
1167 i.add(lot(-10, 50, 1)); i.add(lot(10, 100, 2)); let r = try_reduce(&i, &sell_stk(5), BookingMethod::Fifo);
1170 assert_eq!(basis(&r), dec!(500)); assert!(r.matched.iter().all(|p| p.units.number.is_sign_positive()));
1172 }
1173
1174 #[test]
1175 fn strict_rejects_when_only_same_sign_lot_present() {
1176 let mut i = Inventory::new();
1185 i.add(lot(-10, 100, 1)); let res = i.try_reduce(
1187 &sell_stk(5),
1188 Some(&CostSpec::default()),
1189 BookingMethod::Strict,
1190 );
1191 assert!(
1192 matches!(res, Err(super::BookingError::NoMatchingLot { .. })),
1193 "strict reduction against a same-sign-only inventory must not match; got {res:?}"
1194 );
1195 }
1196
1197 #[test]
1200 fn fifo_insufficient_reports_available() {
1201 let inv = mk([lot(10, 100, 1)]);
1204 let err = inv
1205 .try_reduce(
1206 &sell_stk(15),
1207 Some(&CostSpec::default()),
1208 BookingMethod::Fifo,
1209 )
1210 .unwrap_err();
1211 match err {
1212 super::BookingError::InsufficientUnits {
1213 requested,
1214 available,
1215 ..
1216 } => {
1217 assert_eq!(requested, dec!(15));
1218 assert_eq!(available, dec!(10)); }
1220 other => panic!("expected InsufficientUnits, got {other:?}"),
1221 }
1222 }
1223
1224 #[test]
1227 fn strict_single_lot_partial_cost_basis() {
1228 let inv = mk([lot(10, 100, 1)]);
1230 let r = try_reduce(&inv, &sell_stk(4), BookingMethod::Strict);
1231 assert_eq!(basis(&r), dec!(400)); }
1233
1234 #[test]
1235 fn strict_single_lot_over_reduction_errors() {
1236 let inv = mk([lot(10, 100, 1)]);
1238 let err = inv
1239 .try_reduce(
1240 &sell_stk(11),
1241 Some(&CostSpec::default()),
1242 BookingMethod::Strict,
1243 )
1244 .unwrap_err();
1245 assert!(matches!(err, super::BookingError::InsufficientUnits { .. }));
1246 }
1247
1248 #[test]
1249 fn strict_single_lot_exact_full_reduction_succeeds() {
1250 let inv = mk([lot(10, 100, 1)]);
1252 let r = try_reduce(&inv, &sell_stk(10), BookingMethod::Strict);
1253 assert_eq!(basis(&r), dec!(1000));
1254 }
1255
1256 #[test]
1259 fn hifo_matched_units_and_insufficient_available() {
1260 let inv = mk([lot(10, 100, 1), lot(10, 300, 2)]);
1261 let r = try_reduce(&inv, &sell_stk(8), BookingMethod::Hifo);
1262 assert_eq!(r.matched[0].units.number.abs(), dec!(8));
1264 let err = inv
1265 .try_reduce(
1266 &sell_stk(25),
1267 Some(&CostSpec::default()),
1268 BookingMethod::Hifo,
1269 )
1270 .unwrap_err();
1271 match err {
1272 super::BookingError::InsufficientUnits { available, .. } => {
1273 assert_eq!(available, dec!(20)); }
1275 other => panic!("expected InsufficientUnits, got {other:?}"),
1276 }
1277 }
1278
1279 #[test]
1280 fn strict_from_lot_matched_units() {
1281 let inv = mk([lot(10, 100, 1)]);
1282 let r = try_reduce(&inv, &sell_stk(4), BookingMethod::Strict);
1283 assert_eq!(r.matched[0].units.number.abs(), dec!(4)); }
1285
1286 #[test]
1289 fn strict_with_size_picks_exact_size_lot() {
1290 let inv = mk([lot(10, 100, 1), lot(5, 200, 2)]);
1291 let r = try_reduce(&inv, &sell_stk(5), BookingMethod::StrictWithSize);
1292 assert_eq!(basis(&r), dec!(1000)); }
1294
1295 #[test]
1296 fn strict_with_size_ambiguous_without_exact_or_total() {
1297 let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1298 let err = inv
1299 .try_reduce(
1300 &sell_stk(5),
1301 Some(&CostSpec::default()),
1302 BookingMethod::StrictWithSize,
1303 )
1304 .unwrap_err();
1305 assert!(matches!(err, super::BookingError::AmbiguousMatch { .. }));
1306 }
1307
1308 #[test]
1309 fn strict_with_size_total_match_falls_back_to_fifo() {
1310 let inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1311 let r = try_reduce(&inv, &sell_stk(20), BookingMethod::StrictWithSize);
1312 assert_eq!(basis(&r), dec!(3000)); }
1314
1315 #[test]
1318 fn reduce_fifo_commits_and_basis() {
1319 let mut inv = mk([lot(10, 100, 1), lot(10, 200, 2)]);
1320 let r = inv
1321 .reduce(
1322 &sell_stk(15),
1323 Some(&CostSpec::default()),
1324 BookingMethod::Fifo,
1325 )
1326 .unwrap();
1327 assert_eq!(r.cost_basis.unwrap().number, dec!(2000));
1328 assert_eq!(inv.units("STK"), dec!(5)); }
1330
1331 #[test]
1332 fn reduce_hifo_commits_basis_units_insufficient() {
1333 let mut inv = mk([lot(10, 100, 1), lot(10, 300, 2)]);
1334 let r = inv
1335 .reduce(
1336 &sell_stk(15),
1337 Some(&CostSpec::default()),
1338 BookingMethod::Hifo,
1339 )
1340 .unwrap();
1341 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)]);
1344 let err = inv2
1345 .reduce(
1346 &sell_stk(25),
1347 Some(&CostSpec::default()),
1348 BookingMethod::Hifo,
1349 )
1350 .unwrap_err();
1351 match err {
1352 super::BookingError::InsufficientUnits { available, .. } => {
1353 assert_eq!(available, dec!(10));
1354 }
1355 other => panic!("expected InsufficientUnits, got {other:?}"),
1356 }
1357 }
1358
1359 #[test]
1360 fn reduce_average_only_matching_currency() {
1361 let mut i = Inventory::new();
1362 i.add(lot(10, 100, 2));
1363 i.add(Position::with_cost(
1364 Amount::new(dec!(10), "OTH"),
1365 Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 1).unwrap()),
1366 ));
1367 let r = i
1368 .reduce(
1369 &sell_stk(5),
1370 Some(&CostSpec::default()),
1371 BookingMethod::Average,
1372 )
1373 .unwrap();
1374 assert_eq!(r.cost_basis.unwrap().number, dec!(500)); }
1376
1377 #[test]
1378 fn reduce_from_lot_matched_and_remaining_units() {
1379 let mut inv = mk([lot(10, 100, 1)]);
1380 let r = inv
1381 .reduce(
1382 &sell_stk(4),
1383 Some(&CostSpec::default()),
1384 BookingMethod::Strict,
1385 )
1386 .unwrap();
1387 assert_eq!(r.matched[0].units.number.abs(), dec!(4)); let remaining: Vec<_> = inv.position_list();
1392 assert_eq!(remaining.len(), 1);
1393 assert_eq!(remaining[0].units.number, dec!(6)); assert_eq!(inv.units("STK"), dec!(6)); }
1396
1397 #[test]
1398 fn reduce_merge_filters_currency_sign_and_preserves_other_lots() {
1399 let mut inv = Inventory::new();
1403 inv.add(lot(10, 100, 1)); inv.add(lot(30, 200, 2)); inv.add(lot(-5, 999, 3)); inv.add(Position::with_cost(
1407 Amount::new(dec!(10), "OTH"), Cost::new(dec!(888), "USD").with_date(naive_date(2024, 1, 4).unwrap()),
1409 ));
1410 let spec = CostSpec {
1411 merge: true,
1412 ..CostSpec::default()
1413 };
1414 let r = inv
1415 .reduce(&sell_stk(20), Some(&spec), BookingMethod::Strict)
1416 .unwrap();
1417 assert_eq!(r.cost_basis.unwrap().number, dec!(3500));
1420 assert!(
1422 inv.position_list()
1423 .iter()
1424 .any(|p| p.units.currency.as_ref() == "OTH" && p.units.number == dec!(10)),
1425 "OTH lot must survive the merge"
1426 );
1427 assert!(
1428 inv.position_list()
1429 .iter()
1430 .any(|p| p.units.currency.as_ref() == "STK" && p.units.number == dec!(-5)),
1431 "short STK lot must survive the merge"
1432 );
1433 }
1434
1435 #[test]
1436 fn reduce_none_exact_succeeds_over_reduction_errors() {
1437 let mut inv = Inventory::new();
1438 inv.add(Position::simple(Amount::new(dec!(10), "STK")));
1439 assert!(
1440 inv.reduce(&sell_stk(10), None, BookingMethod::None).is_ok(),
1441 "exact NONE reduction should succeed"
1442 );
1443 let mut inv2 = Inventory::new();
1444 inv2.add(Position::simple(Amount::new(dec!(10), "STK")));
1445 let err = inv2
1446 .reduce(&sell_stk(15), None, BookingMethod::None)
1447 .unwrap_err();
1448 assert!(matches!(err, super::BookingError::InsufficientUnits { .. }));
1449 }
1450
1451 #[test]
1452 fn reduce_merge_uses_weighted_average() {
1453 let mut inv = mk([lot(10, 100, 1), lot(30, 200, 2)]);
1454 let spec = CostSpec {
1455 merge: true,
1456 ..CostSpec::default()
1457 };
1458 let r = inv
1459 .reduce(&sell_stk(20), Some(&spec), BookingMethod::Strict)
1460 .unwrap();
1461 assert_eq!(r.cost_basis.unwrap().number, dec!(3500)); assert_eq!(inv.units("STK"), dec!(20)); }
1464}