Skip to main content

limabean_booking/
public_types.rs

1use hashbrown::HashMap;
2use std::{
3    cmp::Ordering,
4    fmt::{Debug, Display},
5    hash::Hash,
6    iter::{Sum, repeat},
7    ops::{Add, AddAssign, Deref, Mul, Neg, Sub, SubAssign},
8};
9use strum_macros::Display;
10
11pub trait BookingTypes: Clone + Debug {
12    type Account: Eq + Hash + Clone + Display + Debug;
13    type Date: Eq + Hash + Ord + Copy + Display + Debug;
14    type Currency: Eq + Hash + Ord + Clone + Display + Debug;
15    type Number: Number + Display + Debug;
16    type Label: Eq + Hash + Ord + Clone + Display + Debug;
17}
18
19/// The interface which must be supported by a posting to be bookable.
20pub trait PostingSpec: Clone + Debug {
21    type Types: BookingTypes;
22
23    type CostSpec: CostSpec<Types = Self::Types> + Clone + Debug;
24    type PriceSpec: PriceSpec<Types = Self::Types> + Clone + Debug;
25
26    fn account(&self) -> PostingSpecAccount<Self>;
27    fn units(&self) -> Option<PostingSpecNumber<Self>>;
28    fn currency(&self) -> Option<PostingSpecCurrency<Self>>;
29    fn cost(&self) -> Option<&Self::CostSpec>;
30    fn price(&self) -> Option<&Self::PriceSpec>;
31}
32
33pub type PostingSpecAccount<T> = <<T as PostingSpec>::Types as BookingTypes>::Account;
34pub type PostingSpecNumber<T> = <<T as PostingSpec>::Types as BookingTypes>::Number;
35pub type PostingSpecCurrency<T> = <<T as PostingSpec>::Types as BookingTypes>::Currency;
36
37/// A cost specification, which may be rather loosely specified.
38///
39/// After booking, the process of interpolation turns each cost spec into a [Cost].
40pub trait CostSpec: Clone + Debug {
41    type Types: BookingTypes;
42
43    fn date(&self) -> Option<CostSpecDate<Self>>;
44    fn per_unit(&self) -> Option<CostSpecNumber<Self>>;
45    fn total(&self) -> Option<CostSpecNumber<Self>>;
46    fn currency(&self) -> Option<CostSpecCurrency<Self>>;
47    fn label(&self) -> Option<CostSpecLabel<Self>>;
48    fn merge(&self) -> bool;
49}
50
51pub type CostSpecDate<T> = <<T as CostSpec>::Types as BookingTypes>::Date;
52pub type CostSpecNumber<T> = <<T as CostSpec>::Types as BookingTypes>::Number;
53pub type CostSpecCurrency<T> = <<T as CostSpec>::Types as BookingTypes>::Currency;
54pub type CostSpecLabel<T> = <<T as CostSpec>::Types as BookingTypes>::Label;
55
56/// A price specification, which may be rather loosely specified.
57///
58/// After booking, the process of interpolation turns each price spec into a [Price].
59pub trait PriceSpec: Clone + Debug {
60    type Types: BookingTypes;
61
62    fn per_unit(&self) -> Option<PriceSpecNumber<Self>>;
63    fn total(&self) -> Option<PriceSpecNumber<Self>>;
64    fn currency(&self) -> Option<PriceSpecCurrency<Self>>;
65}
66
67pub type PriceSpecNumber<T> = <<T as PriceSpec>::Types as BookingTypes>::Number;
68pub type PriceSpecCurrency<T> = <<T as PriceSpec>::Types as BookingTypes>::Currency;
69
70/// A single position in a currency, optionally at given cost.
71///
72/// Lots held at cost are split into separate positions, each with a unique combination of cost attributes, with at most
73/// one position having no cost.
74#[derive(PartialEq, Eq, Clone, Debug)]
75pub struct Position<B>
76where
77    B: BookingTypes,
78{
79    pub units: B::Number,
80    pub currency: B::Currency,
81    pub cost: Option<Cost<B>>,
82}
83
84impl<B> Display for Position<B>
85where
86    B: BookingTypes,
87{
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        write!(f, "{} {}", &self.currency, self.units)?;
90        if let Some(cost) = self.cost.as_ref() {
91            write!(f, " {cost}")?;
92        }
93        Ok(())
94    }
95}
96
97impl<B> From<(B::Number, B::Currency)> for Position<B>
98where
99    B: BookingTypes,
100{
101    fn from(value: (B::Number, B::Currency)) -> Self {
102        Self {
103            currency: value.1,
104            units: value.0,
105            cost: None,
106        }
107    }
108}
109
110impl<B> Position<B>
111where
112    B: BookingTypes,
113{
114    pub(crate) fn with_accumulated(&self, units: B::Number) -> Self {
115        let cost = self.cost.as_ref().cloned();
116        Position {
117            currency: self.currency.clone(),
118            units: self.units + units,
119            cost,
120        }
121    }
122}
123
124/// A cost complete with any fields which were missing from its [CostSpec].
125///
126/// In addition to `per-unit` which is the natural representation, the `total`
127/// is also exposed, since this may be what the user originally specified in the
128/// beanfile, and ought to be preserved at its original precision.
129#[derive(Clone, Debug)]
130pub struct Cost<B>
131where
132    B: BookingTypes,
133{
134    pub date: B::Date,
135    pub per_unit: B::Number,
136    pub total: B::Number,
137    pub currency: B::Currency,
138    pub label: Option<B::Label>,
139    pub merge: bool,
140}
141
142impl<B> Display for Cost<B>
143where
144    B: BookingTypes,
145{
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        write!(f, "{{{}, {} {}", &self.date, &self.per_unit, &self.currency)?;
148
149        if let Some(label) = &self.label {
150            write!(f, ", \"{label}\"")?;
151        }
152
153        if self.merge {
154            write!(f, ", *",)?;
155        }
156
157        f.write_str("}")
158    }
159}
160
161impl<B> PartialEq for Cost<B>
162where
163    B: BookingTypes,
164{
165    fn eq(&self, other: &Self) -> bool {
166        self.date == other.date
167            && self.per_unit == other.per_unit
168            && self.currency == other.currency
169            && self.label == other.label
170            && self.merge == other.merge
171    }
172}
173
174impl<B> Eq for Cost<B> where B: BookingTypes {}
175
176impl<B> Hash for Cost<B>
177where
178    B: BookingTypes,
179{
180    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
181        self.date.hash(state);
182        self.per_unit.hash(state);
183        self.total.hash(state);
184        self.currency.hash(state);
185        self.label.hash(state);
186        self.merge.hash(state);
187    }
188}
189
190impl<B> Ord for Cost<B>
191where
192    B: BookingTypes,
193    B::Date: Ord,
194    B::Currency: Ord,
195    B::Number: Ord,
196    B::Label: Ord,
197{
198    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
199        match self.date.cmp(&other.date) {
200            core::cmp::Ordering::Equal => {}
201            ord => return ord,
202        }
203
204        match self.currency.cmp(&other.currency) {
205            core::cmp::Ordering::Equal => {}
206            ord => return ord,
207        }
208
209        match self.per_unit.cmp(&other.per_unit) {
210            core::cmp::Ordering::Equal => {}
211            ord => return ord,
212        }
213
214        match self.label.cmp(&other.label) {
215            core::cmp::Ordering::Equal => {}
216            ord => return ord,
217        }
218
219        self.merge.cmp(&other.merge)
220    }
221}
222
223impl<B> PartialOrd for Cost<B>
224where
225    B: BookingTypes,
226    B::Date: Ord,
227    B::Currency: Ord,
228    B::Number: Ord,
229    B::Label: Ord,
230{
231    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
232        Some(self.cmp(other))
233    }
234}
235
236/// The list of posting costs for an [Interpolated] posting.
237///
238/// Multiple different lots may be reduced by a single post,
239/// but only for a single cost currency.
240// (so that reductions don't violate the categorize by currency buckets)
241#[derive(PartialEq, Eq, Clone, Debug)]
242pub struct PostingCosts<B>
243where
244    B: BookingTypes,
245{
246    pub(crate) cost_currency: B::Currency,
247    pub(crate) adjustments: Vec<PostingCost<B>>,
248}
249
250impl<B> PostingCosts<B>
251where
252    B: BookingTypes,
253{
254    pub fn iter(&self) -> impl Iterator<Item = (&B::Currency, &PostingCost<B>)> {
255        repeat(&self.cost_currency).zip(self.adjustments.iter())
256    }
257
258    pub fn into_currency_costs(self) -> impl Iterator<Item = (B::Currency, PostingCost<B>)> {
259        repeat(self.cost_currency).zip(self.adjustments)
260    }
261}
262
263/// One of potentially a number of posting costs for an [Interpolated] posting.
264#[derive(PartialEq, Eq, Clone, Debug)]
265pub struct PostingCost<B>
266where
267    B: BookingTypes,
268{
269    pub date: B::Date,
270    pub units: B::Number,
271    pub per_unit: B::Number,
272    pub total: B::Number,
273    pub label: Option<B::Label>,
274    pub merge: bool,
275}
276
277impl<B> From<(&B::Currency, &PostingCost<B>)> for Cost<B>
278where
279    B: BookingTypes,
280{
281    fn from(value: (&B::Currency, &PostingCost<B>)) -> Self {
282        let (
283            currency,
284            PostingCost {
285                date,
286                units: _,
287                total,
288                per_unit,
289                label,
290                merge,
291            },
292        ) = value;
293        Self {
294            date: *date,
295            per_unit: *per_unit,
296            total: *total,
297            currency: currency.clone(),
298            label: label.clone(),
299            merge: *merge,
300        }
301    }
302}
303
304/// A price complete with any fields which were missing from its [PriceSpec].
305///
306/// In addition to `per-unit` which is the natural representation, the `total`
307/// is also exposed, since this may be what the user originally specified in the
308/// beanfile, and ought to be preserved at its original precision.
309#[derive(Clone, Debug)]
310pub struct Price<B>
311where
312    B: BookingTypes,
313{
314    pub per_unit: B::Number,
315    pub total: Option<B::Number>,
316    pub currency: B::Currency,
317}
318
319impl<B> Display for Price<B>
320where
321    B: BookingTypes,
322{
323    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
324        write!(f, "@ {} {}", &self.per_unit, &self.currency)
325    }
326}
327
328impl<B> PartialEq for Price<B>
329where
330    B: BookingTypes,
331{
332    fn eq(&self, other: &Self) -> bool {
333        self.per_unit == other.per_unit
334            && self.total == other.total
335            && self.currency == other.currency
336    }
337}
338
339impl<B> Eq for Price<B> where B: BookingTypes {}
340
341impl<B> Hash for Price<B>
342where
343    B: BookingTypes,
344{
345    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
346        self.per_unit.hash(state);
347        self.total.hash(state);
348        self.currency.hash(state);
349    }
350}
351impl<B> Ord for Price<B>
352where
353    B: BookingTypes,
354    B::Date: Ord,
355    B::Currency: Ord,
356    B::Number: Ord,
357    B::Label: Ord,
358{
359    fn cmp(&self, other: &Self) -> Ordering {
360        match self.currency.cmp(&other.currency) {
361            core::cmp::Ordering::Equal => {}
362            ord => return ord,
363        }
364
365        match self.per_unit.cmp(&other.per_unit) {
366            core::cmp::Ordering::Equal => {}
367            ord => return ord,
368        }
369
370        self.total.cmp(&other.total)
371    }
372}
373
374impl<B> PartialOrd for Price<B>
375where
376    B: BookingTypes,
377    B::Date: Ord,
378    B::Currency: Ord,
379    B::Number: Ord,
380    B::Label: Ord,
381{
382    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
383        Some(self.cmp(other))
384    }
385}
386
387/// The interpolated postings and updated inventory after booking all postings in a transaction.
388#[derive(Debug)]
389pub struct Bookings<'p, B, P>
390where
391    B: BookingTypes,
392    P: PostingSpec<Types = B>,
393{
394    pub interpolated_postings: Vec<Interpolated<'p, B, P>>,
395    pub updated_inventory: Inventory<B>,
396}
397
398/// An interpolated posting is one complete with any fields which were missing from its [PostingSpec].
399#[derive(Clone, Debug)]
400pub struct Interpolated<'p, B, P>
401where
402    B: BookingTypes,
403    P: PostingSpec<Types = B>,
404{
405    pub(crate) posting: &'p P,
406    pub(crate) idx: usize,
407    pub units: B::Number,
408    pub currency: B::Currency,
409    pub cost: Option<PostingCosts<B>>,
410    pub price: Option<Price<B>>,
411}
412
413pub trait Tolerance: Clone + Debug {
414    type Types: BookingTypes;
415
416    /// The default tolerance for a given currency,
417    /// returning the fallback value if that particular currency was not specified.
418    fn inferred_tolerance_default(
419        &self,
420        cur: &ToleranceCurrency<Self>,
421    ) -> Option<ToleranceNumber<Self>>;
422
423    fn inferred_tolerance_multiplier(&self) -> Option<ToleranceNumber<Self>>;
424}
425
426pub type ToleranceNumber<T> = <<T as Tolerance>::Types as BookingTypes>::Number;
427pub type ToleranceCurrency<T> = <<T as Tolerance>::Types as BookingTypes>::Currency;
428
429/// The properties required for a decimal type to be usable for booking.
430pub trait Number:
431    Copy
432    + Add<Output = Self>
433    + AddAssign
434    + Sub<Output = Self>
435    + SubAssign
436    + Neg<Output = Self>
437    + Mul<Output = Self>
438    + Sum
439    + Eq
440    + Hash
441    + Ord
442    + Sized
443    + Default
444{
445    fn abs(&self) -> Self;
446
447    // zero is neither positive nor negative
448    fn sign(&self) -> Option<Sign>;
449
450    fn zero() -> Self;
451
452    fn new(m: i64, scale: u32) -> Self;
453
454    fn checked_div(self, other: Self) -> Option<Self>;
455
456    // Returns the scale of the decimal number, otherwise known as e.
457    fn scale(&self) -> u32;
458
459    // Returns a new number with specified scale, rounding as required.
460    fn rescaled(self, scale: u32) -> Self;
461}
462
463/// Positive or negative, with zero being neither.
464#[derive(PartialEq, Eq, Clone, Copy, Display, Debug)]
465pub enum Sign {
466    Positive,
467    Negative,
468}
469
470/// The booking method for an account.
471#[derive(PartialEq, Eq, Default, Clone, Copy, Display, Debug)]
472pub enum Booking {
473    #[default]
474    Strict,
475    StrictWithSize,
476    None,
477    Average,
478    Fifo,
479    Lifo,
480    Hifo,
481}
482
483/// The list of positions for an account.
484#[derive(PartialEq, Eq, Clone, Debug)]
485pub struct Positions<B>(Vec<Position<B>>)
486where
487    B: BookingTypes;
488
489impl<B> Display for Positions<B>
490where
491    B: BookingTypes,
492{
493    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
494        for (i, p) in self.0.iter().enumerate() {
495            write!(f, "{}{}", if i > 0 { ", " } else { "" }, p)?;
496        }
497        Ok(())
498    }
499}
500
501impl<B> Positions<B>
502where
503    B: BookingTypes,
504{
505    // Requires that `positions` satisfy our invariants, so can't be public.
506    pub(crate) fn from_previous(positions: Vec<Position<B>>) -> Self {
507        Self(positions)
508    }
509
510    pub(crate) fn get_mut(&mut self, i: usize) -> Option<&mut Position<B>> {
511        self.0.get_mut(i)
512    }
513
514    pub(crate) fn insert(&mut self, i: usize, element: Position<B>) {
515        self.0.insert(i, element)
516    }
517
518    pub fn units(&self) -> HashMap<&B::Currency, B::Number> {
519        let mut units_by_currency = HashMap::default();
520        for Position {
521            currency, units, ..
522        } in &self.0
523        {
524            if units_by_currency.contains_key(currency) {
525                *units_by_currency.get_mut(currency).unwrap() += *units;
526            } else {
527                units_by_currency.insert(currency, *units);
528            }
529        }
530        units_by_currency
531    }
532
533    pub fn accumulate(
534        &mut self,
535        units: B::Number,
536        currency: B::Currency,
537        cost: Option<Cost<B>>,
538        method: Booking,
539    ) {
540        use Ordering::*;
541
542        let insertion_idx = match method {
543            Booking::Strict
544            | Booking::StrictWithSize
545            | Booking::Fifo
546            | Booking::Lifo
547            | Booking::Hifo => {
548                self.binary_search_by(|existing| match &existing.currency.cmp(&currency) {
549                    ordering @ (Less | Greater) => *ordering,
550                    Equal => match (&existing.cost, &cost) {
551                        (None, None) => Equal,
552                        (Some(_), None) => Greater,
553                        (None, Some(_)) => Less,
554                        (Some(existing_cost), Some(cost)) => {
555                            existing_cost.partial_cmp(cost).unwrap_or(Equal)
556                        }
557                    },
558                })
559            }
560            Booking::None => {
561                self.binary_search_by(|existing| match &existing.currency.cmp(&currency) {
562                    ordering @ (Less | Greater) => *ordering,
563                    Equal => match (&existing.cost, &cost) {
564                        (None, None) => Equal,
565                        (Some(_), None) => Greater,
566                        (_, Some(_)) => Less,
567                    },
568                })
569            }
570            Booking::Average => todo!("average booking method is not yet implemented"),
571        };
572
573        match (insertion_idx, cost) {
574            (Ok(i), None) => {
575                let position = self.get_mut(i).unwrap();
576                position.units += units;
577            }
578            (Ok(i), Some(_cost)) => {
579                let position = self.get_mut(i).unwrap();
580                position.units += units;
581            }
582            (Err(i), None) => {
583                let position = Position {
584                    units,
585                    currency,
586                    cost: None,
587                };
588                self.insert(i, position)
589            }
590            (Err(i), Some(cost)) => {
591                let position = Position {
592                    units,
593                    currency,
594                    cost: Some(cost),
595                };
596                self.insert(i, position)
597            }
598        }
599    }
600}
601
602impl<B> Default for Positions<B>
603where
604    B: BookingTypes,
605{
606    fn default() -> Self {
607        Self(Default::default())
608    }
609}
610
611impl<B> Deref for Positions<B>
612where
613    B: BookingTypes,
614{
615    type Target = Vec<Position<B>>;
616
617    fn deref(&self) -> &Self::Target {
618        &self.0
619    }
620}
621
622impl<B> IntoIterator for Positions<B>
623where
624    B: BookingTypes,
625{
626    type Item = Position<B>;
627    type IntoIter = std::vec::IntoIter<Self::Item>;
628
629    fn into_iter(self) -> Self::IntoIter {
630        self.0.into_iter()
631    }
632}
633
634/// All account positions.
635#[derive(PartialEq, Eq, Debug)]
636pub struct Inventory<B>
637where
638    B: BookingTypes,
639{
640    value: HashMap<B::Account, Positions<B>>,
641}
642
643impl<B> Default for Inventory<B>
644where
645    B: BookingTypes,
646{
647    fn default() -> Self {
648        Self {
649            value: Default::default(),
650        }
651    }
652}
653
654impl<B> From<HashMap<B::Account, Positions<B>>> for Inventory<B>
655where
656    B: BookingTypes,
657{
658    fn from(value: HashMap<B::Account, Positions<B>>) -> Self {
659        Self { value }
660    }
661}
662
663impl<B> Deref for Inventory<B>
664where
665    B: BookingTypes,
666{
667    type Target = HashMap<B::Account, Positions<B>>;
668
669    fn deref(&self) -> &Self::Target {
670        &self.value
671    }
672}
673
674impl<B> IntoIterator for Inventory<B>
675where
676    B: BookingTypes,
677{
678    type Item = (B::Account, Positions<B>);
679    type IntoIter = hashbrown::hash_map::IntoIter<B::Account, Positions<B>>;
680
681    fn into_iter(self) -> hashbrown::hash_map::IntoIter<B::Account, Positions<B>> {
682        self.value.into_iter()
683    }
684}
685
686impl<B> Inventory<B>
687where
688    B: BookingTypes,
689{
690    pub(crate) fn insert(&mut self, k: B::Account, v: Positions<B>) -> Option<Positions<B>> {
691        self.value.insert(k, v)
692    }
693}