From 61ced2d482fa19ffd88d007023b634f13995c26e Mon Sep 17 00:00:00 2001 From: lashman Date: Sun, 1 Mar 2026 23:59:15 +0200 Subject: [PATCH] Add CRUD operations for transactions, categories, and aggregation queries --- outlay-core/src/db.rs | 414 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 414 insertions(+) diff --git a/outlay-core/src/db.rs b/outlay-core/src/db.rs index 11c1036..067dbd9 100644 --- a/outlay-core/src/db.rs +++ b/outlay-core/src/db.rs @@ -1,3 +1,4 @@ +use chrono::NaiveDate; use rusqlite::{Connection, Result as SqlResult, params}; use std::path::Path; @@ -153,6 +154,261 @@ impl Database { self.get_schema_version() } + // -- Transaction CRUD -- + + pub fn insert_transaction(&self, txn: &NewTransaction) -> SqlResult { + let now = chrono::Utc::now().to_rfc3339(); + self.conn.execute( + "INSERT INTO transactions (amount, type, category_id, currency, exchange_rate, note, date, created_at, recurring_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + txn.amount, + txn.transaction_type.as_str(), + txn.category_id, + txn.currency, + txn.exchange_rate, + txn.note, + txn.date.format("%Y-%m-%d").to_string(), + now, + txn.recurring_id, + ], + )?; + Ok(self.conn.last_insert_rowid()) + } + + pub fn get_transaction(&self, id: i64) -> SqlResult { + self.conn.query_row( + "SELECT id, amount, type, category_id, currency, exchange_rate, note, date, created_at, recurring_id + FROM transactions WHERE id = ?1", + params![id], + |row| Self::row_to_transaction(row), + ) + } + + pub fn update_transaction(&self, txn: &Transaction) -> SqlResult<()> { + self.conn.execute( + "UPDATE transactions SET amount=?1, type=?2, category_id=?3, currency=?4, + exchange_rate=?5, note=?6, date=?7 WHERE id=?8", + params![ + txn.amount, + txn.transaction_type.as_str(), + txn.category_id, + txn.currency, + txn.exchange_rate, + txn.note, + txn.date.format("%Y-%m-%d").to_string(), + txn.id, + ], + )?; + Ok(()) + } + + pub fn delete_transaction(&self, id: i64) -> SqlResult<()> { + self.conn.execute("DELETE FROM transactions WHERE id = ?1", params![id])?; + Ok(()) + } + + pub fn list_transactions_by_month(&self, year: i32, month: u32) -> SqlResult> { + let prefix = format!("{:04}-{:02}", year, month); + let mut stmt = self.conn.prepare( + "SELECT id, amount, type, category_id, currency, exchange_rate, note, date, created_at, recurring_id + FROM transactions WHERE date LIKE ?1 ORDER BY date DESC, id DESC" + )?; + let rows = stmt.query_map(params![format!("{}%", prefix)], |row| { + Self::row_to_transaction(row) + })?; + rows.collect() + } + + pub fn list_recent_transactions(&self, limit: usize) -> SqlResult> { + let mut stmt = self.conn.prepare( + "SELECT id, amount, type, category_id, currency, exchange_rate, note, date, created_at, recurring_id + FROM transactions ORDER BY date DESC, id DESC LIMIT ?1" + )?; + let rows = stmt.query_map(params![limit as i64], |row| { + Self::row_to_transaction(row) + })?; + rows.collect() + } + + fn row_to_transaction(row: &rusqlite::Row) -> SqlResult { + let type_str: String = row.get(2)?; + let date_str: String = row.get(7)?; + Ok(Transaction { + id: row.get(0)?, + amount: row.get(1)?, + transaction_type: TransactionType::from_str(&type_str).unwrap_or(TransactionType::Expense), + category_id: row.get(3)?, + currency: row.get(4)?, + exchange_rate: row.get(5)?, + note: row.get(6)?, + date: NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").unwrap_or_default(), + created_at: row.get(8)?, + recurring_id: row.get(9)?, + }) + } + + // -- Category CRUD -- + + pub fn list_categories(&self, txn_type: Option) -> SqlResult> { + match txn_type { + Some(t) => { + let mut stmt = self.conn.prepare( + "SELECT id, name, icon, color, type, is_default, sort_order + FROM categories WHERE type = ?1 ORDER BY sort_order" + )?; + let rows = stmt.query_map(params![t.as_str()], |row| Self::row_to_category(row))?; + rows.collect() + } + None => { + let mut stmt = self.conn.prepare( + "SELECT id, name, icon, color, type, is_default, sort_order + FROM categories ORDER BY type, sort_order" + )?; + let rows = stmt.query_map([], |row| Self::row_to_category(row))?; + rows.collect() + } + } + } + + pub fn get_category(&self, id: i64) -> SqlResult { + self.conn.query_row( + "SELECT id, name, icon, color, type, is_default, sort_order + FROM categories WHERE id = ?1", + params![id], + |row| Self::row_to_category(row), + ) + } + + pub fn insert_category(&self, cat: &NewCategory) -> SqlResult { + self.conn.execute( + "INSERT INTO categories (name, icon, color, type, is_default, sort_order) + VALUES (?1, ?2, ?3, ?4, 0, ?5)", + params![ + cat.name, + cat.icon, + cat.color, + cat.transaction_type.as_str(), + cat.sort_order, + ], + )?; + Ok(self.conn.last_insert_rowid()) + } + + pub fn update_category(&self, cat: &Category) -> SqlResult<()> { + self.conn.execute( + "UPDATE categories SET name=?1, icon=?2, color=?3, sort_order=?4 WHERE id=?5", + params![cat.name, cat.icon, cat.color, cat.sort_order, cat.id], + )?; + Ok(()) + } + + pub fn delete_category(&self, id: i64) -> SqlResult<()> { + self.conn.execute("DELETE FROM categories WHERE id = ?1", params![id])?; + Ok(()) + } + + fn row_to_category(row: &rusqlite::Row) -> SqlResult { + let type_str: String = row.get(4)?; + Ok(Category { + id: row.get(0)?, + name: row.get(1)?, + icon: row.get(2)?, + color: row.get(3)?, + transaction_type: TransactionType::from_str(&type_str).unwrap_or(TransactionType::Expense), + is_default: row.get(5)?, + sort_order: row.get(6)?, + }) + } + + // -- Aggregation queries -- + + pub fn get_monthly_totals_by_category( + &self, + year: i32, + month: u32, + txn_type: TransactionType, + ) -> SqlResult> { + let prefix = format!("{:04}-{:02}", year, month); + let mut stmt = self.conn.prepare( + "SELECT c.id, c.name, c.icon, c.color, c.type, c.is_default, c.sort_order, + SUM(t.amount * t.exchange_rate) as total + FROM transactions t + JOIN categories c ON t.category_id = c.id + WHERE t.date LIKE ?1 AND t.type = ?2 + GROUP BY c.id + ORDER BY total DESC" + )?; + let rows = stmt.query_map(params![format!("{}%", prefix), txn_type.as_str()], |row| { + let cat = Self::row_to_category(row)?; + let total: f64 = row.get(7)?; + Ok((cat, total)) + })?; + rows.collect() + } + + pub fn get_monthly_total( + &self, + year: i32, + month: u32, + txn_type: TransactionType, + ) -> SqlResult { + let prefix = format!("{:04}-{:02}", year, month); + self.conn.query_row( + "SELECT COALESCE(SUM(amount * exchange_rate), 0.0) + FROM transactions WHERE date LIKE ?1 AND type = ?2", + params![format!("{}%", prefix), txn_type.as_str()], + |row| row.get(0), + ) + } + + pub fn get_daily_totals( + &self, + year: i32, + month: u32, + ) -> SqlResult> { + let prefix = format!("{:04}-{:02}", year, month); + let mut stmt = self.conn.prepare( + "SELECT date, + COALESCE(SUM(CASE WHEN type='income' THEN amount * exchange_rate ELSE 0 END), 0.0), + COALESCE(SUM(CASE WHEN type='expense' THEN amount * exchange_rate ELSE 0 END), 0.0) + FROM transactions + WHERE date LIKE ?1 + GROUP BY date + ORDER BY date DESC" + )?; + let rows = stmt.query_map(params![format!("{}%", prefix)], |row| { + let date_str: String = row.get(0)?; + let date = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").unwrap_or_default(); + let income: f64 = row.get(1)?; + let expense: f64 = row.get(2)?; + Ok((date, income, expense)) + })?; + rows.collect() + } + + // -- Settings -- + + pub fn get_setting(&self, key: &str) -> SqlResult> { + match self.conn.query_row( + "SELECT value FROM settings WHERE key = ?1", + params![key], + |row| row.get(0), + ) { + Ok(val) => Ok(Some(val)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } + } + + pub fn set_setting(&self, key: &str, value: &str) -> SqlResult<()> { + self.conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + params![key, value], + )?; + Ok(()) + } + fn seed_default_categories(&self) -> SqlResult<()> { let expense_categories = [ ("Food & Dining", "\u{1f354}", "#e74c3c"), @@ -200,6 +456,7 @@ impl Database { #[cfg(test)] mod tests { use super::*; + use chrono::NaiveDate; #[test] fn test_init_creates_tables() { @@ -269,6 +526,163 @@ mod tests { assert_eq!(missing, 0, "All default categories should have icon and color"); } + fn make_expense(db: &Database) -> i64 { + let cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); + let cat = &cats[0]; + let txn = NewTransaction { + amount: 12.50, + transaction_type: TransactionType::Expense, + category_id: cat.id, + currency: "USD".to_string(), + exchange_rate: 1.0, + note: Some("Lunch".to_string()), + date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), + recurring_id: None, + }; + db.insert_transaction(&txn).unwrap() + } + + fn make_income(db: &Database) -> i64 { + let cats = db.list_categories(Some(TransactionType::Income)).unwrap(); + let cat = &cats[0]; + let txn = NewTransaction { + amount: 3000.0, + transaction_type: TransactionType::Income, + category_id: cat.id, + currency: "USD".to_string(), + exchange_rate: 1.0, + note: Some("Salary".to_string()), + date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), + recurring_id: None, + }; + db.insert_transaction(&txn).unwrap() + } + + #[test] + fn test_insert_and_get_transaction() { + let db = Database::open_in_memory().unwrap(); + let id = make_expense(&db); + let txn = db.get_transaction(id).unwrap(); + assert_eq!(txn.amount, 12.50); + assert_eq!(txn.transaction_type, TransactionType::Expense); + assert_eq!(txn.note.as_deref(), Some("Lunch")); + assert_eq!(txn.date, NaiveDate::from_ymd_opt(2026, 3, 1).unwrap()); + } + + #[test] + fn test_update_transaction() { + let db = Database::open_in_memory().unwrap(); + let id = make_expense(&db); + let mut txn = db.get_transaction(id).unwrap(); + txn.amount = 15.75; + txn.note = Some("Dinner".to_string()); + db.update_transaction(&txn).unwrap(); + let updated = db.get_transaction(id).unwrap(); + assert_eq!(updated.amount, 15.75); + assert_eq!(updated.note.as_deref(), Some("Dinner")); + } + + #[test] + fn test_delete_transaction() { + let db = Database::open_in_memory().unwrap(); + let id = make_expense(&db); + db.delete_transaction(id).unwrap(); + assert!(db.get_transaction(id).is_err()); + } + + #[test] + fn test_list_transactions_by_month() { + let db = Database::open_in_memory().unwrap(); + make_expense(&db); + make_income(&db); + let march = db.list_transactions_by_month(2026, 3).unwrap(); + assert_eq!(march.len(), 2); + let feb = db.list_transactions_by_month(2026, 2).unwrap(); + assert_eq!(feb.len(), 0); + } + + #[test] + fn test_list_recent_transactions() { + let db = Database::open_in_memory().unwrap(); + make_expense(&db); + make_income(&db); + let recent = db.list_recent_transactions(1).unwrap(); + assert_eq!(recent.len(), 1); + let all = db.list_recent_transactions(10).unwrap(); + assert_eq!(all.len(), 2); + } + + #[test] + fn test_list_categories_by_type() { + let db = Database::open_in_memory().unwrap(); + let expense = db.list_categories(Some(TransactionType::Expense)).unwrap(); + assert_eq!(expense.len(), 14); + let income = db.list_categories(Some(TransactionType::Income)).unwrap(); + assert_eq!(income.len(), 6); + let all = db.list_categories(None).unwrap(); + assert_eq!(all.len(), 20); + } + + #[test] + fn test_insert_custom_category() { + let db = Database::open_in_memory().unwrap(); + let cat = NewCategory { + name: "Pets".to_string(), + icon: Some("\u{1f436}".to_string()), + color: Some("#a0522d".to_string()), + transaction_type: TransactionType::Expense, + sort_order: 99, + }; + let id = db.insert_category(&cat).unwrap(); + let fetched = db.get_category(id).unwrap(); + assert_eq!(fetched.name, "Pets"); + assert!(!fetched.is_default); + } + + #[test] + fn test_monthly_totals_by_category() { + let db = Database::open_in_memory().unwrap(); + make_expense(&db); + make_expense(&db); + let totals = db.get_monthly_totals_by_category(2026, 3, TransactionType::Expense).unwrap(); + assert_eq!(totals.len(), 1); + assert_eq!(totals[0].1, 25.0); // 12.50 * 2 + } + + #[test] + fn test_monthly_total() { + let db = Database::open_in_memory().unwrap(); + make_expense(&db); + make_income(&db); + let expenses = db.get_monthly_total(2026, 3, TransactionType::Expense).unwrap(); + assert_eq!(expenses, 12.50); + let income = db.get_monthly_total(2026, 3, TransactionType::Income).unwrap(); + assert_eq!(income, 3000.0); + } + + #[test] + fn test_daily_totals() { + let db = Database::open_in_memory().unwrap(); + make_expense(&db); + make_income(&db); + let daily = db.get_daily_totals(2026, 3).unwrap(); + assert_eq!(daily.len(), 1); + let (date, inc, exp) = &daily[0]; + assert_eq!(*date, NaiveDate::from_ymd_opt(2026, 3, 1).unwrap()); + assert_eq!(*inc, 3000.0); + assert_eq!(*exp, 12.50); + } + + #[test] + fn test_settings_crud() { + let db = Database::open_in_memory().unwrap(); + assert_eq!(db.get_setting("base_currency").unwrap(), None); + db.set_setting("base_currency", "EUR").unwrap(); + assert_eq!(db.get_setting("base_currency").unwrap(), Some("EUR".to_string())); + db.set_setting("base_currency", "GBP").unwrap(); + assert_eq!(db.get_setting("base_currency").unwrap(), Some("GBP".to_string())); + } + #[test] fn test_idempotent_init() { let db = Database::open_in_memory().unwrap();