tackler_core/report/
balance_reporter.rs

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