Add budget CRUD operations and progress calculation

set/get/list/delete budgets per category per month. Budget progress
calculates spent vs budgeted percentage. Copy budgets from one month
to the next. Seven new unit tests covering all operations.
This commit is contained in:
2026-03-02 00:24:08 +02:00
parent 29d86a5241
commit b772870d98

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,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);
}
}