Add budget CRUD operations and progress calculation
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user