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