1use rust_decimal::Decimal;
8use rustc_hash::FxHashMap;
9use serde::{Deserialize, Serialize};
10use std::fmt;
11use std::str::FromStr;
12
13use crate::intern::InternedStr;
14use crate::{Amount, CostSpec, Position};
15
16mod booking;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
20#[cfg_attr(
21 feature = "rkyv",
22 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
23)]
24pub enum BookingMethod {
25 #[default]
28 Strict,
29 StrictWithSize,
32 Fifo,
34 Lifo,
36 Hifo,
38 Average,
40 None,
42}
43
44impl FromStr for BookingMethod {
45 type Err = String;
46
47 fn from_str(s: &str) -> Result<Self, Self::Err> {
48 match s.to_uppercase().as_str() {
49 "STRICT" => Ok(Self::Strict),
50 "STRICT_WITH_SIZE" => Ok(Self::StrictWithSize),
51 "FIFO" => Ok(Self::Fifo),
52 "LIFO" => Ok(Self::Lifo),
53 "HIFO" => Ok(Self::Hifo),
54 "AVERAGE" => Ok(Self::Average),
55 "NONE" => Ok(Self::None),
56 _ => Err(format!("unknown booking method: {s}")),
57 }
58 }
59}
60
61impl fmt::Display for BookingMethod {
62 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63 match self {
64 Self::Strict => write!(f, "STRICT"),
65 Self::StrictWithSize => write!(f, "STRICT_WITH_SIZE"),
66 Self::Fifo => write!(f, "FIFO"),
67 Self::Lifo => write!(f, "LIFO"),
68 Self::Hifo => write!(f, "HIFO"),
69 Self::Average => write!(f, "AVERAGE"),
70 Self::None => write!(f, "NONE"),
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct BookingResult {
78 pub matched: Vec<Position>,
80 pub cost_basis: Option<Amount>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum BookingError {
87 AmbiguousMatch {
89 num_matches: usize,
91 currency: InternedStr,
93 },
94 NoMatchingLot {
96 currency: InternedStr,
98 cost_spec: CostSpec,
100 },
101 InsufficientUnits {
103 currency: InternedStr,
105 requested: Decimal,
107 available: Decimal,
109 },
110 CurrencyMismatch {
112 expected: InternedStr,
114 got: InternedStr,
116 },
117}
118
119impl fmt::Display for BookingError {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 match self {
122 Self::AmbiguousMatch {
123 num_matches,
124 currency,
125 } => write!(
126 f,
127 "Ambiguous match: {num_matches} lots match for {currency}"
128 ),
129 Self::NoMatchingLot {
130 currency,
131 cost_spec,
132 } => {
133 write!(f, "No matching lot for {currency} with cost {cost_spec}")
134 }
135 Self::InsufficientUnits {
136 currency,
137 requested,
138 available,
139 } => write!(
140 f,
141 "Insufficient units of {currency}: requested {requested}, available {available}"
142 ),
143 Self::CurrencyMismatch { expected, got } => {
144 write!(f, "Currency mismatch: expected {expected}, got {got}")
145 }
146 }
147 }
148}
149
150impl std::error::Error for BookingError {}
151
152impl BookingError {
153 #[must_use]
166 pub const fn with_account(self, account: InternedStr) -> AccountedBookingError {
167 AccountedBookingError {
168 error: self,
169 account,
170 }
171 }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct AccountedBookingError {
185 pub error: BookingError,
187 pub account: InternedStr,
189}
190
191impl fmt::Display for AccountedBookingError {
192 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
193 match &self.error {
194 BookingError::InsufficientUnits {
195 requested,
196 available,
197 ..
198 } => write!(
199 f,
200 "Not enough units in {}: requested {}, available {}; not enough to reduce",
201 self.account, requested, available
202 ),
203 BookingError::NoMatchingLot { currency, .. } => {
204 write!(f, "No matching lot for {} in {}", currency, self.account)
205 }
206 BookingError::AmbiguousMatch {
207 num_matches,
208 currency,
209 } => write!(
210 f,
211 "Ambiguous lot match for {}: {} lots match in {}",
212 currency, num_matches, self.account
213 ),
214 BookingError::CurrencyMismatch { got, .. } => {
223 write!(f, "No matching lot for {} in {}", got, self.account)
224 }
225 }
226 }
227}
228
229impl std::error::Error for AccountedBookingError {}
230
231#[derive(Debug, Clone, Default, Serialize, Deserialize)]
254#[cfg_attr(
255 feature = "rkyv",
256 derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
257)]
258pub struct Inventory {
259 positions: Vec<Position>,
260 #[serde(skip)]
264 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Skip))]
265 simple_index: FxHashMap<InternedStr, usize>,
266 #[serde(skip)]
270 #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Skip))]
271 units_cache: FxHashMap<InternedStr, Decimal>,
272}
273
274impl PartialEq for Inventory {
275 fn eq(&self, other: &Self) -> bool {
276 self.positions == other.positions
278 }
279}
280
281impl Eq for Inventory {}
282
283impl Inventory {
284 #[must_use]
286 pub fn new() -> Self {
287 Self::default()
288 }
289
290 #[must_use]
292 pub fn positions(&self) -> &[Position] {
293 &self.positions
294 }
295
296 pub const fn positions_mut(&mut self) -> &mut Vec<Position> {
298 &mut self.positions
299 }
300
301 #[must_use]
303 pub fn is_empty(&self) -> bool {
304 self.positions.is_empty()
305 || self
306 .positions
307 .iter()
308 .all(super::position::Position::is_empty)
309 }
310
311 #[must_use]
313 pub const fn len(&self) -> usize {
314 self.positions.len()
315 }
316
317 #[must_use]
322 pub fn units(&self, currency: &str) -> Decimal {
323 self.units_cache.get(currency).copied().unwrap_or_else(|| {
326 self.positions
328 .iter()
329 .filter(|p| p.units.currency == currency)
330 .map(|p| p.units.number)
331 .sum()
332 })
333 }
334
335 #[must_use]
337 pub fn currencies(&self) -> Vec<&str> {
338 let mut currencies: Vec<&str> = self
339 .positions
340 .iter()
341 .filter(|p| !p.is_empty())
342 .map(|p| p.units.currency.as_str())
343 .collect();
344 currencies.sort_unstable();
345 currencies.dedup();
346 currencies
347 }
348
349 #[must_use]
357 pub fn is_reduced_by(&self, units: &Amount) -> bool {
358 self.positions.iter().any(|pos| {
359 pos.units.currency == units.currency
360 && pos.units.number.is_sign_positive() != units.number.is_sign_positive()
361 })
362 }
363
364 #[must_use]
368 pub fn book_value(&self, units_currency: &str) -> FxHashMap<InternedStr, Decimal> {
369 let mut totals: FxHashMap<InternedStr, Decimal> = FxHashMap::default();
370
371 for pos in &self.positions {
372 if pos.units.currency == units_currency
373 && let Some(book) = pos.book_value()
374 {
375 *totals.entry(book.currency.clone()).or_default() += book.number;
376 }
377 }
378
379 totals
380 }
381
382 pub fn add(&mut self, position: Position) {
399 if position.is_empty() {
400 return;
401 }
402
403 *self
405 .units_cache
406 .entry(position.units.currency.clone())
407 .or_default() += position.units.number;
408
409 if position.cost.is_none() {
411 if let Some(&idx) = self.simple_index.get(&position.units.currency) {
412 debug_assert!(self.positions[idx].cost.is_none());
414 self.positions[idx].units += &position.units;
415 return;
416 }
417 let idx = self.positions.len();
419 self.simple_index
420 .insert(position.units.currency.clone(), idx);
421 self.positions.push(position);
422 return;
423 }
424
425 self.positions.push(position);
429 }
430
431 pub fn reduce(
458 &mut self,
459 units: &Amount,
460 cost_spec: Option<&CostSpec>,
461 method: BookingMethod,
462 ) -> Result<BookingResult, BookingError> {
463 let spec = cost_spec.cloned().unwrap_or_default();
464
465 match method {
466 BookingMethod::Strict => self.reduce_strict(units, &spec),
467 BookingMethod::StrictWithSize => self.reduce_strict_with_size(units, &spec),
468 BookingMethod::Fifo => self.reduce_fifo(units, &spec),
469 BookingMethod::Lifo => self.reduce_lifo(units, &spec),
470 BookingMethod::Hifo => self.reduce_hifo(units, &spec),
471 BookingMethod::Average => self.reduce_average(units),
472 BookingMethod::None => self.reduce_none(units),
473 }
474 }
475
476 pub fn compact(&mut self) {
478 self.positions.retain(|p| !p.is_empty());
479 self.rebuild_index();
480 }
481
482 fn rebuild_index(&mut self) {
485 self.simple_index.clear();
486 self.units_cache.clear();
487
488 for (idx, pos) in self.positions.iter().enumerate() {
489 *self
491 .units_cache
492 .entry(pos.units.currency.clone())
493 .or_default() += pos.units.number;
494
495 if pos.cost.is_none() {
497 debug_assert!(
498 !self.simple_index.contains_key(&pos.units.currency),
499 "Invariant violated: multiple simple positions for currency {}",
500 pos.units.currency
501 );
502 self.simple_index.insert(pos.units.currency.clone(), idx);
503 }
504 }
505 }
506
507 pub fn merge(&mut self, other: &Self) {
509 for pos in &other.positions {
510 self.add(pos.clone());
511 }
512 }
513
514 #[must_use]
519 pub fn at_cost(&self) -> Self {
520 let mut result = Self::new();
521
522 for pos in &self.positions {
523 if pos.is_empty() {
524 continue;
525 }
526
527 if let Some(cost) = &pos.cost {
528 let total = pos.units.number * cost.number;
530 result.add(Position::simple(Amount::new(total, &cost.currency)));
531 } else {
532 result.add(pos.clone());
534 }
535 }
536
537 result
538 }
539
540 #[must_use]
545 pub fn at_units(&self) -> Self {
546 let mut result = Self::new();
547
548 for pos in &self.positions {
549 if pos.is_empty() {
550 continue;
551 }
552
553 result.add(Position::simple(pos.units.clone()));
555 }
556
557 result
558 }
559}
560
561impl fmt::Display for Inventory {
562 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
563 if self.is_empty() {
564 return write!(f, "(empty)");
565 }
566
567 let mut non_empty: Vec<_> = self.positions.iter().filter(|p| !p.is_empty()).collect();
569 non_empty.sort_by(|a, b| {
570 let cmp = a.units.currency.cmp(&b.units.currency);
572 if cmp != std::cmp::Ordering::Equal {
573 return cmp;
574 }
575 match (&a.cost, &b.cost) {
577 (Some(ca), Some(cb)) => ca.number.cmp(&cb.number),
578 (Some(_), None) => std::cmp::Ordering::Greater,
579 (None, Some(_)) => std::cmp::Ordering::Less,
580 (None, None) => std::cmp::Ordering::Equal,
581 }
582 });
583
584 for (i, pos) in non_empty.iter().enumerate() {
585 if i > 0 {
586 write!(f, ", ")?;
587 }
588 write!(f, "{pos}")?;
589 }
590 Ok(())
591 }
592}
593
594impl FromIterator<Position> for Inventory {
595 fn from_iter<I: IntoIterator<Item = Position>>(iter: I) -> Self {
596 let mut inv = Self::new();
597 for pos in iter {
598 inv.add(pos);
599 }
600 inv
601 }
602}
603
604#[cfg(test)]
605mod tests {
606 use super::*;
607 use crate::Cost;
608 use chrono::NaiveDate;
609 use rust_decimal_macros::dec;
610
611 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
612 NaiveDate::from_ymd_opt(year, month, day).unwrap()
613 }
614
615 #[test]
616 fn test_empty_inventory() {
617 let inv = Inventory::new();
618 assert!(inv.is_empty());
619 assert_eq!(inv.len(), 0);
620 }
621
622 #[test]
623 fn test_add_simple() {
624 let mut inv = Inventory::new();
625 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
626
627 assert!(!inv.is_empty());
628 assert_eq!(inv.units("USD"), dec!(100));
629 }
630
631 #[test]
632 fn test_add_merge_simple() {
633 let mut inv = Inventory::new();
634 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
635 inv.add(Position::simple(Amount::new(dec!(50), "USD")));
636
637 assert_eq!(inv.len(), 1);
639 assert_eq!(inv.units("USD"), dec!(150));
640 }
641
642 #[test]
643 fn test_add_with_cost_no_merge() {
644 let mut inv = Inventory::new();
645
646 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
647 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
648
649 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
650 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
651
652 assert_eq!(inv.len(), 2);
654 assert_eq!(inv.units("AAPL"), dec!(15));
655 }
656
657 #[test]
658 fn test_currencies() {
659 let mut inv = Inventory::new();
660 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
661 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
662 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
663
664 let currencies = inv.currencies();
665 assert_eq!(currencies.len(), 3);
666 assert!(currencies.contains(&"USD"));
667 assert!(currencies.contains(&"EUR"));
668 assert!(currencies.contains(&"AAPL"));
669 }
670
671 #[test]
672 fn test_reduce_strict_unique() {
673 let mut inv = Inventory::new();
674 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
675 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
676
677 let result = inv
678 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
679 .unwrap();
680
681 assert_eq!(inv.units("AAPL"), dec!(5));
682 assert!(result.cost_basis.is_some());
683 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00)); }
685
686 #[test]
687 fn test_reduce_strict_multiple_match_with_different_costs_is_ambiguous() {
688 let mut inv = Inventory::new();
689
690 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
691 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
692
693 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
694 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
695
696 let result = inv.reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict);
700
701 assert!(
702 matches!(result, Err(BookingError::AmbiguousMatch { .. })),
703 "expected AmbiguousMatch, got {result:?}"
704 );
705 assert_eq!(inv.units("AAPL"), dec!(15));
707 }
708
709 #[test]
710 fn test_reduce_strict_multiple_match_with_identical_costs_uses_fifo() {
711 let mut inv = Inventory::new();
712
713 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
715
716 inv.add(Position::with_cost(
717 Amount::new(dec!(10), "AAPL"),
718 cost.clone(),
719 ));
720 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
721
722 let result = inv
723 .reduce(&Amount::new(dec!(-3), "AAPL"), None, BookingMethod::Strict)
724 .expect("identical lots should fall back to FIFO without error");
725
726 assert_eq!(inv.units("AAPL"), dec!(12));
727 assert_eq!(result.cost_basis.unwrap().number, dec!(450.00));
728 }
729
730 #[test]
731 fn test_reduce_strict_multiple_match_different_dates_same_cost_uses_fifo() {
732 let mut inv = Inventory::new();
733
734 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 15));
738 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 15));
739
740 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
741 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
742
743 let result = inv
744 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Strict)
745 .expect("same cost number, different dates should fall back to FIFO");
746
747 assert_eq!(inv.units("AAPL"), dec!(15));
748 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
750 }
751
752 #[test]
753 fn test_reduce_strict_multiple_match_total_match_exception() {
754 let mut inv = Inventory::new();
755
756 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
757 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
758
759 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
760 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
761
762 let result = inv
765 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Strict)
766 .expect("total-match exception should accept a full liquidation");
767
768 assert_eq!(inv.units("AAPL"), dec!(0));
769 assert_eq!(result.cost_basis.unwrap().number, dec!(2300.00));
771 }
772
773 #[test]
774 fn test_reduce_strict_with_spec() {
775 let mut inv = Inventory::new();
776
777 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
778 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
779
780 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
781 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
782
783 let spec = CostSpec::empty().with_date(date(2024, 1, 1));
785 let result = inv
786 .reduce(
787 &Amount::new(dec!(-3), "AAPL"),
788 Some(&spec),
789 BookingMethod::Strict,
790 )
791 .unwrap();
792
793 assert_eq!(inv.units("AAPL"), dec!(12)); assert_eq!(result.cost_basis.unwrap().number, dec!(450.00)); }
796
797 #[test]
798 fn test_reduce_fifo() {
799 let mut inv = Inventory::new();
800
801 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
802 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
803 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
804
805 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
806 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
807 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
808
809 let result = inv
811 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo)
812 .unwrap();
813
814 assert_eq!(inv.units("AAPL"), dec!(15));
815 assert_eq!(result.cost_basis.unwrap().number, dec!(1750.00));
817 }
818
819 #[test]
820 fn test_reduce_lifo() {
821 let mut inv = Inventory::new();
822
823 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
824 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
825 let cost3 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
826
827 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
828 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
829 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
830
831 let result = inv
833 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Lifo)
834 .unwrap();
835
836 assert_eq!(inv.units("AAPL"), dec!(15));
837 assert_eq!(result.cost_basis.unwrap().number, dec!(2750.00));
839 }
840
841 #[test]
842 fn test_reduce_insufficient() {
843 let mut inv = Inventory::new();
844 let cost = Cost::new(dec!(150.00), "USD");
845 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
846
847 let result = inv.reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Fifo);
848
849 assert!(matches!(
850 result,
851 Err(BookingError::InsufficientUnits { .. })
852 ));
853 }
854
855 #[test]
856 fn test_book_value() {
857 let mut inv = Inventory::new();
858
859 let cost1 = Cost::new(dec!(100.00), "USD");
860 let cost2 = Cost::new(dec!(150.00), "USD");
861
862 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
863 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
864
865 let book = inv.book_value("AAPL");
866 assert_eq!(book.get("USD"), Some(&dec!(1750.00))); }
868
869 #[test]
870 fn test_display() {
871 let mut inv = Inventory::new();
872 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
873
874 let s = format!("{inv}");
875 assert!(s.contains("100 USD"));
876 }
877
878 #[test]
879 fn test_display_empty() {
880 let inv = Inventory::new();
881 assert_eq!(format!("{inv}"), "(empty)");
882 }
883
884 #[test]
885 fn test_from_iterator() {
886 let positions = vec![
887 Position::simple(Amount::new(dec!(100), "USD")),
888 Position::simple(Amount::new(dec!(50), "USD")),
889 ];
890
891 let inv: Inventory = positions.into_iter().collect();
892 assert_eq!(inv.units("USD"), dec!(150));
893 }
894
895 #[test]
896 fn test_add_costed_positions_kept_separate() {
897 let mut inv = Inventory::new();
900
901 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
902
903 inv.add(Position::with_cost(
905 Amount::new(dec!(10), "AAPL"),
906 cost.clone(),
907 ));
908 assert_eq!(inv.len(), 1);
909 assert_eq!(inv.units("AAPL"), dec!(10));
910
911 inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
913 assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(0)); }
916
917 #[test]
918 fn test_add_costed_positions_net_units() {
919 let mut inv = Inventory::new();
921
922 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
923
924 inv.add(Position::with_cost(
926 Amount::new(dec!(10), "AAPL"),
927 cost.clone(),
928 ));
929
930 inv.add(Position::with_cost(Amount::new(dec!(-3), "AAPL"), cost));
932 assert_eq!(inv.len(), 2); assert_eq!(inv.units("AAPL"), dec!(7)); }
935
936 #[test]
937 fn test_add_no_cancel_different_cost() {
938 let mut inv = Inventory::new();
940
941 let cost1 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
942 let cost2 = Cost::new(dec!(160.00), "USD").with_date(date(2024, 1, 15));
943
944 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
946
947 inv.add(Position::with_cost(Amount::new(dec!(-5), "AAPL"), cost2));
949
950 assert_eq!(inv.len(), 2);
952 assert_eq!(inv.units("AAPL"), dec!(5)); }
954
955 #[test]
956 fn test_add_no_cancel_same_sign() {
957 let mut inv = Inventory::new();
959
960 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
961
962 inv.add(Position::with_cost(
964 Amount::new(dec!(10), "AAPL"),
965 cost.clone(),
966 ));
967
968 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost));
970
971 assert_eq!(inv.len(), 2);
973 assert_eq!(inv.units("AAPL"), dec!(15));
974 }
975
976 #[test]
977 fn test_merge_keeps_lots_separate() {
978 let mut inv1 = Inventory::new();
980 let mut inv2 = Inventory::new();
981
982 let cost = Cost::new(dec!(150.00), "USD").with_date(date(2024, 1, 1));
983
984 inv1.add(Position::with_cost(
986 Amount::new(dec!(10), "AAPL"),
987 cost.clone(),
988 ));
989
990 inv2.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
992
993 inv1.merge(&inv2);
995 assert_eq!(inv1.len(), 2); assert_eq!(inv1.units("AAPL"), dec!(0)); }
998
999 #[test]
1004 fn test_hifo_with_tie_breaking() {
1005 let mut inv = Inventory::new();
1007
1008 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1010 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1011 let cost3 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 3, 1));
1012
1013 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1014 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1015 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost3));
1016
1017 let result = inv
1019 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
1020 .unwrap();
1021
1022 assert_eq!(inv.units("AAPL"), dec!(15));
1023 assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1025 }
1026
1027 #[test]
1028 fn test_hifo_with_different_costs() {
1029 let mut inv = Inventory::new();
1031
1032 let cost_low = Cost::new(dec!(50.00), "USD").with_date(date(2024, 1, 1));
1033 let cost_mid = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1034 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1035
1036 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_low));
1037 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_mid));
1038 inv.add(Position::with_cost(
1039 Amount::new(dec!(10), "AAPL"),
1040 cost_high,
1041 ));
1042
1043 let result = inv
1045 .reduce(&Amount::new(dec!(-15), "AAPL"), None, BookingMethod::Hifo)
1046 .unwrap();
1047
1048 assert_eq!(inv.units("AAPL"), dec!(15));
1049 assert_eq!(result.cost_basis.unwrap().number, dec!(2500.00));
1051 }
1052
1053 #[test]
1054 fn test_average_booking_with_pre_existing_positions() {
1055 let mut inv = Inventory::new();
1056
1057 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1059 let cost2 = Cost::new(dec!(200.00), "USD").with_date(date(2024, 2, 1));
1060
1061 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1062 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1063
1064 let result = inv
1067 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Average)
1068 .unwrap();
1069
1070 assert_eq!(inv.units("AAPL"), dec!(15));
1071 assert_eq!(result.cost_basis.unwrap().number, dec!(750.00));
1073 }
1074
1075 #[test]
1076 fn test_average_booking_reduces_all() {
1077 let mut inv = Inventory::new();
1078
1079 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1080 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1081
1082 let result = inv
1084 .reduce(
1085 &Amount::new(dec!(-10), "AAPL"),
1086 None,
1087 BookingMethod::Average,
1088 )
1089 .unwrap();
1090
1091 assert!(inv.is_empty() || inv.units("AAPL").is_zero());
1092 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1093 }
1094
1095 #[test]
1096 fn test_none_booking_augmentation() {
1097 let mut inv = Inventory::new();
1099 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1100
1101 let result = inv
1103 .reduce(&Amount::new(dec!(50), "USD"), None, BookingMethod::None)
1104 .unwrap();
1105
1106 assert_eq!(inv.units("USD"), dec!(150));
1107 assert!(result.matched.is_empty()); assert!(result.cost_basis.is_none());
1109 }
1110
1111 #[test]
1112 fn test_none_booking_reduction() {
1113 let mut inv = Inventory::new();
1115 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1116
1117 let result = inv
1118 .reduce(&Amount::new(dec!(-30), "USD"), None, BookingMethod::None)
1119 .unwrap();
1120
1121 assert_eq!(inv.units("USD"), dec!(70));
1122 assert!(!result.matched.is_empty());
1123 }
1124
1125 #[test]
1126 fn test_none_booking_insufficient() {
1127 let mut inv = Inventory::new();
1128 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1129
1130 let result = inv.reduce(&Amount::new(dec!(-150), "USD"), None, BookingMethod::None);
1131
1132 assert!(matches!(
1133 result,
1134 Err(BookingError::InsufficientUnits { .. })
1135 ));
1136 }
1137
1138 #[test]
1139 fn test_booking_error_no_matching_lot() {
1140 let mut inv = Inventory::new();
1141
1142 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1144 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1145
1146 let wrong_spec = CostSpec::empty().with_date(date(2024, 12, 31));
1148 let result = inv.reduce(
1149 &Amount::new(dec!(-5), "AAPL"),
1150 Some(&wrong_spec),
1151 BookingMethod::Strict,
1152 );
1153
1154 assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1155 }
1156
1157 #[test]
1158 fn test_booking_error_insufficient_units() {
1159 let mut inv = Inventory::new();
1160
1161 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1162 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1163
1164 let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Fifo);
1166
1167 match result {
1168 Err(BookingError::InsufficientUnits {
1169 requested,
1170 available,
1171 ..
1172 }) => {
1173 assert_eq!(requested, dec!(20));
1174 assert_eq!(available, dec!(10));
1175 }
1176 _ => panic!("Expected InsufficientUnits error"),
1177 }
1178 }
1179
1180 #[test]
1181 fn test_strict_with_size_exact_match() {
1182 let mut inv = Inventory::new();
1183
1184 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1186 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1187
1188 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1189 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1190
1191 let result = inv
1193 .reduce(
1194 &Amount::new(dec!(-5), "AAPL"),
1195 None,
1196 BookingMethod::StrictWithSize,
1197 )
1198 .unwrap();
1199
1200 assert_eq!(inv.units("AAPL"), dec!(10));
1201 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1202 }
1203
1204 #[test]
1205 fn test_strict_with_size_total_match() {
1206 let mut inv = Inventory::new();
1207
1208 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1210 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1211
1212 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1213 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1214
1215 let result = inv
1217 .reduce(
1218 &Amount::new(dec!(-15), "AAPL"),
1219 None,
1220 BookingMethod::StrictWithSize,
1221 )
1222 .unwrap();
1223
1224 assert_eq!(inv.units("AAPL"), dec!(0));
1225 assert_eq!(result.cost_basis.unwrap().number, dec!(1500.00));
1226 }
1227
1228 #[test]
1229 fn test_strict_with_size_ambiguous() {
1230 let mut inv = Inventory::new();
1231
1232 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1234 let cost2 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1235
1236 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1237 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost2));
1238
1239 let result = inv.reduce(
1241 &Amount::new(dec!(-7), "AAPL"),
1242 None,
1243 BookingMethod::StrictWithSize,
1244 );
1245
1246 assert!(matches!(result, Err(BookingError::AmbiguousMatch { .. })));
1247 }
1248
1249 #[test]
1250 fn test_short_position() {
1251 let mut inv = Inventory::new();
1253
1254 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1256 inv.add(Position::with_cost(Amount::new(dec!(-10), "AAPL"), cost));
1257
1258 assert_eq!(inv.units("AAPL"), dec!(-10));
1259 assert!(!inv.is_empty());
1260 }
1261
1262 #[test]
1263 fn test_at_cost() {
1264 let mut inv = Inventory::new();
1265
1266 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1267 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1268
1269 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1270 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1271 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1272
1273 let at_cost = inv.at_cost();
1274
1275 assert_eq!(at_cost.units("USD"), dec!(1850));
1278 assert_eq!(at_cost.units("AAPL"), dec!(0)); }
1280
1281 #[test]
1282 fn test_at_units() {
1283 let mut inv = Inventory::new();
1284
1285 let cost1 = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1286 let cost2 = Cost::new(dec!(150.00), "USD").with_date(date(2024, 2, 1));
1287
1288 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost1));
1289 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost2));
1290
1291 let at_units = inv.at_units();
1292
1293 assert_eq!(at_units.units("AAPL"), dec!(15));
1295 assert_eq!(at_units.len(), 1);
1297 }
1298
1299 #[test]
1300 fn test_add_empty_position() {
1301 let mut inv = Inventory::new();
1302 inv.add(Position::simple(Amount::new(dec!(0), "USD")));
1303
1304 assert!(inv.is_empty());
1305 assert_eq!(inv.len(), 0);
1306 }
1307
1308 #[test]
1309 fn test_compact() {
1310 let mut inv = Inventory::new();
1311
1312 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1313 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1314
1315 inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Fifo)
1317 .unwrap();
1318
1319 inv.compact();
1321 assert!(inv.is_empty());
1322 assert_eq!(inv.len(), 0);
1323 }
1324
1325 #[test]
1326 fn test_booking_method_from_str() {
1327 assert_eq!(
1328 BookingMethod::from_str("STRICT").unwrap(),
1329 BookingMethod::Strict
1330 );
1331 assert_eq!(
1332 BookingMethod::from_str("fifo").unwrap(),
1333 BookingMethod::Fifo
1334 );
1335 assert_eq!(
1336 BookingMethod::from_str("LIFO").unwrap(),
1337 BookingMethod::Lifo
1338 );
1339 assert_eq!(
1340 BookingMethod::from_str("Hifo").unwrap(),
1341 BookingMethod::Hifo
1342 );
1343 assert_eq!(
1344 BookingMethod::from_str("AVERAGE").unwrap(),
1345 BookingMethod::Average
1346 );
1347 assert_eq!(
1348 BookingMethod::from_str("NONE").unwrap(),
1349 BookingMethod::None
1350 );
1351 assert_eq!(
1352 BookingMethod::from_str("strict_with_size").unwrap(),
1353 BookingMethod::StrictWithSize
1354 );
1355 assert!(BookingMethod::from_str("INVALID").is_err());
1356 }
1357
1358 #[test]
1359 fn test_booking_method_display() {
1360 assert_eq!(format!("{}", BookingMethod::Strict), "STRICT");
1361 assert_eq!(format!("{}", BookingMethod::Fifo), "FIFO");
1362 assert_eq!(format!("{}", BookingMethod::Lifo), "LIFO");
1363 assert_eq!(format!("{}", BookingMethod::Hifo), "HIFO");
1364 assert_eq!(format!("{}", BookingMethod::Average), "AVERAGE");
1365 assert_eq!(format!("{}", BookingMethod::None), "NONE");
1366 assert_eq!(
1367 format!("{}", BookingMethod::StrictWithSize),
1368 "STRICT_WITH_SIZE"
1369 );
1370 }
1371
1372 #[test]
1373 fn test_booking_error_display() {
1374 let err = BookingError::AmbiguousMatch {
1375 num_matches: 3,
1376 currency: "AAPL".into(),
1377 };
1378 assert!(format!("{err}").contains("3 lots match"));
1379
1380 let err = BookingError::NoMatchingLot {
1381 currency: "AAPL".into(),
1382 cost_spec: CostSpec::empty(),
1383 };
1384 assert!(format!("{err}").contains("No matching lot"));
1385
1386 let err = BookingError::InsufficientUnits {
1387 currency: "AAPL".into(),
1388 requested: dec!(100),
1389 available: dec!(50),
1390 };
1391 assert!(format!("{err}").contains("requested 100"));
1392 assert!(format!("{err}").contains("available 50"));
1393
1394 let err = BookingError::CurrencyMismatch {
1395 expected: "USD".into(),
1396 got: "EUR".into(),
1397 };
1398 assert!(format!("{err}").contains("expected USD"));
1399 assert!(format!("{err}").contains("got EUR"));
1400 }
1401
1402 #[test]
1403 fn test_book_value_multiple_currencies() {
1404 let mut inv = Inventory::new();
1405
1406 let cost_usd = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1408 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_usd));
1409
1410 let cost_eur = Cost::new(dec!(90.00), "EUR").with_date(date(2024, 2, 1));
1412 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_eur));
1413
1414 let book = inv.book_value("AAPL");
1415 assert_eq!(book.get("USD"), Some(&dec!(1000.00)));
1416 assert_eq!(book.get("EUR"), Some(&dec!(450.00)));
1417 }
1418
1419 #[test]
1420 fn test_reduce_hifo_insufficient_units() {
1421 let mut inv = Inventory::new();
1422
1423 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1424 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1425
1426 let result = inv.reduce(&Amount::new(dec!(-20), "AAPL"), None, BookingMethod::Hifo);
1427
1428 assert!(matches!(
1429 result,
1430 Err(BookingError::InsufficientUnits { .. })
1431 ));
1432 }
1433
1434 #[test]
1435 fn test_reduce_average_insufficient_units() {
1436 let mut inv = Inventory::new();
1437
1438 let cost = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1439 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost));
1440
1441 let result = inv.reduce(
1442 &Amount::new(dec!(-20), "AAPL"),
1443 None,
1444 BookingMethod::Average,
1445 );
1446
1447 assert!(matches!(
1448 result,
1449 Err(BookingError::InsufficientUnits { .. })
1450 ));
1451 }
1452
1453 #[test]
1454 fn test_reduce_average_empty_inventory() {
1455 let mut inv = Inventory::new();
1456
1457 let result = inv.reduce(
1458 &Amount::new(dec!(-10), "AAPL"),
1459 None,
1460 BookingMethod::Average,
1461 );
1462
1463 assert!(matches!(
1464 result,
1465 Err(BookingError::InsufficientUnits { .. })
1466 ));
1467 }
1468
1469 #[test]
1470 fn test_inventory_display_sorted() {
1471 let mut inv = Inventory::new();
1472
1473 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1475 inv.add(Position::simple(Amount::new(dec!(50), "EUR")));
1476 inv.add(Position::simple(Amount::new(dec!(10), "AAPL")));
1477
1478 let display = format!("{inv}");
1479
1480 let aapl_pos = display.find("AAPL").unwrap();
1482 let eur_pos = display.find("EUR").unwrap();
1483 let usd_pos = display.find("USD").unwrap();
1484
1485 assert!(aapl_pos < eur_pos);
1486 assert!(eur_pos < usd_pos);
1487 }
1488
1489 #[test]
1490 fn test_inventory_with_cost_display_sorted() {
1491 let mut inv = Inventory::new();
1492
1493 let cost_high = Cost::new(dec!(200.00), "USD").with_date(date(2024, 1, 1));
1495 let cost_low = Cost::new(dec!(100.00), "USD").with_date(date(2024, 2, 1));
1496
1497 inv.add(Position::with_cost(
1498 Amount::new(dec!(10), "AAPL"),
1499 cost_high,
1500 ));
1501 inv.add(Position::with_cost(Amount::new(dec!(5), "AAPL"), cost_low));
1502
1503 let display = format!("{inv}");
1504
1505 assert!(display.contains("AAPL"));
1507 assert!(display.contains("100"));
1508 assert!(display.contains("200"));
1509 }
1510
1511 #[test]
1512 fn test_reduce_hifo_no_matching_lot() {
1513 let mut inv = Inventory::new();
1514
1515 inv.add(Position::simple(Amount::new(dec!(100), "USD")));
1517
1518 let result = inv.reduce(&Amount::new(dec!(-10), "AAPL"), None, BookingMethod::Hifo);
1519
1520 assert!(matches!(result, Err(BookingError::NoMatchingLot { .. })));
1521 }
1522
1523 #[test]
1524 fn test_fifo_respects_dates() {
1525 let mut inv = Inventory::new();
1527
1528 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1530 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1531
1532 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1533 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1534
1535 let result = inv
1537 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Fifo)
1538 .unwrap();
1539
1540 assert_eq!(result.cost_basis.unwrap().number, dec!(500.00));
1542 }
1543
1544 #[test]
1545 fn test_lifo_respects_dates() {
1546 let mut inv = Inventory::new();
1548
1549 let cost_old = Cost::new(dec!(100.00), "USD").with_date(date(2024, 1, 1));
1551 let cost_new = Cost::new(dec!(200.00), "USD").with_date(date(2024, 3, 1));
1552
1553 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_old));
1554 inv.add(Position::with_cost(Amount::new(dec!(10), "AAPL"), cost_new));
1555
1556 let result = inv
1558 .reduce(&Amount::new(dec!(-5), "AAPL"), None, BookingMethod::Lifo)
1559 .unwrap();
1560
1561 assert_eq!(result.cost_basis.unwrap().number, dec!(1000.00));
1563 }
1564
1565 #[test]
1577 fn test_accounted_error_display_insufficient_units() {
1578 let err = BookingError::InsufficientUnits {
1579 currency: "AAPL".into(),
1580 requested: dec!(15),
1581 available: dec!(10),
1582 }
1583 .with_account("Assets:Stock".into());
1584 let rendered = format!("{err}");
1585
1586 assert!(
1589 rendered.contains("not enough"),
1590 "must contain 'not enough' (pta-standards): {rendered}"
1591 );
1592 assert!(
1593 rendered.contains("Assets:Stock"),
1594 "must contain account name: {rendered}"
1595 );
1596 assert!(
1597 rendered.contains("15") && rendered.contains("10"),
1598 "must contain requested and available amounts: {rendered}"
1599 );
1600 }
1601
1602 #[test]
1603 fn test_accounted_error_display_no_matching_lot() {
1604 let err = BookingError::NoMatchingLot {
1605 currency: "AAPL".into(),
1606 cost_spec: CostSpec::empty(),
1607 }
1608 .with_account("Assets:Stock".into());
1609 let rendered = format!("{err}");
1610
1611 assert!(
1612 rendered.contains("No matching lot"),
1613 "must contain 'No matching lot': {rendered}"
1614 );
1615 assert!(
1616 rendered.contains("AAPL"),
1617 "must contain currency: {rendered}"
1618 );
1619 assert!(
1620 rendered.contains("Assets:Stock"),
1621 "must contain account name: {rendered}"
1622 );
1623 }
1624
1625 #[test]
1626 fn test_accounted_error_display_ambiguous_match() {
1627 let err = BookingError::AmbiguousMatch {
1628 num_matches: 3,
1629 currency: "AAPL".into(),
1630 }
1631 .with_account("Assets:Stock".into());
1632 let rendered = format!("{err}");
1633
1634 assert!(
1635 rendered.contains("Ambiguous"),
1636 "must contain 'Ambiguous': {rendered}"
1637 );
1638 assert!(
1639 rendered.contains("AAPL"),
1640 "must contain currency: {rendered}"
1641 );
1642 assert!(
1643 rendered.contains("Assets:Stock"),
1644 "must contain account name: {rendered}"
1645 );
1646 assert!(
1647 rendered.contains('3'),
1648 "must contain match count: {rendered}"
1649 );
1650 }
1651
1652 #[test]
1653 fn test_accounted_error_display_currency_mismatch_renders_as_no_matching_lot() {
1654 let err = BookingError::CurrencyMismatch {
1662 expected: "USD".into(),
1663 got: "EUR".into(),
1664 }
1665 .with_account("Assets:Cash".into());
1666 let rendered = format!("{err}");
1667
1668 assert!(
1669 rendered.contains("No matching lot"),
1670 "CurrencyMismatch must render as 'No matching lot' for E4001 \
1671 consistency: {rendered}"
1672 );
1673 assert!(
1674 rendered.contains("EUR"),
1675 "must contain the mismatched (got) currency: {rendered}"
1676 );
1677 assert!(
1678 rendered.contains("Assets:Cash"),
1679 "must contain account name: {rendered}"
1680 );
1681 }
1682}