Skip to main content

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 Chart of Accounts
39    /// Output size is "small"; ~ size of Chart of Accounts
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 Chart of Accounts
85    /// * Output size is "small"; ~ size of Chart of Accounts
86    ///
87    /// `my_acctn_sum` starting Account Tree Sum entry
88    /// `acc_sums` current incomplete (in sense of Chart of Account) account sums
89    /// `returns`  new set of Account Tree Sums 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 =
116                        settings.get_txn_account(my_acctn.atn.parent.as_str(), &my_acctn.comm)?;
117                    Ok(vec![(new_parent_atn, Decimal::ZERO), my_acctn_sum.clone()])
118                } else {
119                    let new_parent_atn =
120                        settings.get_txn_account(my_acctn.atn.parent.as_str(), &my_acctn.comm)?;
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 Chart of Accounts
144    fn calculate_account_sums<'a, I>(
145        txns: I,
146        price_lookup_ctx: &PriceLookupCtx<'_>,
147        inverted: bool,
148    ) -> Vec<(TxnAccount, Decimal)>
149    where
150        I: Iterator<Item = &'a &'a Transaction>,
151    {
152        let inv = Decimal::from(-1);
153
154        // Calculate sum of postings for each account.
155        //
156        // Input size: is "big",    ~ all transactions
157        // Output size: is "small", ~ size of CoA
158        txns.flat_map(|txn| price_lookup_ctx.convert_prices(txn))
159            .sorted_by_key(|(acctn, _, _)| acctn.clone())
160            .chunk_by(|(acctn, _, _)| acctn.clone())
161            .into_iter()
162            .map(|(_, postings)| {
163                let mut ps = postings.peekable();
164                // unwrap: ok: this is inside map, hence there must be at least one element
165                let acctn = ps.peek().unwrap(/*:ok:*/).0.clone();
166                let acc_sum = ps.map(|(_, amount, _)| amount).sum::<Decimal>();
167                if inverted {
168                    (acctn, acc_sum * inv)
169                } else {
170                    (acctn, acc_sum)
171                }
172            })
173            .collect()
174    }
175
176    /// Calculate balance items
177    ///
178    /// * Input size is "big";     ~ all transactions
179    /// * Output size is "small";  ~ size of Chart of Accounts
180    ///
181    /// * `txns` sequence of transactions
182    /// * `returns` unfiltered sequence of `BalanceTreeNode`s
183    fn balance_tree<'a, I>(
184        txns: I,
185        price_lookup_ctx: &PriceLookupCtx<'_>,
186        settings: &Settings,
187    ) -> Result<Vec<BalanceTreeNode>, tackler::Error>
188    where
189        I: Iterator<Item = &'a &'a Transaction>,
190    {
191        // Calculate sum of postings for each account.
192        //
193        // Input size: is "big",    ~ all transactions
194        // Output size: is "small", ~ size of CoA
195        let account_sums: Vec<(TxnAccount, Decimal)> =
196            Self::calculate_account_sums(txns, price_lookup_ctx, settings.inverted);
197
198        // From every account bubble up and insert missing parent AccTNs.
199        //
200        // This will generate duplicate forks and roots, because we are arriving
201        // from different branches to the same fork in the trunk. So the set must be made
202        // distinct before it can be used, so we won't duplicate sub_tree_account_sums
203        //
204        // Why duplicates? This is using old incomplete set of AccTNSums, not the new,
205        // complete set, which will be the result of this function,
206        // so the same fork in trunk will be "missing" multiple times.
207        //
208        //
209        // Input size:  "small", e.g. ~ size of CoA
210        // Output size: "small", e.g. ~ size of CoA
211        let complete_coa_sum_tree: &Vec<(TxnAccount, Decimal)> = &account_sums
212            .iter()
213            .try_fold(
214                Vec::new(),
215                |mut trees: Vec<Vec<(TxnAccount, Decimal)>>, acc| {
216                    let bua = Balance::bubble_up_acctn(acc, &account_sums, settings)?;
217                    trees.push(bua);
218                    Ok::<Vec<Vec<(TxnAccount, Decimal)>>, tackler::Error>(trees)
219                },
220            )?
221            .into_iter()
222            .flatten()
223            .collect::<HashSet<_>>() // make it distinct
224            .into_iter()
225            .collect::<Vec<(TxnAccount, Decimal)>>();
226
227        // Get all root accounts
228        // Input size:  "small", e.g. ~ size of CoA
229        // Output size: "small", e.g. ~ size of CoA
230        let roots = complete_coa_sum_tree
231            .iter()
232            .filter(|(acctn, _)| acctn.atn.depth == 1);
233
234        // Start from all roots and get all subtree BalanceTreeNodes
235        // Input size:  "small", e.g. ~ size of CoA
236        // Output size: "small", e.g. ~ size of CoA
237        let mut bal = roots
238            .flat_map(|root_acc_sum| {
239                Balance::get_balance_tree_nodes(root_acc_sum, complete_coa_sum_tree)
240            })
241            .collect::<Vec<BalanceTreeNode>>();
242
243        bal.sort_by(ord_by_btn);
244        Ok(bal)
245    }
246
247    /// Balance for this `txn_set`
248    ///
249    /// # Errors
250    /// Returns `Err` in case of error
251    pub fn from<T>(
252        title: &str,
253        txn_set: &TxnSet<'_>,
254        price_lookup_ctx: &PriceLookupCtx<'_>,
255        accounts: &T,
256        settings: &Settings,
257    ) -> Result<Balance, tackler::Error>
258    where
259        T: BalanceSelector + ?Sized,
260    {
261        Self::from_iter(
262            title,
263            &txn_set.txns,
264            price_lookup_ctx,
265            accounts,
266            settings,
267            settings.report.balance.bal_type.clone(),
268        )
269    }
270
271    #[allow(clippy::needless_pass_by_value)]
272    pub(crate) fn from_iter<'a, I, T>(
273        title: &str,
274        txns: I,
275        price_lookup_ctx: &PriceLookupCtx<'_>,
276        accounts: &T,
277        settings: &Settings,
278        bal_type: BalanceType,
279    ) -> Result<Balance, tackler::Error>
280    where
281        T: BalanceSelector + ?Sized,
282        I: IntoIterator<Item = &'a &'a Transaction>,
283    {
284        let bal = match bal_type {
285            BalanceType::Tree => {
286                Balance::balance_tree(txns.into_iter(), price_lookup_ctx, settings)?
287            }
288            BalanceType::Flat => {
289                Balance::balance_flat(txns.into_iter(), price_lookup_ctx, settings)
290            }
291        };
292
293        let filt_bal: Vec<_> = bal.into_iter().filter(|b| accounts.eval(b)).collect();
294
295        if filt_bal.is_empty() {
296            Ok(Balance {
297                title: title.to_string(),
298                bal: Vec::default(),
299                deltas: BTreeMap::default(),
300            })
301        } else {
302            let deltas = filt_bal
303                .iter()
304                .chunk_by(|btn| btn.acctn.comm.clone())
305                .into_iter()
306                .map(|(c, bs)| {
307                    let dsum = bs.map(|b| b.account_sum).sum();
308                    (c.is_any().then_some(c), dsum)
309                })
310                .collect();
311
312            Ok(Balance {
313                title: title.to_string(),
314                bal: filt_bal,
315                deltas,
316            })
317        }
318    }
319
320    fn balance_flat<'a, I>(
321        txns: I,
322        price_lookup_ctx: &PriceLookupCtx<'_>,
323        settings: &Settings,
324    ) -> Vec<BalanceTreeNode>
325    where
326        I: Iterator<Item = &'a &'a Transaction>,
327    {
328        let account_sums: Vec<(TxnAccount, Decimal)> =
329            Self::calculate_account_sums(txns, price_lookup_ctx, settings.inverted);
330
331        let mut v: Vec<BalanceTreeNode> = account_sums
332            .into_iter()
333            .map(|(acctn, acc_sum)| BalanceTreeNode {
334                acctn: acctn.clone(),
335                sub_acc_tree_sum: Decimal::ZERO,
336                account_sum: acc_sum,
337            })
338            .collect();
339
340        v.sort_by(ord_by_btn);
341        v
342    }
343}