tackler_core/report/
balance_reporter.rs

1/*
2 * Tackler-NG 2023-2025
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6use crate::kernel::balance::{BTNs, Balance, Deltas};
7use crate::kernel::report_item_selector::{
8    BalanceAllSelector, BalanceByAccountSelector, BalanceSelector,
9};
10use crate::kernel::{BalanceSettings, Settings};
11use crate::model::{BalanceTreeNode, TxnSet};
12use crate::report::{Report, write_acc_sel_checksum, write_price_metadata, write_report_timezone};
13use crate::tackler;
14use itertools::Itertools;
15use rust_decimal::prelude::Zero;
16use rust_decimal::{Decimal, RoundingStrategy};
17use std::cmp::max;
18use std::io;
19
20#[derive(Debug, Clone)]
21pub struct BalanceReporter {
22    pub report_settings: BalanceSettings,
23}
24
25impl TryFrom<&Settings> for BalanceReporter {
26    type Error = tackler::Error;
27
28    fn try_from(settings: &Settings) -> Result<Self, Self::Error> {
29        Ok(BalanceReporter {
30            report_settings: BalanceSettings::try_from(settings)?,
31        })
32    }
33}
34
35impl BalanceReporter {
36    pub(crate) fn acc_selector(ras: &[String]) -> Result<Box<dyn BalanceSelector>, tackler::Error> {
37        if ras.is_empty() {
38            Ok(Box::<BalanceAllSelector>::default())
39        } else {
40            let s: Vec<_> = ras.iter().map(|s| s.as_str()).collect();
41            let ras = BalanceByAccountSelector::from(&s)?;
42            Ok(Box::new(ras))
43        }
44    }
45
46    fn get_acc_selector(&self) -> Result<Box<dyn BalanceSelector>, tackler::Error> {
47        BalanceReporter::acc_selector(&self.report_settings.ras)
48    }
49}
50
51impl BalanceReporter {
52    pub(crate) fn txt_report<W: io::Write + ?Sized>(
53        writer: &mut W,
54        bal_report: &Balance,
55        bal_settings: &BalanceSettings,
56    ) -> Result<(), tackler::Error> {
57        let get_max_sum_len = |bal: &BTNs, f: fn(&BalanceTreeNode) -> Decimal| -> usize {
58            bal.iter()
59                .map(|btn| {
60                    let d = f(btn);
61                    // include space for '+-' to the length always
62                    format!("{:+.prec$}", d, prec = bal_settings.scale.get_precision(&d))
63                        .chars()
64                        .count()
65                })
66                .fold(0, max)
67        };
68        fn get_max_delta_len(deltas: &Deltas) -> usize {
69            deltas
70                .iter()
71                .map(|(_, d)| format!("{}", d).chars().count())
72                .fold(0, max)
73        }
74        /// Max used length of commodity could be calculated from deltas
75        /// because all balance account commodities are present in there
76        fn get_max_commodity_len(deltas: &Deltas) -> usize {
77            deltas
78                .iter()
79                .map(|(opt_comm, _)| {
80                    opt_comm
81                        .as_ref()
82                        .map_or(0, |comm| comm.name.chars().count())
83                })
84                .fold(0, max)
85        }
86
87        let delta_max_len = get_max_delta_len(&bal_report.deltas);
88        let comm_max_len = get_max_commodity_len(&bal_report.deltas);
89
90        // max of 12, max_sum_len or delta_max_len
91        let left_sum_len = max(
92            12,
93            max(
94                get_max_sum_len(&bal_report.bal, |btn| btn.account_sum),
95                delta_max_len,
96            ),
97        );
98
99        let sub_acc_tree_sum_len = get_max_sum_len(&bal_report.bal, |btn| btn.sub_acc_tree_sum);
100
101        // filler between account sums (acc and accTree sums)
102        // width of this filler is mandated by delta sum's max commodity length,
103        // because then AccTreesSum won't overlap with delta's commodity
104        let filler_field = if comm_max_len.is_zero() {
105            " ".repeat(3)
106        } else {
107            " ".repeat(4 + comm_max_len)
108        };
109        let filler_field_len = filler_field.chars().count();
110
111        fn make_commodity_field(comm_max_len: usize, btn: &BalanceTreeNode) -> String {
112            if comm_max_len.is_zero() {
113                // always separate with two spaces
114                " ".repeat(2)
115            } else {
116                let comm = &btn.acctn.comm;
117                match &comm.is_any() {
118                    true => {
119                        format!(" {: <cl$}  ", comm.name, cl = comm_max_len)
120                    }
121                    false => format!(" {}  ", " ".repeat(comm_max_len)),
122                }
123            }
124        }
125
126        let left_ruler = " ".repeat(9);
127
128        writeln!(writer, "{}", bal_report.title)?;
129        writeln!(writer, "{}", "-".repeat(bal_report.title.chars().count()))?;
130
131        if !bal_report.is_empty() {
132            for btn in &bal_report.bal {
133                let prec_1 = bal_settings.scale.get_precision(&btn.account_sum);
134                let prec_2 = bal_settings.scale.get_precision(&btn.sub_acc_tree_sum);
135
136                writeln!(
137                    writer,
138                    "{left_ruler}{:>asl$.prec_1$}{:>width$}{:>satsl$.prec_2$}{}{}",
139                    btn.account_sum.round_dp_with_strategy(
140                        prec_1 as u32,
141                        RoundingStrategy::MidpointAwayFromZero
142                    ),
143                    "",
144                    btn.sub_acc_tree_sum.round_dp_with_strategy(
145                        prec_2 as u32,
146                        RoundingStrategy::MidpointAwayFromZero
147                    ),
148                    make_commodity_field(comm_max_len, btn),
149                    btn.acctn.atn,
150                    asl = left_sum_len,
151                    satsl = sub_acc_tree_sum_len,
152                    width = filler_field_len,
153                )?;
154            }
155
156            writeln!(
157                writer,
158                "{}",
159                "=".repeat(
160                    left_ruler.chars().count()
161                        + left_sum_len
162                        + (if comm_max_len.is_zero() {
163                            0
164                        } else {
165                            comm_max_len + 1
166                        })
167                )
168            )?;
169
170            let deltas = bal_report.deltas.iter().sorted_by_key(|i| {
171                i.0.as_ref()
172                    .map_or(String::default(), |comm| comm.name.clone())
173            });
174            for delta in deltas {
175                let prec = bal_settings.scale.get_precision(delta.1);
176                writeln!(
177                    writer,
178                    "{left_ruler}{:>width$.prec$}{}",
179                    delta.1.round_dp_with_strategy(
180                        prec as u32,
181                        RoundingStrategy::MidpointAwayFromZero
182                    ),
183                    delta
184                        .0
185                        .as_ref()
186                        .map_or(String::default(), |c| format!(" {}", &c.name)),
187                    width = left_sum_len,
188                )?;
189            }
190        }
191        Ok(())
192    }
193}
194
195impl Report for BalanceReporter {
196    fn write_txt_report<W: io::Write + ?Sized>(
197        &self,
198        cfg: &Settings,
199        writer: &mut W,
200        txn_data: &TxnSet<'_>,
201    ) -> Result<(), tackler::Error> {
202        let bal_acc_sel = self.get_acc_selector()?;
203
204        let price_lookup_ctx = self.report_settings.price_lookup.make_ctx(
205            &txn_data.txns,
206            self.report_settings.report_commodity.clone(),
207            &cfg.price.price_db,
208        );
209
210        write_acc_sel_checksum(cfg, writer, bal_acc_sel.as_ref())?;
211
212        if !price_lookup_ctx.is_empty() {
213            write_report_timezone(cfg, writer)?;
214        }
215
216        write_price_metadata(cfg, writer, &price_lookup_ctx)?;
217
218        writeln!(writer)?;
219
220        let bal_report = Balance::from(
221            &self.report_settings.title,
222            txn_data,
223            &price_lookup_ctx,
224            bal_acc_sel.as_ref(),
225            cfg,
226        )?;
227
228        BalanceReporter::txt_report(writer, &bal_report, &self.report_settings)?;
229        Ok(())
230    }
231}