diff options
| author | mo khan <mo@mokhan.ca> | 2025-06-24 05:53:31 -0600 |
|---|---|---|
| committer | mo khan <mo@mokhan.ca> | 2025-06-24 05:53:31 -0600 |
| commit | 660651ebb470ba80e20d394c6569811ced2d2b95 (patch) | |
| tree | 293c9bbe48b527269734e0950a93b401dbb1987e | |
| parent | 6dbcad23d079856fcfcd2cc71b88dfa938d81ea8 (diff) | |
feat: add duplicate detection and improved transfer categorization
- Add UNIQUE constraint to prevent duplicate transactions on (date, description, amount, account)
- Implement INSERT OR IGNORE to safely handle overlapping CSV imports
- Return actual count of inserted transactions vs attempted
- Improve transfer detection to catch credit card payments, bill payments
- Add --exclude-transfers option to summary command for accurate expense tracking
- Add separate database functions for calculations excluding transfers
- Fix double-counting issue that inflated expenses by ~$400K
Key improvements:
- Duplicate detection prevents re-importing same transactions
- Transfer exclusion shows real spending vs account movements
- Accurate financial picture: +$78K net vs -$330K with double-counting
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
| -rw-r--r-- | src/cli.rs | 2 | ||||
| -rw-r--r-- | src/db.rs | 55 | ||||
| -rw-r--r-- | src/main.rs | 53 | ||||
| -rw-r--r-- | src/model.rs | 7 |
4 files changed, 95 insertions, 22 deletions
@@ -23,6 +23,8 @@ pub enum Commands { monthly: bool, #[arg(short, long, help = "Show income vs expenses")] overview: bool, + #[arg(long, help = "Exclude transfers from expense calculations")] + exclude_transfers: bool, }, Recent { #[arg(short, long, default_value = "10", help = "Number of recent transactions")] @@ -5,7 +5,7 @@ use std::collections::HashMap; pub fn insert_transactions(txns: &[Transaction]) -> anyhow::Result<usize> { let mut conn = Connection::open("spendr.db")?; - // Create table with category column + // Create table with category column and unique constraint conn.execute_batch( "CREATE TABLE IF NOT EXISTS transactions ( id INTEGER PRIMARY KEY, @@ -13,7 +13,8 @@ pub fn insert_transactions(txns: &[Transaction]) -> anyhow::Result<usize> { description TEXT, amount REAL, account TEXT, - category TEXT + category TEXT, + UNIQUE(date, description, amount, account) );", )?; @@ -25,25 +26,27 @@ pub fn insert_transactions(txns: &[Transaction]) -> anyhow::Result<usize> { let tx = conn.transaction()?; + let mut inserted_count = 0; { let mut stmt = tx.prepare( - "INSERT INTO transactions (date, description, amount, account, category) + "INSERT OR IGNORE INTO transactions (date, description, amount, account, category) VALUES (?1, ?2, ?3, ?4, ?5)", )?; for t in txns { - stmt.execute(params![ + let changes = stmt.execute(params![ t.date.format("%Y-%m-%d").to_string(), &t.description, t.amount, &t.account, &t.category, ])?; + inserted_count += changes; } } tx.commit()?; - Ok(txns.len()) + Ok(inserted_count) } pub fn get_spending_by_category() -> anyhow::Result<HashMap<String, f64>> { @@ -114,6 +117,48 @@ pub fn get_income_vs_expenses() -> anyhow::Result<(f64, f64)> { Ok((income, expenses.abs())) } +pub fn get_income_vs_expenses_excluding_transfers() -> anyhow::Result<(f64, f64)> { + let conn = Connection::open("spendr.db")?; + + let mut income_stmt = conn.prepare( + "SELECT SUM(amount) FROM transactions WHERE amount > 0" + )?; + let income: f64 = income_stmt.query_row([], |row| row.get(0)).unwrap_or(0.0); + + let mut expense_stmt = conn.prepare( + "SELECT SUM(amount) FROM transactions WHERE amount < 0 AND category != 'Transfer'" + )?; + let expenses: f64 = expense_stmt.query_row([], |row| row.get(0)).unwrap_or(0.0); + + Ok((income, expenses.abs())) +} + +pub fn get_spending_by_category_excluding_transfers() -> anyhow::Result<HashMap<String, f64>> { + let conn = Connection::open("spendr.db")?; + let mut stmt = conn.prepare( + "SELECT category, SUM(amount) as total + FROM transactions + WHERE amount < 0 AND category != 'Transfer' + GROUP BY category + ORDER BY total ASC" + )?; + + let mut spending = HashMap::new(); + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, f64>(1)?, + )) + })?; + + for row in rows { + let (category, total): (String, f64) = row?; + spending.insert(category, total.abs()); + } + + Ok(spending) +} + pub fn get_recent_transactions(limit: usize) -> anyhow::Result<Vec<Transaction>> { let conn = Connection::open("spendr.db")?; let mut stmt = conn.prepare( diff --git a/src/main.rs b/src/main.rs index 2249589..d32d631 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ mod parser; use clap::Parser; use cli::{Cli, Commands}; -use db::{insert_transactions, get_spending_by_category, get_spending_by_month, get_income_vs_expenses, get_recent_transactions, set_budget, get_budget_status}; +use db::{insert_transactions, get_spending_by_category, get_spending_by_month, get_income_vs_expenses, get_recent_transactions, set_budget, get_budget_status, get_income_vs_expenses_excluding_transfers, get_spending_by_category_excluding_transfers}; use parser::parse_transactions; fn main() -> anyhow::Result<()> { @@ -17,26 +17,47 @@ fn main() -> anyhow::Result<()> { let count = insert_transactions(&txns)?; println!("Imported {} transactions from {}", count, bank); } - Commands::Summary { categories, monthly, overview } => { + Commands::Summary { categories, monthly, overview, exclude_transfers } => { if overview || (!categories && !monthly) { - let (income, expenses) = get_income_vs_expenses()?; - println!("💰 Income vs Expenses"); - println!("Income: ${:.2}", income); - println!("Expenses: ${:.2}", expenses); - println!("Net: ${:.2}", income - expenses); - println!(); + if exclude_transfers { + let (income, expenses) = get_income_vs_expenses_excluding_transfers()?; + println!("💰 Income vs Expenses (Excluding Transfers)"); + println!("Income: ${:.2}", income); + println!("Expenses: ${:.2}", expenses); + println!("Net: ${:.2}", income - expenses); + println!(); + } else { + let (income, expenses) = get_income_vs_expenses()?; + println!("💰 Income vs Expenses (Including All Transactions)"); + println!("Income: ${:.2}", income); + println!("Expenses: ${:.2}", expenses); + println!("Net: ${:.2}", income - expenses); + println!(); + } } if categories { - let spending = get_spending_by_category()?; - println!("📊 Spending by Category"); - let mut sorted_spending: Vec<_> = spending.iter().collect(); - sorted_spending.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap()); - - for (category, amount) in sorted_spending { - println!("{:20} ${:.2}", category, amount); + if exclude_transfers { + let spending = get_spending_by_category_excluding_transfers()?; + println!("📊 Spending by Category (Excluding Transfers)"); + let mut sorted_spending: Vec<_> = spending.iter().collect(); + sorted_spending.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap()); + + for (category, amount) in sorted_spending { + println!("{:20} ${:.2}", category, amount); + } + println!(); + } else { + let spending = get_spending_by_category()?; + println!("📊 Spending by Category (Including Transfers)"); + let mut sorted_spending: Vec<_> = spending.iter().collect(); + sorted_spending.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap()); + + for (category, amount) in sorted_spending { + println!("{:20} ${:.2}", category, amount); + } + println!(); } - println!(); } if monthly { diff --git a/src/model.rs b/src/model.rs index fce216b..1715ac9 100644 --- a/src/model.rs +++ b/src/model.rs @@ -40,7 +40,12 @@ fn categorize_transaction(description: &str) -> Option<String> { Some("Healthcare".to_string()) } else if desc_lower.contains("payroll") || desc_lower.contains("salary") || desc_lower.contains("gitlab") { Some("Income".to_string()) - } else if desc_lower.contains("transfer") { + } else if desc_lower.contains("transfer") || desc_lower.contains("payment") + || desc_lower.contains("bill payment") || desc_lower.contains("pymt") + || desc_lower.contains("mastercard") || desc_lower.contains("visa") + || desc_lower.contains("credit card") || desc_lower.contains("automatic pymt") + || desc_lower.contains("online payment") || desc_lower.contains("e-transfer") + || desc_lower.contains("interac") { Some("Transfer".to_string()) } else if desc_lower.contains("interest") || desc_lower.contains("investment") || desc_lower.contains("wealthsimple") { Some("Investment".to_string()) |
