Add budget CRUD operations and progress calculation

This commit is contained in:
2026-03-02 00:24:08 +02:00
parent 47affa37f0
commit 1105793d88

View File

@@ -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<Option<Budget>> {
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<Vec<Budget>> {
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<Option<(f64, f64, f64)>> {
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<Option<ExchangeRate>> {
@@ -728,4 +811,104 @@ 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();
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);
}
}