diff --git a/revolut_data/eur_savings_2026-04-26.csv b/revolut_data/eur_savings_2026-04-26.csv new file mode 100644 index 0000000..e4b5736 --- /dev/null +++ b/revolut_data/eur_savings_2026-04-26.csv @@ -0,0 +1,9 @@ +Completed Date,Product name,Description,Money out,Money in,Balance +19 mar 2025,,Money brought forward,,,€0 +21 mar 2025,Instant Access - Aion Bank,Deposit,,+€337.37,€337.37 +22 mar 2025,Instant Access - Aion Bank,"Gross interest +Earned on 2025/03/22",,+€0.01,€337.38 +23 mar 2025,Instant Access - Aion Bank,"Gross interest +Earned on 2025/03/23",,+€0.01,€337.39 +14 Jul 2025,Instant Access - Aion Bank,Withdrawal,-€100,,€238.53 +31 Dec 2025,,Money carried forward,,,€0 diff --git a/src/csvparser.rs b/src/csvparser.rs index b5aa804..75693cd 100644 --- a/src/csvparser.rs +++ b/src/csvparser.rs @@ -91,6 +91,7 @@ fn extract_cash(cashline: &str) -> Result { cashline.to_string().replace(",", ".") }; let cashline_string: String = cashline_string.replace(" ", ""); + let cashline_string: String = cashline_string.trim_start_matches('+').to_string(); log::info!("Processed moneyin/total amount line: {cashline_string}"); let mut euro_parser = tuple((double::<&str, Error<_>>, tag("€"))); let mut euro_parser2 = tuple((tag("€"), double::<&str, Error<_>>)); @@ -121,6 +122,32 @@ fn extract_cash(cashline: &str) -> Result { } } +fn sanitize_df(df: &DataFrame) -> DataFrame { + if let Ok(col) = df.column("Description") { + if let Ok(utf) = col.utf8() { + let vals: Vec = utf + .into_iter() + .map(|opt| { + opt.map(|st| { + let replaced = st + .replace("\r\n", " ") + .replace('\n', " ") + .replace('\r', " "); + replaced.split_whitespace().collect::>().join(" ") + }) + .unwrap_or_default() + }) + .collect(); + let new_series = Series::new("Description", vals); + let mut new_df = df.clone(); + if new_df.with_column(new_series).is_ok() { + return new_df; + } + } + } + df.clone() +} + fn extract_dividends_transactions(df: &DataFrame) -> Result { let df_transactions = if df.get_column_names().contains(&"Currency") { df.select([ @@ -535,11 +562,11 @@ pub fn parse_revolut_transactions(csvtoparse: &str) -> Result Result<(), String> { + let desc = vec!["Line1\nLine2", "LineA\r\nLineB"]; + let dates = vec!["01 Jan 2025", "02 Jan 2025"]; + let prod = vec!["P", "Q"]; + let money_out = vec!["null", "-€10"]; + let money_in = vec!["+€0.01", "+€5"]; + let balance = vec!["€0", "€5.01"]; + + let df = DataFrame::new(vec![ + Series::new("Completed Date", dates), + Series::new("Product name", prod), + Series::new("Description", desc), + Series::new("Money out", money_out), + Series::new("Money in", money_in), + Series::new("Balance", balance), + ]) + .map_err(|e| format!("DataFrame creation failed: {e}"))?; + + let sanitized = sanitize_df(&df); + let desc_col = sanitized + .column("Description") + .map_err(|_| "Missing Description column")?; + let utf = desc_col.utf8().map_err(|_| "Description not utf8")?; + for opt in utf.into_iter() { + if let Some(s) = opt { + if s.contains('\n') || s.contains('\r') { + return Err(format!("Found newline in Description after sanitize: {s}")); + } + } + } + + Ok(()) + } #[test] fn test_parse_investment_incomes() -> Result<(), String> { @@ -1670,4 +1731,31 @@ mod tests { ); Ok(()) } + + #[test] + fn test_parse_revolut_eur_savings_20260426() -> Result<(), String> { + let res = parse_revolut_transactions("revolut_data/eur_savings_2026-04-26.csv"); + if res.is_err() { + return Err(format!("Parsing failed: {:?}", res)); + } + let dividends = res.unwrap().dividend_transactions; + // dates, incomes, taxes, symbols + let expected_result = vec![ + ( + "03/22/25".to_owned(), + crate::Currency::EUR(0.01), + crate::Currency::EUR(0.00), + None, + ), + ( + "03/23/25".to_owned(), + crate::Currency::EUR(0.01), + crate::Currency::EUR(0.00), + None, + ), + ]; + assert_eq!(dividends, expected_result); + + Ok(()) + } }