ibflex/
ledger_reg_output_parser.rs

1/*!
2 * Parser for Ledger's output of the `register` command.
3 */
4
5use std::str::FromStr;
6
7use chrono::{NaiveDate, NaiveDateTime};
8use rust_decimal::Decimal;
9
10use crate::{flex_enums::CashAction, model::CommonTransaction, ISO_DATE_FORMAT};
11
12/**
13 * Ledger Register row.
14 */
15// pub struct RegisterRow {}
16
17/**
18 * Clean-up the ledger register report.
19 * The report variable is a list of lines.
20 */
21pub fn clean_up_register_output(lines: Vec<&str>) -> Vec<String> {
22    let mut new_vec = vec![];
23
24    // eliminate useless lines
25    for line in lines {
26        if line.is_empty() {
27            continue;
28        }
29
30        // Check the account line. If empty, skip. This is just the running total.
31        if line.chars().nth(50).unwrap() == ' ' {
32            continue;
33        }
34
35        new_vec.push(line.to_owned());
36    }
37
38    new_vec
39}
40
41/**
42 * Parse raw lines from the ledger register output and get RegisterRow.
43 */
44pub fn get_rows_from_register(ledger_lines: Vec<String>) -> Vec<CommonTransaction> {
45    let mut txs: Vec<CommonTransaction> = vec![];
46    // remember the transaction row, with the medatada: date, symbol...
47    let empty_tx = CommonTransaction::default();
48    let mut prev_row = &empty_tx;
49
50    for line in ledger_lines {
51        let tx = get_row_from_register_line(&line, prev_row);
52
53        txs.push(tx);
54
55        prev_row = txs.last().unwrap();
56    }
57    txs
58}
59
60/// Parse one register line into a Transaction object
61fn get_row_from_register_line(line: &str, header: &CommonTransaction) -> CommonTransaction {
62    // header is the transaction with the date (and other metadata?)
63
64    log::debug!("parsing: {:?}", line);
65
66    if line.is_empty() {
67        panic!("The lines must be prepared by `clean_up_register_output`");
68    }
69
70    let has_symbol = line.chars().nth(1).unwrap() != ' ';
71
72    let date_str = &line[0..10].trim();
73    let payee_str = &line[11..46].trim();
74    let account_str = &line[46..85].trim();
75    let amount_str = &line[85..107].trim();
76
77    let mut tx = CommonTransaction::default();
78
79    // Date
80    tx.date = if date_str.is_empty() {
81        header.date
82    } else {
83        // parse
84        // log::debug!("parsing date: {:?}", date_str);
85
86        let tx_date =
87            NaiveDate::parse_from_str(date_str, ISO_DATE_FORMAT).expect("valid date expected");
88        NaiveDateTime::from(tx_date.and_hms_opt(0, 0, 0).unwrap())
89    };
90
91    // Report Date
92    tx.report_date = tx.date.format(ISO_DATE_FORMAT).to_string();
93
94    // Payee
95    tx.payee = if payee_str.is_empty() {
96        header.payee.to_owned()
97    } else {
98        payee_str.to_string()
99    };
100
101    // Symbol
102    tx.symbol = if has_symbol {
103        let parts: Vec<&str> = payee_str.split_whitespace().collect();
104        let symbol = parts[0];
105        // if symbol.contains('.') {
106        //     let index = symbol.find('.').unwrap();
107        //     symbol = &symbol[0..index];
108        // }
109        symbol.to_string()
110    } else {
111        header.symbol.to_string()
112    };
113
114    // Type
115    // Get just the first 2 characters.
116    let account = &account_str[0..2];
117    tx.r#type = if account == "In" {
118        CashAction::Dividend.to_string()
119    } else if account == "Ex" {
120        CashAction::WhTax.to_string()
121    } else {
122        log::warn!("Could not parse type {:?}", account);
123
124        "Error!".to_string()
125    };
126
127    // Account
128    tx.account = account_str.to_string();
129
130    // Amount
131    // Get from the end.
132    let parts: Vec<&str> = amount_str.split_whitespace().collect();
133    if parts.len() > 2 {
134        println!("cannot parse: {:?}", tx);
135    }
136    assert!(parts.len() == 2, "Invalid amount in ledger: {:?}", amount_str);
137
138    let amount = parts[0].replace(",", "");
139    tx.amount = Decimal::from_str(&amount).expect("a valid number");
140
141    // Currency
142    tx.currency = parts[1].to_string();
143
144    tx
145}
146
147// tests
148
149#[cfg(test)]
150mod tests {
151
152    use chrono::{Datelike, NaiveDate};
153    use rust_decimal::Decimal;
154
155    use crate::{
156        ledger_reg_output_parser::{clean_up_register_output, get_rows_from_register},
157        model::CommonTransaction,
158    };
159
160    use super::get_row_from_register_line;
161
162    /**
163     * Parse the transaction top row, with date/payee/account/amount
164     * `l r --init-file tests/init.ledger`
165     */
166    #[test_log::test]
167    fn test_parse_header_row() {
168        let line = r#"2022-12-01 Supermarket                        Expenses:Food                                      15.00 EUR            15.00 EUR"#;
169
170        let header = CommonTransaction::default();
171
172        let actual = get_row_from_register_line(line, &header);
173
174        log::debug!("actual: {:?}", actual);
175
176        // Assertions
177
178        // Date
179        assert_eq!(actual.date.year(), 2022);
180        // Report Date
181        assert!(!actual.report_date.is_empty());
182        // Payee
183        assert!(!actual.payee.is_empty());
184        assert_eq!(actual.payee, "Supermarket");
185        // Account
186        assert!(!actual.account.is_empty());
187        assert_eq!(actual.account, "Expenses:Food");
188        // Symbol
189        //assert!(!actual.symbol.is_empty());
190        // Type
191        assert!(!actual.r#type.is_empty());
192        // Amount
193        assert!(!actual.amount.is_zero());
194        assert_eq!(actual.amount, Decimal::from(15));
195        // Currency
196        assert!(!actual.currency.is_empty());
197        assert_eq!(actual.currency, "EUR");
198    }
199
200    #[test_log::test]
201    fn parse_distribution_report() {
202        let ledger_output = r#"2022-12-15 TRET_AS Distribution                  Income:Investment:IB:TRET_AS                      -38.40 EUR           -38.40 EUR
203                                              Expenses:Investment:IB:Withholding Tax              5.77 EUR           -32.63 EUR"#;
204        let lines = ledger_output.lines().collect();
205        log::debug!("lines: {:?}", lines);
206
207        let clean_lines = clean_up_register_output(lines);
208        let rows = get_rows_from_register(clean_lines);
209
210        log::debug!("rows: {:?}", rows);
211
212        // Assertions
213
214        assert_eq!(2, rows.len());
215
216        // Symbol
217        assert_eq!("TRET_AS", rows[0].symbol);
218
219        // 2nd row
220
221        assert_eq!(Decimal::from_str_exact("5.77").unwrap(), rows[1].amount);
222
223        // todo: assert other fields
224    }
225
226    /**
227     * Parse the posting rows (not the top row).
228     * `l r --init-file tests/init.ledger`
229     */
230    #[test_log::test]
231    fn parse_posting_row_test() {
232        let date = NaiveDate::from_ymd_opt(2022, 12, 1)
233            .unwrap()
234            .and_hms_opt(0, 0, 0)
235            .unwrap();
236        let header = CommonTransaction {
237            date: date,
238            report_date: String::default(), // The report date comes from the Flex report.
239            payee: "Supermarket".to_string(),
240            account: "Expenses:Food".to_string(),
241            amount: Decimal::from(15),
242            currency: "EUR".to_string(),
243            description: String::default(),
244            symbol: String::default(),
245            r#type: String::default(),
246        };
247
248        let line = r#"                                              Assets:Bank:Checking                              -15.00 EUR                    0"#;
249
250        let actual = get_row_from_register_line(line, &header);
251
252        // Date
253        assert_eq!(actual.date.year(), 2022);
254        // Report Date
255        assert!(!actual.report_date.is_empty());
256        // Payee
257        assert!(!actual.payee.is_empty());
258        assert_eq!(actual.payee, "Supermarket");
259        // Account
260        assert!(!actual.account.is_empty());
261        assert_eq!(actual.account, "Assets:Bank:Checking");
262        // Type
263        assert!(!actual.r#type.is_empty());
264        // Amount
265        assert!(!actual.amount.is_zero());
266        assert_eq!(actual.amount, Decimal::from(-15));
267        // Currency
268        assert!(!actual.currency.is_empty());
269        assert_eq!(actual.currency, "EUR");
270    }
271}