ibflex/
ledger_reg_output_parser.rs1use std::str::FromStr;
6
7use chrono::{NaiveDate, NaiveDateTime};
8use rust_decimal::Decimal;
9
10use crate::{flex_enums::CashAction, model::CommonTransaction, ISO_DATE_FORMAT};
11
12pub fn clean_up_register_output(lines: Vec<&str>) -> Vec<String> {
22 let mut new_vec = vec![];
23
24 for line in lines {
26 if line.is_empty() {
27 continue;
28 }
29
30 if line.chars().nth(50).unwrap() == ' ' {
32 continue;
33 }
34
35 new_vec.push(line.to_owned());
36 }
37
38 new_vec
39}
40
41pub fn get_rows_from_register(ledger_lines: Vec<String>) -> Vec<CommonTransaction> {
45 let mut txs: Vec<CommonTransaction> = vec![];
46 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
60fn get_row_from_register_line(line: &str, header: &CommonTransaction) -> CommonTransaction {
62 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 tx.date = if date_str.is_empty() {
81 header.date
82 } else {
83 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 tx.report_date = tx.date.format(ISO_DATE_FORMAT).to_string();
93
94 tx.payee = if payee_str.is_empty() {
96 header.payee.to_owned()
97 } else {
98 payee_str.to_string()
99 };
100
101 tx.symbol = if has_symbol {
103 let parts: Vec<&str> = payee_str.split_whitespace().collect();
104 let symbol = parts[0];
105 symbol.to_string()
110 } else {
111 header.symbol.to_string()
112 };
113
114 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 tx.account = account_str.to_string();
129
130 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 tx.currency = parts[1].to_string();
143
144 tx
145}
146
147#[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 #[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 assert_eq!(actual.date.year(), 2022);
180 assert!(!actual.report_date.is_empty());
182 assert!(!actual.payee.is_empty());
184 assert_eq!(actual.payee, "Supermarket");
185 assert!(!actual.account.is_empty());
187 assert_eq!(actual.account, "Expenses:Food");
188 assert!(!actual.r#type.is_empty());
192 assert!(!actual.amount.is_zero());
194 assert_eq!(actual.amount, Decimal::from(15));
195 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 assert_eq!(2, rows.len());
215
216 assert_eq!("TRET_AS", rows[0].symbol);
218
219 assert_eq!(Decimal::from_str_exact("5.77").unwrap(), rows[1].amount);
222
223 }
225
226 #[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(), 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 assert_eq!(actual.date.year(), 2022);
254 assert!(!actual.report_date.is_empty());
256 assert!(!actual.payee.is_empty());
258 assert_eq!(actual.payee, "Supermarket");
259 assert!(!actual.account.is_empty());
261 assert_eq!(actual.account, "Assets:Bank:Checking");
262 assert!(!actual.r#type.is_empty());
264 assert!(!actual.amount.is_zero());
266 assert_eq!(actual.amount, Decimal::from(-15));
267 assert!(!actual.currency.is_empty());
269 assert_eq!(actual.currency, "EUR");
270 }
271}