use crate::model::Transaction; use chrono::NaiveDate; pub fn parse(filename: &str) -> anyhow::Result> { let content = std::fs::read_to_string(filename)?; // Find the line that starts with "Item #" to locate the actual CSV data let lines: Vec<&str> = content.lines().collect(); let mut csv_start = 0; for (i, line) in lines.iter().enumerate() { if line.starts_with("Item #") { csv_start = i; break; } } // Create CSV content from the found start point let csv_content = lines[csv_start..].join("\n"); let mut reader = csv::ReaderBuilder::new() .has_headers(true) .from_reader(csv_content.as_bytes()); let mut transactions = Vec::new(); for result in reader.records() { let record = result?; if record.len() >= 6 { // Format: Item #, Card #, Transaction Date, Posting Date, Transaction Amount, Description if let (Ok(date), Ok(amount), Some(description)) = ( NaiveDate::parse_from_str(&record[2], "%Y%m%d"), record[4].parse::(), record.get(5), ) { // BMO amounts are positive for charges, negative for payments let account = "BMO Mastercard".to_string(); transactions.push(Transaction::new( date, description.to_string(), -amount, account, )); } } } Ok(transactions) } #[cfg(test)] mod tests { use super::*; use std::io::Write; use tempfile::NamedTempFile; #[test] fn test_bmo_parser() { let csv_content = "Following data is valid as of 20250619015709:\n\nItem #,Card #,Transaction Date,Posting Date,Transaction Amount,Description\n1,'5191230194633468',20250612,20250616,7.5,AHS ACH PARKING LOTS EDMONTON AB\n2,'5191230194633468',20250615,20250616,144.9,SP MODERN K9 LTD CALGARY AB"; let mut temp_file = NamedTempFile::new().unwrap(); temp_file.write_all(csv_content.as_bytes()).unwrap(); temp_file.flush().unwrap(); let transactions = parse(temp_file.path().to_str().unwrap()).unwrap(); assert_eq!(transactions.len(), 2); let first_txn = &transactions[0]; assert_eq!(first_txn.date.format("%Y-%m-%d").to_string(), "2025-06-12"); assert_eq!(first_txn.description, "AHS ACH PARKING LOTS EDMONTON AB"); assert_eq!(first_txn.amount, -7.5); assert_eq!(first_txn.account, "BMO Mastercard"); assert_eq!(first_txn.category, Some("Transportation".to_string())); let second_txn = &transactions[1]; assert_eq!(second_txn.date.format("%Y-%m-%d").to_string(), "2025-06-15"); assert_eq!(second_txn.description, "SP MODERN K9 LTD CALGARY AB"); assert_eq!(second_txn.amount, -144.9); assert_eq!(second_txn.account, "BMO Mastercard"); assert_eq!(second_txn.category, Some("Other".to_string())); } #[test] fn test_bmo_parser_with_payment() { let csv_content = "Following data is valid as of 20250619015709:\n\nItem #,Card #,Transaction Date,Posting Date,Transaction Amount,Description\n1,'5191230194633468',20250604,20250604,-1168.15,AUTOMATIC PYMT RECEIVED"; let mut temp_file = NamedTempFile::new().unwrap(); temp_file.write_all(csv_content.as_bytes()).unwrap(); temp_file.flush().unwrap(); let transactions = parse(temp_file.path().to_str().unwrap()).unwrap(); assert_eq!(transactions.len(), 1); let payment_txn = &transactions[0]; assert_eq!( payment_txn.date.format("%Y-%m-%d").to_string(), "2025-06-04" ); assert_eq!(payment_txn.description, "AUTOMATIC PYMT RECEIVED"); assert_eq!(payment_txn.amount, 1168.15); // Payment becomes positive assert_eq!(payment_txn.account, "BMO Mastercard"); assert_eq!(payment_txn.category, Some("Transfer".to_string())); } #[test] fn test_bmo_parser_no_item_header() { let csv_content = "Just some text without Item # header\nThis should not parse anything"; let mut temp_file = NamedTempFile::new().unwrap(); temp_file.write_all(csv_content.as_bytes()).unwrap(); temp_file.flush().unwrap(); let transactions = parse(temp_file.path().to_str().unwrap()).unwrap(); assert_eq!(transactions.len(), 0); } }