Skip to main content

tackler_core/report/
register_reporter.rs

1/*
2 * Tackler-NG 2023-2025
3 * SPDX-License-Identifier: Apache-2.0
4 */
5
6use crate::kernel::Settings;
7use crate::kernel::accumulator;
8use crate::kernel::report_item_selector::{
9    RegisterAllSelector, RegisterByAccountSelector, RegisterSelector,
10};
11use crate::kernel::report_settings::RegisterSettings;
12use crate::math::format::format_with_scale;
13use crate::model::{RegisterEntry, TxnSet};
14use crate::report::{FormatWriter, Report, report_timezone};
15use crate::tackler;
16use crate::tackler::Error;
17use jiff::Zoned;
18use jiff::tz::TimeZone;
19use rust_decimal::Decimal;
20use std::io;
21use std::io::Write;
22use tackler_api::metadata::Metadata;
23use tackler_api::metadata::items::{CreditAccountReport, MetadataItem};
24use tackler_api::reports::register_report::{RegisterPosting, RegisterReport, RegisterTxn};
25use tackler_api::txn_ts;
26use tackler_api::txn_ts::TimestampStyle;
27
28#[derive(Debug, Clone)]
29pub struct RegisterReporter {
30    pub report_settings: RegisterSettings,
31}
32
33impl RegisterReporter {
34    fn get_acc_selector(&self) -> Result<Box<dyn RegisterSelector<'_>>, tackler::Error> {
35        let ras = &self.report_settings.ras;
36        if ras.is_empty() {
37            Ok(Box::<RegisterAllSelector>::default())
38        } else {
39            let s: Vec<_> = ras.iter().map(String::as_str).collect();
40            let ras = RegisterByAccountSelector::try_from(&s)?;
41
42            Ok(Box::new(ras))
43        }
44    }
45}
46
47fn reg_entry_txt_writer<W: io::Write + ?Sized>(
48    f: &mut W,
49    re: &RegisterEntry<'_>,
50    register_settings: &RegisterSettings,
51) -> Result<(), tackler::Error> {
52    let ts_style = register_settings.timestamp_style;
53    let report_tz = register_settings.report_tz.clone();
54
55    let fmt: fn(&Zoned, TimeZone) -> String = match ts_style {
56        TimestampStyle::Date => txn_ts::as_tz_date,
57        TimestampStyle::Secodns => txn_ts::as_tz_seconds,
58        TimestampStyle::Full => txn_ts::as_tz_full,
59    };
60
61    if !re.posts.is_empty() {
62        write!(f, "{}", re.fmt_with_cfg(fmt, report_tz, register_settings))?;
63    }
64    Ok(())
65}
66
67fn register_entry_to_api(
68    re: &RegisterEntry<'_>,
69    register_settings: &RegisterSettings,
70) -> Option<RegisterTxn> {
71    if re.posts.is_empty() {
72        return None;
73    }
74
75    let inverter = Decimal::from(-1);
76
77    let scale = &register_settings.scale;
78    let ts_style = register_settings.timestamp_style;
79    let report_tz = register_settings.report_tz.clone();
80
81    let fmt: fn(&Zoned, TimeZone) -> String = match ts_style {
82        TimestampStyle::Date => txn_ts::as_tz_date,
83        TimestampStyle::Secodns => txn_ts::as_tz_seconds,
84        TimestampStyle::Full => txn_ts::as_tz_full,
85    };
86
87    let txn = re.txn;
88
89    let r = re
90        .posts
91        .iter()
92        .map(|p| {
93            let commodity = if p.target_commodity.is_any() {
94                Some(p.target_commodity.name.clone())
95            } else {
96                None
97            };
98            let base_commodity = if p.is_commodity_conv() {
99                Some(p.post.acctn.comm.name.clone())
100            } else {
101                None
102            };
103
104            let (a, rt) = if register_settings.inverted {
105                (&(p.post.amount * inverter), &(p.amount * inverter))
106            } else {
107                (&p.post.amount, &p.amount)
108            };
109
110            RegisterPosting {
111                account: p.post.acctn.atn.account.clone(),
112                amount: format_with_scale(0, a, scale),
113                running_total: format_with_scale(0, rt, scale),
114                comment: p.post.comment.clone(),
115                commodity,
116                rate: p.rate.map(|r| format_with_scale(0, &r, scale)),
117                base_commodity,
118            }
119        })
120        .collect();
121
122    Some(RegisterTxn {
123        display_time: fmt(&txn.header.timestamp, report_tz),
124        txn: txn.header.clone(),
125        postings: r,
126    })
127}
128
129impl Report for RegisterReporter {
130    fn write_reports<W: Write + ?Sized>(
131        &self,
132        cfg: &Settings,
133        writers: &mut Vec<FormatWriter<'_>>,
134        metadata: Option<&Metadata>,
135        txn_data: &TxnSet<'_>,
136    ) -> Result<(), Error> {
137        assert_eq!(self.report_settings.inverted, cfg.inverted);
138
139        let acc_sel = self.get_acc_selector()?;
140
141        let report_commodity = self.report_settings.report_commodity.clone();
142        let price_lookup_ctx = self.report_settings.price_lookup.make_ctx(
143            &txn_data.txns,
144            report_commodity,
145            &cfg.price.price_db,
146        );
147
148        let mut metadata = match metadata {
149            Some(md) => md.clone(),
150            None => Metadata::default(),
151        };
152
153        if let Some(hash) = cfg.get_hash() {
154            let asc = acc_sel.account_selector_metadata(hash);
155            metadata.push(asc);
156        }
157
158        let rtz = MetadataItem::TimeZoneInfo(report_timezone(cfg)?);
159        metadata.push(rtz);
160
161        if !price_lookup_ctx.is_empty() {
162            let pr = MetadataItem::PriceRecords(price_lookup_ctx.metadata());
163            metadata.push(pr);
164        }
165
166        if self.report_settings.inverted {
167            let credit = MetadataItem::CreditAccountReport(CreditAccountReport {});
168            metadata.push(credit);
169        }
170
171        let ras = self.get_acc_selector()?;
172
173        let register =
174            accumulator::register_engine(&txn_data.txns, &price_lookup_ctx, ras.as_ref());
175
176        for w in writers {
177            match w {
178                FormatWriter::TxtFormat(writer) => {
179                    // There is always at least TimeZoneInfo
180                    writeln!(writer, "{}\n", metadata.text(cfg.report.tz.clone()))?;
181
182                    let title = &self.report_settings.title;
183                    writeln!(writer, "{title}")?;
184                    writeln!(writer, "{}", "-".repeat(title.chars().count()))?;
185
186                    for re in &register {
187                        reg_entry_txt_writer(writer, re, &self.report_settings)?;
188                    }
189                }
190                FormatWriter::JsonFormat(writer) => {
191                    let transactions = register
192                        .iter()
193                        .filter_map(|re| register_entry_to_api(re, &self.report_settings))
194                        .collect();
195
196                    let rr = RegisterReport {
197                        metadata: Some(metadata.clone()), // at least TimeZoneInfo
198                        title: self.report_settings.title.clone(),
199                        transactions,
200                    };
201                    serde_json::to_writer_pretty(&mut *writer, &rr)?;
202                    writeln!(writer)?;
203                }
204            }
205        }
206        Ok(())
207    }
208}