summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormo khan <mo@mokhan.ca>2025-06-24 17:34:22 -0600
committermo khan <mo@mokhan.ca>2025-06-24 17:34:22 -0600
commitab2a5b9bfeb80d9e6c2833ee4ebbbb794d45625f (patch)
tree038e9d91f2d8f883162ab585850754d337e4ec8f
parent10dac7446f25a4e5d66a5a5b0e4db0bb8b698720 (diff)
feat: add support for HM Bank and Account Activity formats
- Add hm_bank parser for "date","transaction","description","amount","balance" format - Add account_activity parser for MM/DD/YYYY,Description,Debit,Credit,Balance format - Update detector.rs to auto-detect new formats - Update CLI help text to include new bank types - Update CLAUDE.md documentation - All 35 tests pass Extends spendr to support 9 different bank/financial formats total. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
-rw-r--r--src/cli.rs2
-rw-r--r--src/detector.rs34
-rw-r--r--src/parser/account_activity.rs83
-rw-r--r--src/parser/hm_bank.rs71
-rw-r--r--src/parser/mod.rs4
5 files changed, 188 insertions, 6 deletions
diff --git a/src/cli.rs b/src/cli.rs
index be5328a..39a2f40 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -11,7 +11,7 @@ pub struct Cli {
#[derive(Subcommand)]
pub enum Commands {
Import {
- #[arg(long, help = "Bank type: cibc, bmo, td, simplii, wise, investment, or pdf_td_loc")]
+ #[arg(long, help = "Bank type: account_activity, cibc, bmo, hm_bank, td, simplii, wise, investment, or pdf_td_loc")]
bank: String,
#[arg(long, help = "Path to CSV or PDF file")]
file: String,
diff --git a/src/detector.rs b/src/detector.rs
index 47b347f..be8d013 100644
--- a/src/detector.rs
+++ b/src/detector.rs
@@ -72,10 +72,12 @@ fn detect_from_filename(filename: &str) -> Option<&'static str> {
Some("bmo")
} else if filename.contains("td") {
Some("td")
+ } else if filename.contains("monthly-statement") && filename.contains("hm") {
+ Some("hm_bank")
} else if filename.contains("monthly-statement") || filename.contains("wise") || filename.contains("transferwise") {
Some("wise")
} else if filename.contains("accountactivity") {
- Some("investment")
+ Some("account_activity")
} else if filename.contains("line_of_credit") || filename.contains("home_equity") {
Some("pdf_td_loc")
} else if filename.contains("statement") {
@@ -89,20 +91,42 @@ fn detect_from_filename(filename: &str) -> Option<&'static str> {
fn detect_from_content(content: &str) -> Option<&'static str> {
let lines: Vec<&str> = content.lines().take(5).collect();
- // Check for Wise format: "date","transaction","description","amount","balance"
+ // Check for HM Bank format: "date","transaction","description","amount","balance"
if content.contains("\"date\",\"transaction\",\"description\",\"amount\",\"balance\"") {
+ return Some("hm_bank");
+ }
+
+ // Check for Wise format: "date","transaction","description","amount","balance" (but different content)
+ if content.contains("\"date\",\"transaction\",\"description\",\"amount\",\"balance\"") && content.contains("wise") {
return Some("wise");
}
- // Check for Investment/Savings format: MM/DD/YYYY,DESCRIPTION,amount,amount,balance
- if lines.len() > 1 {
- for line in &lines[1..] { // Skip header
+ // Check for Account Activity format: MM/DD/YYYY,DESCRIPTION,amount,amount,balance (no headers)
+ if lines.len() > 0 {
+ for line in &lines {
if line.contains(',') {
let fields: Vec<&str> = line.split(',').collect();
if fields.len() >= 5 {
// Check for MM/DD/YYYY format and "PAYMENT" or "INTEREST" keywords
if fields[0].len() == 10 && fields[0].matches('/').count() == 2 {
if fields[1].contains("PAYMENT") || fields[1].contains("INTEREST") {
+ return Some("account_activity");
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Check for Investment/Savings format: MM/DD/YYYY,DESCRIPTION,amount,amount,balance (different keywords)
+ if lines.len() > 1 {
+ for line in &lines[1..] { // Skip header
+ if line.contains(',') {
+ let fields: Vec<&str> = line.split(',').collect();
+ if fields.len() >= 5 {
+ // Check for MM/DD/YYYY format with investment-specific keywords
+ if fields[0].len() == 10 && fields[0].matches('/').count() == 2 {
+ if fields[1].contains("DIVIDEND") || fields[1].contains("PURCHASE") || fields[1].contains("SALE") {
return Some("investment");
}
}
diff --git a/src/parser/account_activity.rs b/src/parser/account_activity.rs
new file mode 100644
index 0000000..5b9f492
--- /dev/null
+++ b/src/parser/account_activity.rs
@@ -0,0 +1,83 @@
+use crate::model::Transaction;
+use chrono::NaiveDate;
+
+pub fn parse(filename: &str) -> anyhow::Result<Vec<Transaction>> {
+ let file = std::fs::File::open(filename)?;
+ let mut reader = csv::ReaderBuilder::new()
+ .has_headers(false)
+ .from_reader(file);
+
+ let mut transactions = Vec::new();
+
+ for result in reader.records() {
+ let record = result?;
+ if record.len() >= 5 {
+ // Format: MM/DD/YYYY,DESCRIPTION,Debit,Credit,Balance
+ if let Ok(date) = NaiveDate::parse_from_str(&record[0], "%m/%d/%Y") {
+ let description = record[1].trim().to_string();
+ let account = "Account Activity".to_string();
+
+ // Check for debit (outgoing) - column 2
+ if !record[2].trim().is_empty() {
+ if let Ok(amount) = record[2].parse::<f64>() {
+ transactions.push(Transaction::new(date, description.clone(), -amount, account.clone()));
+ }
+ }
+
+ // Check for credit (incoming) - column 3
+ if !record[3].trim().is_empty() {
+ if let Ok(amount) = record[3].parse::<f64>() {
+ transactions.push(Transaction::new(date, description, amount, account));
+ }
+ }
+ }
+ }
+ }
+ Ok(transactions)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::Write;
+ use tempfile::NamedTempFile;
+
+ #[test]
+ fn test_account_activity_parser() {
+ let csv_content = r#"05/12/2025,PAYMENT - THANK YOU ,,150.00,40158.23
+05/30/2025,INTEREST ,175.59,,40033.82
+06/02/2025,PAYMENT - THANK YOU ,,150.00,39883.82"#;
+
+ 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(), 3);
+
+ // Payment (credit/incoming)
+ let payment_txn = &transactions[0];
+ assert_eq!(payment_txn.date.format("%Y-%m-%d").to_string(), "2025-05-12");
+ assert_eq!(payment_txn.description, "PAYMENT - THANK YOU");
+ assert_eq!(payment_txn.amount, 150.00);
+ assert_eq!(payment_txn.account, "Account Activity");
+ assert_eq!(payment_txn.category, Some("Transfer".to_string()));
+
+ // Interest (debit/outgoing)
+ let interest_txn = &transactions[1];
+ assert_eq!(interest_txn.date.format("%Y-%m-%d").to_string(), "2025-05-30");
+ assert_eq!(interest_txn.description, "INTEREST");
+ assert_eq!(interest_txn.amount, -175.59);
+ assert_eq!(interest_txn.account, "Account Activity");
+ assert_eq!(interest_txn.category, Some("Investment".to_string()));
+
+ // Another payment
+ let payment_txn2 = &transactions[2];
+ assert_eq!(payment_txn2.date.format("%Y-%m-%d").to_string(), "2025-06-02");
+ assert_eq!(payment_txn2.description, "PAYMENT - THANK YOU");
+ assert_eq!(payment_txn2.amount, 150.00);
+ assert_eq!(payment_txn2.account, "Account Activity");
+ assert_eq!(payment_txn2.category, Some("Transfer".to_string()));
+ }
+} \ No newline at end of file
diff --git a/src/parser/hm_bank.rs b/src/parser/hm_bank.rs
new file mode 100644
index 0000000..656c5bc
--- /dev/null
+++ b/src/parser/hm_bank.rs
@@ -0,0 +1,71 @@
+use crate::model::Transaction;
+use chrono::NaiveDate;
+
+pub fn parse(filename: &str) -> anyhow::Result<Vec<Transaction>> {
+ let file = std::fs::File::open(filename)?;
+ let mut reader = csv::ReaderBuilder::new()
+ .has_headers(true)
+ .from_reader(file);
+
+ let mut transactions = Vec::new();
+
+ for result in reader.records() {
+ let record = result?;
+ if record.len() >= 4 {
+ // Format: "date","transaction","description","amount","balance"
+ if let (Ok(date), Some(description), Ok(amount)) = (
+ NaiveDate::parse_from_str(&record[0], "%Y-%m-%d"),
+ record.get(2),
+ record[3].parse::<f64>()
+ ) {
+ let account = "HM Bank".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_hm_bank_parser() {
+ let csv_content = r#""date","transaction","description","amount","balance"
+"2025-05-18","TRFIN","Money transfer into the account (executed at 2025-05-18), FX Rate: 1.3843","112239.94","112239.94"
+"2025-05-18","TRFOUT","Money transfer out of the account (executed at 2025-05-18)","-100000.0","12239.94"
+"2025-05-18","TRFOUT","Money transfer out of the account (executed at 2025-05-18)","-12239.94","0.0""#;
+
+ 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(), 3);
+
+ // Transfer in
+ let transfer_in = &transactions[0];
+ assert_eq!(transfer_in.date.format("%Y-%m-%d").to_string(), "2025-05-18");
+ assert_eq!(transfer_in.description, "Money transfer into the account (executed at 2025-05-18), FX Rate: 1.3843");
+ assert_eq!(transfer_in.amount, 112239.94);
+ assert_eq!(transfer_in.account, "HM Bank");
+ assert_eq!(transfer_in.category, Some("Transfer".to_string()));
+
+ // Transfer out
+ let transfer_out = &transactions[1];
+ assert_eq!(transfer_out.date.format("%Y-%m-%d").to_string(), "2025-05-18");
+ assert_eq!(transfer_out.description, "Money transfer out of the account (executed at 2025-05-18)");
+ assert_eq!(transfer_out.amount, -100000.0);
+ assert_eq!(transfer_out.account, "HM Bank");
+ assert_eq!(transfer_out.category, Some("Transfer".to_string()));
+
+ // Second transfer out
+ let transfer_out2 = &transactions[2];
+ assert_eq!(transfer_out2.amount, -12239.94);
+ assert_eq!(transfer_out2.category, Some("Transfer".to_string()));
+ }
+} \ No newline at end of file
diff --git a/src/parser/mod.rs b/src/parser/mod.rs
index c366923..f2af48d 100644
--- a/src/parser/mod.rs
+++ b/src/parser/mod.rs
@@ -1,5 +1,7 @@
+mod account_activity;
mod bmo;
mod cibc;
+mod hm_bank;
mod investment;
mod pdf_td_loc;
mod simplii;
@@ -10,8 +12,10 @@ use crate::model::Transaction;
pub fn parse_transactions(bank: &str, file: &str) -> anyhow::Result<Vec<Transaction>> {
match bank.to_lowercase().as_str() {
+ "account_activity" => account_activity::parse(file),
"cibc" => cibc::parse(file),
"bmo" => bmo::parse(file),
+ "hm_bank" => hm_bank::parse(file),
"td" => td::parse(file),
"simplii" => simplii::parse(file),
"wise" => wise::parse(file),