Skip to main content

tackler_core/kernel/
balance.rs

1/*
2 * Tackler-NG 2023-2026
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6use crate::config::BalanceType;
7use crate::kernel::Settings;
8use crate::kernel::price_lookup::PriceLookupCtx;
9use crate::kernel::report_item_selector::BalanceSelector;
10use crate::model::balance_tree_node::ord_by_btn;
11use crate::model::{BalanceTreeNode, Commodity, Transaction, TxnAccount, TxnSet};
12use crate::tackler;
13use itertools::Itertools;
14use rust_decimal::Decimal;
15use std::collections::{BTreeMap, HashMap};
16use std::sync::Arc;
17
18// Deltas must be sorted by Commodity on reports, use BTreeMap
19pub type Deltas = BTreeMap<Option<Arc<Commodity>>, Decimal>;
20pub type BTNs = Vec<BalanceTreeNode>;
21#[derive(Debug)]
22pub struct Balance {
23    pub(crate) title: String,
24    pub(crate) bal: BTNs,
25    pub(crate) deltas: Deltas,
26}
27
28impl Balance {
29    pub(crate) fn is_empty(&self) -> bool {
30        self.bal.is_empty()
31    }
32}
33
34impl Balance {
35    /// Bubble up from leafs to root, and generate any missing (gap)
36    /// `AccountTreeNode` (ATN) for new ATN entry with zero atn sum.
37    ///
38    /// The max depth of recursion is the sub-account count
39    /// from leaf to root (e.g. it's small)
40    ///
41    /// * Input size is "small";  ~ size of Chart of Accounts
42    /// * Output size is "small"; ~ size of Chart of Accounts
43    ///
44    /// `my_acctn_sum` starting Account Tree Sum entry
45    /// `acc_sums` current incomplete (in sense of Chart of Account) account sums
46    fn bubble_up_acctn(
47        acc_sums: &mut HashMap<TxnAccount, Decimal>,
48        my_acctn_sum: &(TxnAccount, Decimal),
49        settings: &Settings,
50    ) -> Result<(), tackler::Error> {
51        let my_acctn = &my_acctn_sum.0;
52        if my_acctn.is_root() {
53            // we are on top, so this node (my_acctn) exist already
54            // End of recursion
55            Ok(())
56        } else {
57            // Not on top => find parent for this node
58            let new_parent_atn =
59                settings.get_txn_account(my_acctn.atn.parent.as_str(), &my_acctn.comm)?;
60
61            let parent = acc_sums.get_key_value(&new_parent_atn);
62            if parent.is_some() {
63                // End of recursion
64                Ok(())
65            } else if new_parent_atn.is_root() {
66                acc_sums.insert(new_parent_atn, Decimal::ZERO);
67                // End of recursion
68                Ok(())
69            } else {
70                acc_sums.insert(new_parent_atn.clone(), Decimal::ZERO);
71                Balance::bubble_up_acctn(acc_sums, &(new_parent_atn, Decimal::ZERO), settings)
72            }
73        }
74    }
75
76    /// Calculate sum of postings for each account.
77    ///
78    /// Input size: is "big",    ~ all transactions
79    /// Output size: is "small", ~ size of Chart of Accounts
80    fn calculate_account_sums<'a, I>(
81        txns: I,
82        price_lookup_ctx: &PriceLookupCtx<'_>,
83        inverted: bool,
84    ) -> impl Iterator<Item = (TxnAccount, Decimal)>
85    where
86        I: Iterator<Item = &'a &'a Transaction>,
87    {
88        let mut account_sums: HashMap<TxnAccount, Decimal> = HashMap::new();
89
90        txns.for_each(|txn| {
91            price_lookup_ctx.convert_prices(txn).for_each(|p| {
92                let val = if inverted { -p.1 } else { p.1 };
93                account_sums
94                    .entry(p.0)
95                    .and_modify(|v| {
96                        *v += val;
97                    })
98                    .or_insert(val);
99            });
100        });
101        account_sums.into_iter()
102    }
103
104    /// Calculate balance items
105    ///
106    /// * Input size is "big";     ~ all transactions
107    /// * Output size is "small";  ~ size of Chart of Accounts
108    ///
109    /// * `txns` sequence of transactions
110    /// * `returns` unfiltered sequence of `BalanceTreeNode`s
111    fn balance_tree<'a, I>(
112        txns: I,
113        price_lookup_ctx: &PriceLookupCtx<'_>,
114        settings: &Settings,
115    ) -> Result<Vec<BalanceTreeNode>, tackler::Error>
116    where
117        I: Iterator<Item = &'a &'a Transaction>,
118    {
119        // Calculate sum of postings for each account.
120        //
121        // Input size: is "big",    ~ all transactions
122        // Output size: is "small", ~ size of CoA
123        let account_sums: Vec<(TxnAccount, Decimal)> =
124            Self::calculate_account_sums(txns, price_lookup_ctx, settings.inverted).collect();
125
126        // From every account bubble up and insert missing parent AccTNs.
127        //
128        // Input size:  "small", e.g. ~ size of CoA
129        // Output size: "small", e.g. ~ size of CoA
130        let mut complete_acctn_sums: HashMap<TxnAccount, Decimal> =
131            HashMap::from_iter(account_sums.iter().cloned());
132
133        account_sums
134            .iter()
135            .try_for_each(|acc| -> Result<(), tackler::Error> {
136                Balance::bubble_up_acctn(&mut complete_acctn_sums, acc, settings)
137            })?;
138
139        let mut bal_tns: HashMap<TxnAccount, BalanceTreeNode> = HashMap::new();
140        complete_acctn_sums
141            .iter()
142            .sorted_by(|a, b| a.0.atn.depth.cmp(&b.0.atn.depth).reverse())
143            .try_for_each(|atn_val| -> Result<(), tackler::Error> {
144                // me: insert or update
145                let me = bal_tns
146                    .entry(atn_val.0.clone())
147                    .and_modify(|v| {
148                        v.account_sum += *atn_val.1;
149                        v.sub_acc_tree_sum += *atn_val.1;
150                    })
151                    .or_insert({
152                        BalanceTreeNode {
153                            acctn: atn_val.0.clone(),
154                            sub_acc_tree_sum: *atn_val.1,
155                            account_sum: *atn_val.1,
156                        }
157                    });
158
159                let tree_sum = me.sub_acc_tree_sum;
160
161                // If I'm not root, then insert or update parent
162                if !atn_val.0.is_root() {
163                    let parent_atn =
164                        settings.get_txn_account(atn_val.0.atn.parent.as_str(), &atn_val.0.comm)?;
165
166                    bal_tns
167                        .entry(parent_atn.clone())
168                        .and_modify(|v| {
169                            v.sub_acc_tree_sum += tree_sum;
170                        })
171                        .or_insert({
172                            BalanceTreeNode {
173                                acctn: parent_atn,
174                                sub_acc_tree_sum: tree_sum,
175                                account_sum: Decimal::ZERO,
176                            }
177                        });
178                }
179                Ok(())
180            })?;
181
182        let bal: Vec<BalanceTreeNode> = bal_tns
183            .into_iter()
184            .map(|c| c.1)
185            .sorted_by(ord_by_btn)
186            .collect();
187
188        Ok(bal)
189    }
190
191    /// Balance for this `txn_set`
192    ///
193    /// # Errors
194    /// Returns `Err` in case of error
195    pub fn from<T>(
196        title: &str,
197        txn_set: &TxnSet<'_>,
198        price_lookup_ctx: &PriceLookupCtx<'_>,
199        accounts: &T,
200        settings: &Settings,
201    ) -> Result<Balance, tackler::Error>
202    where
203        T: BalanceSelector + ?Sized,
204    {
205        Self::from_iter(
206            title,
207            &txn_set.txns,
208            price_lookup_ctx,
209            accounts,
210            settings,
211            settings.report.balance.bal_type.clone(),
212        )
213    }
214
215    #[allow(clippy::needless_pass_by_value)]
216    pub(crate) fn from_iter<'a, I, T>(
217        title: &str,
218        txns: I,
219        price_lookup_ctx: &PriceLookupCtx<'_>,
220        accounts: &T,
221        settings: &Settings,
222        bal_type: BalanceType,
223    ) -> Result<Balance, tackler::Error>
224    where
225        T: BalanceSelector + ?Sized,
226        I: IntoIterator<Item = &'a &'a Transaction>,
227    {
228        let bal = match bal_type {
229            BalanceType::Tree => {
230                Balance::balance_tree(txns.into_iter(), price_lookup_ctx, settings)?
231            }
232            BalanceType::Flat => {
233                Balance::balance_flat(txns.into_iter(), price_lookup_ctx, settings)
234            }
235        };
236
237        let filt_bal: Vec<_> = bal.into_iter().filter(|b| accounts.eval(b)).collect();
238
239        if filt_bal.is_empty() {
240            Ok(Balance {
241                title: title.to_string(),
242                bal: Vec::default(),
243                deltas: BTreeMap::default(),
244            })
245        } else {
246            let deltas = filt_bal
247                .iter()
248                .chunk_by(|btn| btn.acctn.comm.clone())
249                .into_iter()
250                .map(|(c, bs)| {
251                    let dsum = bs.map(|b| b.account_sum).sum();
252                    (c.is_any().then_some(c), dsum)
253                })
254                .collect();
255
256            Ok(Balance {
257                title: title.to_string(),
258                bal: filt_bal,
259                deltas,
260            })
261        }
262    }
263
264    fn balance_flat<'a, I>(
265        txns: I,
266        price_lookup_ctx: &PriceLookupCtx<'_>,
267        settings: &Settings,
268    ) -> Vec<BalanceTreeNode>
269    where
270        I: Iterator<Item = &'a &'a Transaction>,
271    {
272        let account_sums = Self::calculate_account_sums(txns, price_lookup_ctx, settings.inverted);
273
274        let mut v: Vec<BalanceTreeNode> = account_sums
275            .map(|(acctn, acc_sum)| BalanceTreeNode {
276                acctn: acctn.clone(),
277                sub_acc_tree_sum: Decimal::ZERO,
278                account_sum: acc_sum,
279            })
280            .collect();
281
282        v.sort_by(ord_by_btn);
283        v
284    }
285}