diff --git a/outlay-core/src/db.rs b/outlay-core/src/db.rs index 2b93fd1..760f726 100644 --- a/outlay-core/src/db.rs +++ b/outlay-core/src/db.rs @@ -387,6 +387,89 @@ impl Database { rows.collect() } + // -- Budgets -- + + pub fn set_budget(&self, category_id: i64, month: &str, amount: f64) -> SqlResult<()> { + self.conn.execute( + "INSERT OR REPLACE INTO budgets (category_id, amount, month) VALUES (?1, ?2, ?3)", + params![category_id, amount, month], + )?; + Ok(()) + } + + pub fn get_budget(&self, category_id: i64, month: &str) -> SqlResult> { + match self.conn.query_row( + "SELECT id, category_id, amount, month FROM budgets WHERE category_id = ?1 AND month = ?2", + params![category_id, month], + |row| { + Ok(Budget { + id: row.get(0)?, + category_id: row.get(1)?, + amount: row.get(2)?, + month: row.get(3)?, + }) + }, + ) { + Ok(b) => Ok(Some(b)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } + } + + pub fn list_budgets_for_month(&self, month: &str) -> SqlResult> { + let mut stmt = self.conn.prepare( + "SELECT id, category_id, amount, month FROM budgets WHERE month = ?1" + )?; + let rows = stmt.query_map(params![month], |row| { + Ok(Budget { + id: row.get(0)?, + category_id: row.get(1)?, + amount: row.get(2)?, + month: row.get(3)?, + }) + })?; + rows.collect() + } + + pub fn delete_budget(&self, id: i64) -> SqlResult<()> { + self.conn.execute("DELETE FROM budgets WHERE id = ?1", params![id])?; + Ok(()) + } + + pub fn get_budget_progress( + &self, + category_id: i64, + month: &str, + ) -> SqlResult> { + let budget = match self.get_budget(category_id, month)? { + Some(b) => b, + None => return Ok(None), + }; + + let spent: f64 = self.conn.query_row( + "SELECT COALESCE(SUM(amount * exchange_rate), 0.0) FROM transactions + WHERE category_id = ?1 AND date LIKE ?2 AND type = 'expense'", + params![category_id, format!("{}%", month)], + |row| row.get(0), + )?; + + let pct = if budget.amount > 0.0 { + (spent / budget.amount) * 100.0 + } else { + 0.0 + }; + + Ok(Some((budget.amount, spent, pct))) + } + + pub fn copy_budgets(&self, from_month: &str, to_month: &str) -> SqlResult<()> { + let budgets = self.list_budgets_for_month(from_month)?; + for b in &budgets { + self.set_budget(b.category_id, to_month, b.amount)?; + } + Ok(()) + } + // -- Exchange Rates -- pub fn get_cached_rate(&self, base: &str, target: &str) -> SqlResult> { @@ -728,4 +811,105 @@ mod tests { ).unwrap(); assert_eq!(count, 20); } + + #[test] + fn test_set_and_get_budget() { + let db = Database::open_in_memory().unwrap(); + let cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); + let cat_id = cats[0].id; + + db.set_budget(cat_id, "2026-03", 500.0).unwrap(); + let budget = db.get_budget(cat_id, "2026-03").unwrap().unwrap(); + assert_eq!(budget.category_id, cat_id); + assert!((budget.amount - 500.0).abs() < 0.01); + assert_eq!(budget.month, "2026-03"); + } + + #[test] + fn test_set_budget_updates_existing() { + let db = Database::open_in_memory().unwrap(); + let cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); + let cat_id = cats[0].id; + + db.set_budget(cat_id, "2026-03", 500.0).unwrap(); + db.set_budget(cat_id, "2026-03", 750.0).unwrap(); + let budget = db.get_budget(cat_id, "2026-03").unwrap().unwrap(); + assert!((budget.amount - 750.0).abs() < 0.01); + } + + #[test] + fn test_list_budgets_for_month() { + let db = Database::open_in_memory().unwrap(); + let cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); + + db.set_budget(cats[0].id, "2026-03", 500.0).unwrap(); + db.set_budget(cats[1].id, "2026-03", 300.0).unwrap(); + db.set_budget(cats[0].id, "2026-04", 600.0).unwrap(); + + let march = db.list_budgets_for_month("2026-03").unwrap(); + assert_eq!(march.len(), 2); + + let april = db.list_budgets_for_month("2026-04").unwrap(); + assert_eq!(april.len(), 1); + } + + #[test] + fn test_delete_budget() { + let db = Database::open_in_memory().unwrap(); + let cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); + let cat_id = cats[0].id; + + db.set_budget(cat_id, "2026-03", 500.0).unwrap(); + let budget = db.get_budget(cat_id, "2026-03").unwrap().unwrap(); + db.delete_budget(budget.id).unwrap(); + assert!(db.get_budget(cat_id, "2026-03").unwrap().is_none()); + } + + #[test] + fn test_budget_progress() { + let db = Database::open_in_memory().unwrap(); + let cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); + let cat_id = cats[0].id; + + db.set_budget(cat_id, "2026-03", 500.0).unwrap(); + + // Add some expenses + let txn = NewTransaction { + amount: 200.0, + transaction_type: TransactionType::Expense, + category_id: cat_id, + currency: "USD".to_string(), + exchange_rate: 1.0, + note: None, + date: NaiveDate::from_ymd_opt(2026, 3, 15).unwrap(), + recurring_id: None, + }; + db.insert_transaction(&txn).unwrap(); + + let (budget_amt, spent, pct) = db.get_budget_progress(cat_id, "2026-03").unwrap().unwrap(); + assert!((budget_amt - 500.0).abs() < 0.01); + assert!((spent - 200.0).abs() < 0.01); + assert!((pct - 40.0).abs() < 0.01); + } + + #[test] + fn test_budget_progress_no_budget() { + let db = Database::open_in_memory().unwrap(); + let cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); + assert!(db.get_budget_progress(cats[0].id, "2026-03").unwrap().is_none()); + } + + #[test] + fn test_copy_budgets() { + let db = Database::open_in_memory().unwrap(); + let cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); + + db.set_budget(cats[0].id, "2026-03", 500.0).unwrap(); + db.set_budget(cats[1].id, "2026-03", 300.0).unwrap(); + + db.copy_budgets("2026-03", "2026-04").unwrap(); + + let april = db.list_budgets_for_month("2026-04").unwrap(); + assert_eq!(april.len(), 2); + } }