Add budget CRUD operations and progress calculation
This commit is contained in:
@@ -387,6 +387,89 @@ impl Database {
|
|||||||
rows.collect()
|
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 --
|
// -- Exchange Rates --
|
||||||
|
|
||||||
pub fn get_cached_rate(&self, base: &str, target: &str) -> SqlResult<Option<ExchangeRate>> {
|
pub fn get_cached_rate(&self, base: &str, target: &str) -> SqlResult<Option<ExchangeRate>> {
|
||||||
@@ -728,4 +811,104 @@ mod tests {
|
|||||||
).unwrap();
|
).unwrap();
|
||||||
assert_eq!(count, 20);
|
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