Skip to main content

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::math::format::format_with_scale;
13use crate::model::{BalanceTreeNode, TxnSet};
14use crate::report::{FormatWriter, Report, report_timezone};
15use crate::tackler;
16use crate::tackler::Error;
17use rust_decimal::Decimal;
18use rust_decimal::prelude::Zero;
19use std::cmp::max;
20use std::io;
21use std::io::Write;
22use tackler_api::metadata::Metadata;
23use tackler_api::metadata::items::{CreditAccountReport, MetadataItem};
24use tackler_api::reports::balance_report::{BalanceItem, BalanceReport, Delta};
25
26#[derive(Debug, Clone)]
27pub struct BalanceReporter {
28    pub report_settings: BalanceSettings,
29}
30
31impl TryFrom<&Settings> for BalanceReporter {
32    type Error = tackler::Error;
33
34    fn try_from(settings: &Settings) -> Result<Self, Self::Error> {
35        Ok(BalanceReporter {
36            report_settings: BalanceSettings::try_from(settings)?,
37        })
38    }
39}
40
41impl BalanceReporter {
42    pub(crate) fn acc_selector(ras: &[String]) -> Result<Box<dyn BalanceSelector>, tackler::Error> {
43        if ras.is_empty() {
44            Ok(Box::<BalanceAllSelector>::default())
45        } else {
46            let s: Vec<_> = ras.iter().map(String::as_str).collect();
47            let ras = BalanceByAccountSelector::try_from(&s)?;
48            Ok(Box::new(ras))
49        }
50    }
51
52    fn get_acc_selector(&self) -> Result<Box<dyn BalanceSelector>, tackler::Error> {
53        BalanceReporter::acc_selector(&self.report_settings.ras)
54    }
55}
56
57impl BalanceReporter {
58    #[allow(clippy::too_many_lines)]
59    pub(crate) fn txt_report<W: io::Write + ?Sized>(
60        writer: &mut W,
61        bal_report: &Balance,
62        bal_settings: &BalanceSettings,
63    ) -> Result<(), tackler::Error> {
64        fn get_max_delta_len(deltas: &Deltas) -> usize {
65            deltas
66                .values()
67                .map(|d| format!("{d}").chars().count())
68                .fold(0, max)
69        }
70        /// Max used length of commodity could be calculated from deltas
71        /// because all balance account commodities are present in there
72        fn get_max_commodity_len(deltas: &Deltas) -> usize {
73            deltas
74                .keys()
75                .map(|opt_comm| {
76                    opt_comm
77                        .as_ref()
78                        .map_or(0, |comm| comm.name.chars().count())
79                })
80                .fold(0, max)
81        }
82
83        fn make_commodity_field(
84            comm_max_len: usize,
85            btn: &BalanceTreeNode,
86            bal_settings: &BalanceSettings,
87        ) -> String {
88            if comm_max_len.is_zero() {
89                // This is the space between acc_tree_sum (ACCTS), commodity, account
90                match bal_settings.bal_type {
91                    BalanceType::Tree => {
92                        // -> always separate with two spaces ACCTS and account
93                        " ".repeat(2)
94                    }
95                    BalanceType::Flat => {
96                        // no need to separate, as there is filler before account field
97                        String::default()
98                    }
99                }
100            } else {
101                let comm = &btn.acctn.comm;
102                match &comm.is_any() {
103                    true => {
104                        match bal_settings.bal_type {
105                            BalanceType::Tree => {
106                                format!(" {: <cl$}  ", comm.name, cl = comm_max_len)
107                            }
108                            BalanceType::Flat => {
109                                // there is filler before this field
110                                format!("{: <cl$}  ", comm.name, cl = comm_max_len)
111                            }
112                        }
113                    }
114                    false => {
115                        match bal_settings.bal_type {
116                            BalanceType::Tree => {
117                                format!(" {}  ", " ".repeat(comm_max_len))
118                            }
119                            BalanceType::Flat => {
120                                // there is filler before this field
121                                format!("{}  ", " ".repeat(comm_max_len))
122                            }
123                        }
124                    }
125                }
126            }
127        }
128
129        let get_max_sum_len = |bal: &BTNs, f: fn(&BalanceTreeNode) -> Decimal| -> usize {
130            bal.iter()
131                .map(|btn| {
132                    let d = f(btn);
133                    // include space for '+-' to the length always
134                    format!("{:+.prec$}", d, prec = bal_settings.scale.get_precision(&d))
135                        .chars()
136                        .count()
137                })
138                .fold(0, max)
139        };
140
141        let delta_max_len = get_max_delta_len(&bal_report.deltas);
142        let comm_max_len = get_max_commodity_len(&bal_report.deltas);
143
144        // max of 12, max_sum_len or delta_max_len
145        let left_sum_len = max(
146            12,
147            max(
148                get_max_sum_len(&bal_report.bal, |btn| btn.account_sum),
149                delta_max_len,
150            ),
151        );
152
153        let sub_acc_tree_sum_len = get_max_sum_len(&bal_report.bal, |btn| btn.sub_acc_tree_sum);
154
155        // filler between account sums (acc and accTree sums)
156        // width of this filler is mandated by delta sum's max commodity length,
157        // because then AccTreesSum won't overlap with delta's commodity
158        let filler_field = if comm_max_len.is_zero() {
159            " ".repeat(3)
160        } else {
161            " ".repeat(4 + comm_max_len)
162        };
163
164        let left_ruler = " ".repeat(9);
165
166        writeln!(writer, "{}", bal_report.title)?;
167        writeln!(writer, "{}", "-".repeat(bal_report.title.chars().count()))?;
168
169        if !bal_report.is_empty() {
170            for btn in &bal_report.bal {
171                let acc_sum =
172                    format_with_scale(left_sum_len, &btn.account_sum, &bal_settings.scale);
173                let comm = make_commodity_field(comm_max_len, btn, bal_settings);
174                let atn = &btn.acctn.atn;
175
176                match bal_settings.bal_type {
177                    BalanceType::Tree => {
178                        writeln!(
179                            writer,
180                            "{left_ruler}{acc_sum}{filler_field}{acc_tree_sum}{comm}{atn}",
181                            acc_tree_sum = format_with_scale(
182                                sub_acc_tree_sum_len,
183                                &btn.sub_acc_tree_sum,
184                                &bal_settings.scale
185                            )
186                        )?;
187                    }
188                    BalanceType::Flat => {
189                        writeln!(writer, "{left_ruler}{acc_sum}{filler_field}{comm}{atn}")?;
190                    }
191                }
192            }
193
194            writeln!(
195                writer,
196                "{}",
197                "=".repeat(
198                    left_ruler.chars().count()
199                        + left_sum_len
200                        + (if comm_max_len.is_zero() {
201                            0
202                        } else {
203                            comm_max_len + 1
204                        })
205                )
206            )?;
207
208            for delta in &bal_report.deltas {
209                writeln!(
210                    writer,
211                    "{left_ruler}{}{}",
212                    format_with_scale(left_sum_len, delta.1, &bal_settings.scale),
213                    delta
214                        .0
215                        .as_ref()
216                        .map_or(String::default(), |c| format!(" {}", c.name)),
217                )?;
218            }
219        }
220        Ok(())
221    }
222
223    fn btn_to_api(btn: &BalanceTreeNode, report_settings: &BalanceSettings) -> BalanceItem {
224        let acc_sum = match report_settings.bal_type {
225            BalanceType::Tree => Some(format_with_scale(
226                0,
227                &btn.sub_acc_tree_sum,
228                &report_settings.scale,
229            )),
230            BalanceType::Flat => None,
231        };
232        BalanceItem {
233            account_sum: format_with_scale(0, &btn.account_sum, &report_settings.scale),
234            account_tree_sum: acc_sum,
235            account: btn.acctn.atn.account.clone(),
236            commodity: if btn.acctn.comm.is_any() {
237                Some(btn.acctn.comm.name.clone())
238            } else {
239                None
240            },
241        }
242    }
243
244    #[must_use]
245    pub fn balance_to_api(
246        metadata: Option<&Metadata>,
247        bal: &Balance,
248        report_settings: &BalanceSettings,
249    ) -> BalanceReport {
250        let balances = bal
251            .bal
252            .iter()
253            .map(|btn| Self::btn_to_api(btn, report_settings))
254            .collect::<Vec<BalanceItem>>();
255
256        let deltas = bal
257            .deltas
258            .iter()
259            .map(|(c, v)| Delta {
260                commodity: c.as_ref().map(|c| c.name.clone()),
261                delta: format_with_scale(0, v, &report_settings.scale),
262            })
263            .collect::<Vec<Delta>>();
264
265        BalanceReport {
266            metadata: metadata.cloned(),
267            title: bal.title.clone(),
268            balances,
269            deltas,
270        }
271    }
272}
273
274impl Report for BalanceReporter {
275    fn write_reports<W: Write + ?Sized>(
276        &self,
277        cfg: &Settings,
278        writers: &mut Vec<FormatWriter<'_>>,
279        metadata: Option<&Metadata>,
280        txn_data: &TxnSet<'_>,
281    ) -> Result<(), Error> {
282        assert_eq!(self.report_settings.inverted, cfg.inverted);
283
284        let acc_sel = self.get_acc_selector()?;
285
286        let price_lookup_ctx = self.report_settings.price_lookup.make_ctx(
287            &txn_data.txns,
288            self.report_settings.report_commodity.clone(),
289            &cfg.price.price_db,
290        );
291        let bal_report = Balance::from(
292            &self.report_settings.title,
293            txn_data,
294            &price_lookup_ctx,
295            acc_sel.as_ref(),
296            cfg,
297        )?;
298
299        let mut metadata = match metadata {
300            Some(md) => md.clone(),
301            None => Metadata::default(),
302        };
303
304        if let Some(hash) = cfg.get_hash() {
305            let asc = acc_sel.account_selector_metadata(hash);
306            metadata.push(asc);
307        }
308
309        if !price_lookup_ctx.is_empty() {
310            let rtz = MetadataItem::TimeZoneInfo(report_timezone(cfg)?);
311            metadata.push(rtz);
312
313            let pr = MetadataItem::PriceRecords(price_lookup_ctx.metadata());
314            metadata.push(pr);
315        }
316
317        if self.report_settings.inverted {
318            let credit = MetadataItem::CreditAccountReport(CreditAccountReport {});
319            metadata.push(credit);
320        }
321
322        for w in writers {
323            match w {
324                FormatWriter::TxtFormat(writer) => {
325                    if !metadata.is_empty() {
326                        writeln!(writer, "{}\n", metadata.text(cfg.report.tz.clone()))?;
327                    }
328
329                    BalanceReporter::txt_report(writer, &bal_report, &self.report_settings)?;
330                }
331                FormatWriter::JsonFormat(writer) => {
332                    let md = if metadata.is_empty() {
333                        None
334                    } else {
335                        Some(&metadata)
336                    };
337                    serde_json::to_writer_pretty(
338                        &mut *writer,
339                        &Self::balance_to_api(md, &bal_report, &self.report_settings),
340                    )?;
341                    writeln!(writer)?;
342                }
343            }
344        }
345        Ok(())
346    }
347}