tackler_core/kernel/
balance.rs

1/*
2 * Tackler-NG 2023-2025
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;
16use std::{collections::HashSet, 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    /// Recursive get balance tree nodes for this subtree
36    /// starting from and defined by "me"
37    ///
38    /// Input size is "small";  ~ size of CoA
39    /// Output size is "small"; ~ size of CoA
40    ///
41    /// `me` is name of root account for this sub-tree
42    /// `acc_sums` is list of all account sums
43    /// `returns` list of balance tree nodes for this sub-tree
44    fn get_balance_tree_nodes(
45        me: &(TxnAccount, Decimal),
46        acc_sums: &[(TxnAccount, Decimal)],
47    ) -> Vec<BalanceTreeNode> {
48        let (my_acctn, my_sum) = me;
49
50        // find my children (childs)
51        let childs = acc_sums
52            .iter()
53            .filter(|(atn, _)| my_acctn.is_parent_of(atn));
54
55        // calculate balance tree nodes of my childs
56        let childs_balance_trees = childs
57            .flat_map(|c| Balance::get_balance_tree_nodes(c, acc_sums))
58            .collect::<Vec<BalanceTreeNode>>();
59
60        // calculate top sum of my children's balance trees
61        let my_childs_sum = childs_balance_trees
62            .iter()
63            .filter(|btn| btn.acctn.atn.parent == my_acctn.atn.account)
64            .map(|btn| btn.sub_acc_tree_sum)
65            .sum::<Decimal>();
66
67        let my_btn = BalanceTreeNode {
68            acctn: my_acctn.clone(),
69            sub_acc_tree_sum: my_childs_sum + my_sum,
70            account_sum: *my_sum,
71        };
72
73        let mut x = vec![my_btn];
74        x.extend(childs_balance_trees);
75        x
76    }
77
78    /// Bubble up from leafs to root, and generate any missing (gap)
79    /// AccountTreeNode (ATN) for new ATN entry with zero atn sum.
80    ///
81    /// The max depth of recursion is the sub-account count
82    /// from leaf to root (e.g. it's small)
83    ///
84    /// * Input size is "small";  ~ size of CoA
85    /// * Output size is "small"; ~ size of CoA
86    ///
87    /// `my_acctn_sum` starting AccTNSum entry
88    /// `acc_sums` current incomplete (in sense of Chart of Account) account sums
89    /// `returns`  new set of AccTNSums without gaps from this branch to root (from leaf to root)
90    fn bubble_up_acctn(
91        my_acctn_sum: &(TxnAccount, Decimal),
92        acc_sums: &[(TxnAccount, Decimal)],
93        settings: &Settings,
94    ) -> Result<Vec<(TxnAccount, Decimal)>, tackler::Error> {
95        let my_acctn = &my_acctn_sum.0;
96        if my_acctn.is_root() {
97            // we are on top, so either this node (my_acctn) exist already
98            // or it has been created by its child;
99            // End of recursion
100            Ok(vec![my_acctn_sum.clone()])
101        } else {
102            // Not on top => find parent for this node
103            let parent = acc_sums
104                .iter()
105                .filter(|(atn, _)| atn.is_parent_of(my_acctn))
106                .collect::<Vec<&(TxnAccount, Decimal)>>();
107
108            assert!(parent.is_empty() || parent.len() == 1);
109
110            if parent.is_empty() {
111                if my_acctn.my_parent_is_root() {
112                    // This is on depth 2, and it doesn't have parent
113                    // => let's create root account
114                    // End of Recursion
115                    let new_parent_atn = settings
116                        .get_txn_account(my_acctn.atn.parent.as_str(), my_acctn.comm.clone())?;
117                    Ok(vec![(new_parent_atn, Decimal::ZERO), my_acctn_sum.clone()])
118                } else {
119                    let new_parent_atn = settings
120                        .get_txn_account(my_acctn.atn.parent.as_str(), my_acctn.comm.clone())?;
121                    let mut sub_tree = vec![my_acctn_sum.clone()];
122                    let mut x = Balance::bubble_up_acctn(
123                        &(new_parent_atn, Decimal::ZERO),
124                        acc_sums,
125                        settings,
126                    )?;
127                    x.append(&mut sub_tree);
128                    Ok(x)
129                }
130            } else {
131                // Parent of this exists, just bubble them up together
132                let mut sub_tree = vec![my_acctn_sum.clone()];
133                let mut x = Balance::bubble_up_acctn(parent[0], acc_sums, settings)?;
134                x.append(&mut sub_tree);
135                Ok(x)
136            }
137        }
138    }
139
140    /// Calculate sum of postings for each account.
141    ///
142    /// Input size: is "big",    ~ all transactions
143    /// Output size: is "small", ~ size of CoA
144    fn calculate_account_sums<'a, I>(
145        txns: I,
146        price_lookup_ctx: &PriceLookupCtx<'_>,
147    ) -> Vec<(TxnAccount, Decimal)>
148    where
149        I: Iterator<Item = &'a &'a Transaction>,
150    {
151        // Calculate sum of postings for each account.
152        //
153        // Input size: is "big",    ~ all transactions
154        // Output size: is "small", ~ size of CoA
155        txns.flat_map(|txn| price_lookup_ctx.convert_prices(txn))
156            .sorted_by_key(|(acctn, _, _)| acctn.clone())
157            .chunk_by(|(acctn, _, _)| acctn.clone())
158            .into_iter()
159            .map(|(_, postings)| {
160                let mut ps = postings.peekable();
161                // unwrap: ok: this is inside map, hence there must be at least one element
162                let acctn = ps.peek().unwrap(/*:ok:*/).0.clone();
163                let acc_sum = ps.map(|(_, amount, _)| amount).sum::<Decimal>();
164                (acctn, acc_sum)
165            })
166            .collect()
167    }
168
169    /// Calculate balance items
170    ///
171    /// * Input size is "big";     ~ all transactions
172    /// * Output size is "small";  ~ size of CoA
173    ///
174    /// * `txns` sequence of transactions
175    /// * `returns` unfiltered sequence of BalanceTreeNodes
176    fn balance_tree<'a, I>(
177        txns: I,
178        price_lookup_ctx: &PriceLookupCtx<'_>,
179        settings: &Settings,
180    ) -> Result<Vec<BalanceTreeNode>, tackler::Error>
181    where
182        I: Iterator<Item = &'a &'a Transaction>,
183    {
184        // Calculate sum of postings for each account.
185        //
186        // Input size: is "big",    ~ all transactions
187        // Output size: is "small", ~ size of CoA
188        let account_sums: Vec<(TxnAccount, Decimal)> =
189            Self::calculate_account_sums(txns, price_lookup_ctx);
190
191        // From every account bubble up and insert missing parent AccTNs.
192        //
193        // This will generate duplicate forks and roots, because we are arriving
194        // from different branches to the same fork in the trunk. So the set must be made
195        // distinct before it can be used, so we won't duplicate sub_tree_account_sums
196        //
197        // Why duplicates? This is using old incomplete set of AccTNSums, not the new,
198        // complete set, which will be the result of this function,
199        // so the same fork in trunk will be "missing" multiple times.
200        //
201        //
202        // Input size:  "small", e.g. ~ size of CoA
203        // Output size: "small", e.g. ~ size of CoA
204        let complete_coa_sum_tree: &Vec<(TxnAccount, Decimal)> = &account_sums
205            .iter()
206            .try_fold(
207                Vec::new(),
208                |mut trees: Vec<Vec<(TxnAccount, Decimal)>>, acc| {
209                    let bua = Balance::bubble_up_acctn(acc, &account_sums, settings)?;
210                    trees.push(bua);
211                    Ok::<Vec<Vec<(TxnAccount, Decimal)>>, tackler::Error>(trees)
212                },
213            )?
214            .into_iter()
215            .flatten()
216            .collect::<HashSet<_>>() // make it distinct
217            .into_iter()
218            .collect::<Vec<(TxnAccount, Decimal)>>();
219
220        // Get all root accounts
221        // Input size:  "small", e.g. ~ size of CoA
222        // Output size: "small", e.g. ~ size of CoA
223        let roots = complete_coa_sum_tree
224            .iter()
225            .filter(|(acctn, _)| acctn.atn.depth == 1);
226
227        // Start from all roots and get all subtree BalanceTreeNodes
228        // Input size:  "small", e.g. ~ size of CoA
229        // Output size: "small", e.g. ~ size of CoA
230        let mut bal = roots
231            .flat_map(|root_acc_sum| {
232                Balance::get_balance_tree_nodes(root_acc_sum, complete_coa_sum_tree)
233            })
234            .collect::<Vec<BalanceTreeNode>>();
235
236        bal.sort_by(ord_by_btn);
237        Ok(bal)
238    }
239
240    pub fn from<T>(
241        title: &str,
242        txn_set: &TxnSet<'_>,
243        price_lookup_ctx: &PriceLookupCtx<'_>,
244        accounts: &T,
245        settings: &Settings,
246    ) -> Result<Balance, tackler::Error>
247    where
248        T: BalanceSelector + ?Sized,
249    {
250        Self::from_iter(
251            title,
252            &txn_set.txns,
253            price_lookup_ctx,
254            accounts,
255            settings,
256            settings.report.balance.bal_type.clone(),
257        )
258    }
259
260    pub(crate) fn from_iter<'a, I, T>(
261        title: &str,
262        txns: I,
263        price_lookup_ctx: &PriceLookupCtx<'_>,
264        accounts: &T,
265        settings: &Settings,
266        bal_type: BalanceType,
267    ) -> Result<Balance, tackler::Error>
268    where
269        T: BalanceSelector + ?Sized,
270        I: IntoIterator<Item = &'a &'a Transaction>,
271    {
272        let bal = match bal_type {
273            BalanceType::Tree => {
274                Balance::balance_tree(txns.into_iter(), price_lookup_ctx, settings)?
275            }
276            BalanceType::Flat => {
277                Balance::balance_flat(txns.into_iter(), price_lookup_ctx, settings)?
278            }
279        };
280
281        let filt_bal: Vec<_> = bal.into_iter().filter(|b| accounts.eval(b)).collect();
282
283        if filt_bal.is_empty() {
284            Ok(Balance {
285                title: title.to_string(),
286                bal: Default::default(),
287                deltas: Default::default(),
288            })
289        } else {
290            let deltas = filt_bal
291                .iter()
292                .chunk_by(|btn| btn.acctn.comm.clone())
293                .into_iter()
294                .map(|(c, bs)| {
295                    let dsum = bs.map(|b| b.account_sum).sum();
296                    (c.is_any().then_some(c), dsum)
297                })
298                .collect();
299
300            Ok(Balance {
301                title: title.to_string(),
302                bal: filt_bal,
303                deltas,
304            })
305        }
306    }
307
308    fn balance_flat<'a, I>(
309        txns: I,
310        price_lookup_ctx: &PriceLookupCtx<'_>,
311        _settings: &Settings,
312    ) -> Result<Vec<BalanceTreeNode>, tackler::Error>
313    where
314        I: Iterator<Item = &'a &'a Transaction>,
315    {
316        let account_sums: Vec<(TxnAccount, Decimal)> =
317            Self::calculate_account_sums(txns, price_lookup_ctx);
318
319        let mut v: Vec<BalanceTreeNode> = account_sums
320            .into_iter()
321            .map(|(acctn, acc_sum)| BalanceTreeNode {
322                acctn: acctn.clone(),
323                sub_acc_tree_sum: Decimal::ZERO,
324                account_sum: acc_sum,
325            })
326            .collect();
327
328        v.sort_by(ord_by_btn);
329        Ok(v)
330    }
331}