Add CRUD operations for transactions, categories, and aggregation queries

This commit is contained in:
2026-03-01 23:59:15 +02:00
parent 9865f8288f
commit 61ced2d482

View File

@@ -1,3 +1,4 @@
use chrono::NaiveDate;
use rusqlite::{Connection, Result as SqlResult, params}; use rusqlite::{Connection, Result as SqlResult, params};
use std::path::Path; use std::path::Path;
@@ -153,6 +154,261 @@ impl Database {
self.get_schema_version() self.get_schema_version()
} }
// -- Transaction CRUD --
pub fn insert_transaction(&self, txn: &NewTransaction) -> SqlResult<i64> {
let now = chrono::Utc::now().to_rfc3339();
self.conn.execute(
"INSERT INTO transactions (amount, type, category_id, currency, exchange_rate, note, date, created_at, recurring_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
txn.amount,
txn.transaction_type.as_str(),
txn.category_id,
txn.currency,
txn.exchange_rate,
txn.note,
txn.date.format("%Y-%m-%d").to_string(),
now,
txn.recurring_id,
],
)?;
Ok(self.conn.last_insert_rowid())
}
pub fn get_transaction(&self, id: i64) -> SqlResult<Transaction> {
self.conn.query_row(
"SELECT id, amount, type, category_id, currency, exchange_rate, note, date, created_at, recurring_id
FROM transactions WHERE id = ?1",
params![id],
|row| Self::row_to_transaction(row),
)
}
pub fn update_transaction(&self, txn: &Transaction) -> SqlResult<()> {
self.conn.execute(
"UPDATE transactions SET amount=?1, type=?2, category_id=?3, currency=?4,
exchange_rate=?5, note=?6, date=?7 WHERE id=?8",
params![
txn.amount,
txn.transaction_type.as_str(),
txn.category_id,
txn.currency,
txn.exchange_rate,
txn.note,
txn.date.format("%Y-%m-%d").to_string(),
txn.id,
],
)?;
Ok(())
}
pub fn delete_transaction(&self, id: i64) -> SqlResult<()> {
self.conn.execute("DELETE FROM transactions WHERE id = ?1", params![id])?;
Ok(())
}
pub fn list_transactions_by_month(&self, year: i32, month: u32) -> SqlResult<Vec<Transaction>> {
let prefix = format!("{:04}-{:02}", year, month);
let mut stmt = self.conn.prepare(
"SELECT id, amount, type, category_id, currency, exchange_rate, note, date, created_at, recurring_id
FROM transactions WHERE date LIKE ?1 ORDER BY date DESC, id DESC"
)?;
let rows = stmt.query_map(params![format!("{}%", prefix)], |row| {
Self::row_to_transaction(row)
})?;
rows.collect()
}
pub fn list_recent_transactions(&self, limit: usize) -> SqlResult<Vec<Transaction>> {
let mut stmt = self.conn.prepare(
"SELECT id, amount, type, category_id, currency, exchange_rate, note, date, created_at, recurring_id
FROM transactions ORDER BY date DESC, id DESC LIMIT ?1"
)?;
let rows = stmt.query_map(params![limit as i64], |row| {
Self::row_to_transaction(row)
})?;
rows.collect()
}
fn row_to_transaction(row: &rusqlite::Row) -> SqlResult<Transaction> {
let type_str: String = row.get(2)?;
let date_str: String = row.get(7)?;
Ok(Transaction {
id: row.get(0)?,
amount: row.get(1)?,
transaction_type: TransactionType::from_str(&type_str).unwrap_or(TransactionType::Expense),
category_id: row.get(3)?,
currency: row.get(4)?,
exchange_rate: row.get(5)?,
note: row.get(6)?,
date: NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").unwrap_or_default(),
created_at: row.get(8)?,
recurring_id: row.get(9)?,
})
}
// -- Category CRUD --
pub fn list_categories(&self, txn_type: Option<TransactionType>) -> SqlResult<Vec<Category>> {
match txn_type {
Some(t) => {
let mut stmt = self.conn.prepare(
"SELECT id, name, icon, color, type, is_default, sort_order
FROM categories WHERE type = ?1 ORDER BY sort_order"
)?;
let rows = stmt.query_map(params![t.as_str()], |row| Self::row_to_category(row))?;
rows.collect()
}
None => {
let mut stmt = self.conn.prepare(
"SELECT id, name, icon, color, type, is_default, sort_order
FROM categories ORDER BY type, sort_order"
)?;
let rows = stmt.query_map([], |row| Self::row_to_category(row))?;
rows.collect()
}
}
}
pub fn get_category(&self, id: i64) -> SqlResult<Category> {
self.conn.query_row(
"SELECT id, name, icon, color, type, is_default, sort_order
FROM categories WHERE id = ?1",
params![id],
|row| Self::row_to_category(row),
)
}
pub fn insert_category(&self, cat: &NewCategory) -> SqlResult<i64> {
self.conn.execute(
"INSERT INTO categories (name, icon, color, type, is_default, sort_order)
VALUES (?1, ?2, ?3, ?4, 0, ?5)",
params![
cat.name,
cat.icon,
cat.color,
cat.transaction_type.as_str(),
cat.sort_order,
],
)?;
Ok(self.conn.last_insert_rowid())
}
pub fn update_category(&self, cat: &Category) -> SqlResult<()> {
self.conn.execute(
"UPDATE categories SET name=?1, icon=?2, color=?3, sort_order=?4 WHERE id=?5",
params![cat.name, cat.icon, cat.color, cat.sort_order, cat.id],
)?;
Ok(())
}
pub fn delete_category(&self, id: i64) -> SqlResult<()> {
self.conn.execute("DELETE FROM categories WHERE id = ?1", params![id])?;
Ok(())
}
fn row_to_category(row: &rusqlite::Row) -> SqlResult<Category> {
let type_str: String = row.get(4)?;
Ok(Category {
id: row.get(0)?,
name: row.get(1)?,
icon: row.get(2)?,
color: row.get(3)?,
transaction_type: TransactionType::from_str(&type_str).unwrap_or(TransactionType::Expense),
is_default: row.get(5)?,
sort_order: row.get(6)?,
})
}
// -- Aggregation queries --
pub fn get_monthly_totals_by_category(
&self,
year: i32,
month: u32,
txn_type: TransactionType,
) -> SqlResult<Vec<(Category, f64)>> {
let prefix = format!("{:04}-{:02}", year, month);
let mut stmt = self.conn.prepare(
"SELECT c.id, c.name, c.icon, c.color, c.type, c.is_default, c.sort_order,
SUM(t.amount * t.exchange_rate) as total
FROM transactions t
JOIN categories c ON t.category_id = c.id
WHERE t.date LIKE ?1 AND t.type = ?2
GROUP BY c.id
ORDER BY total DESC"
)?;
let rows = stmt.query_map(params![format!("{}%", prefix), txn_type.as_str()], |row| {
let cat = Self::row_to_category(row)?;
let total: f64 = row.get(7)?;
Ok((cat, total))
})?;
rows.collect()
}
pub fn get_monthly_total(
&self,
year: i32,
month: u32,
txn_type: TransactionType,
) -> SqlResult<f64> {
let prefix = format!("{:04}-{:02}", year, month);
self.conn.query_row(
"SELECT COALESCE(SUM(amount * exchange_rate), 0.0)
FROM transactions WHERE date LIKE ?1 AND type = ?2",
params![format!("{}%", prefix), txn_type.as_str()],
|row| row.get(0),
)
}
pub fn get_daily_totals(
&self,
year: i32,
month: u32,
) -> SqlResult<Vec<(NaiveDate, f64, f64)>> {
let prefix = format!("{:04}-{:02}", year, month);
let mut stmt = self.conn.prepare(
"SELECT date,
COALESCE(SUM(CASE WHEN type='income' THEN amount * exchange_rate ELSE 0 END), 0.0),
COALESCE(SUM(CASE WHEN type='expense' THEN amount * exchange_rate ELSE 0 END), 0.0)
FROM transactions
WHERE date LIKE ?1
GROUP BY date
ORDER BY date DESC"
)?;
let rows = stmt.query_map(params![format!("{}%", prefix)], |row| {
let date_str: String = row.get(0)?;
let date = NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").unwrap_or_default();
let income: f64 = row.get(1)?;
let expense: f64 = row.get(2)?;
Ok((date, income, expense))
})?;
rows.collect()
}
// -- Settings --
pub fn get_setting(&self, key: &str) -> SqlResult<Option<String>> {
match self.conn.query_row(
"SELECT value FROM settings WHERE key = ?1",
params![key],
|row| row.get(0),
) {
Ok(val) => Ok(Some(val)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e),
}
}
pub fn set_setting(&self, key: &str, value: &str) -> SqlResult<()> {
self.conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
params![key, value],
)?;
Ok(())
}
fn seed_default_categories(&self) -> SqlResult<()> { fn seed_default_categories(&self) -> SqlResult<()> {
let expense_categories = [ let expense_categories = [
("Food & Dining", "\u{1f354}", "#e74c3c"), ("Food & Dining", "\u{1f354}", "#e74c3c"),
@@ -200,6 +456,7 @@ impl Database {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use chrono::NaiveDate;
#[test] #[test]
fn test_init_creates_tables() { fn test_init_creates_tables() {
@@ -269,6 +526,163 @@ mod tests {
assert_eq!(missing, 0, "All default categories should have icon and color"); assert_eq!(missing, 0, "All default categories should have icon and color");
} }
fn make_expense(db: &Database) -> i64 {
let cats = db.list_categories(Some(TransactionType::Expense)).unwrap();
let cat = &cats[0];
let txn = NewTransaction {
amount: 12.50,
transaction_type: TransactionType::Expense,
category_id: cat.id,
currency: "USD".to_string(),
exchange_rate: 1.0,
note: Some("Lunch".to_string()),
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
recurring_id: None,
};
db.insert_transaction(&txn).unwrap()
}
fn make_income(db: &Database) -> i64 {
let cats = db.list_categories(Some(TransactionType::Income)).unwrap();
let cat = &cats[0];
let txn = NewTransaction {
amount: 3000.0,
transaction_type: TransactionType::Income,
category_id: cat.id,
currency: "USD".to_string(),
exchange_rate: 1.0,
note: Some("Salary".to_string()),
date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
recurring_id: None,
};
db.insert_transaction(&txn).unwrap()
}
#[test]
fn test_insert_and_get_transaction() {
let db = Database::open_in_memory().unwrap();
let id = make_expense(&db);
let txn = db.get_transaction(id).unwrap();
assert_eq!(txn.amount, 12.50);
assert_eq!(txn.transaction_type, TransactionType::Expense);
assert_eq!(txn.note.as_deref(), Some("Lunch"));
assert_eq!(txn.date, NaiveDate::from_ymd_opt(2026, 3, 1).unwrap());
}
#[test]
fn test_update_transaction() {
let db = Database::open_in_memory().unwrap();
let id = make_expense(&db);
let mut txn = db.get_transaction(id).unwrap();
txn.amount = 15.75;
txn.note = Some("Dinner".to_string());
db.update_transaction(&txn).unwrap();
let updated = db.get_transaction(id).unwrap();
assert_eq!(updated.amount, 15.75);
assert_eq!(updated.note.as_deref(), Some("Dinner"));
}
#[test]
fn test_delete_transaction() {
let db = Database::open_in_memory().unwrap();
let id = make_expense(&db);
db.delete_transaction(id).unwrap();
assert!(db.get_transaction(id).is_err());
}
#[test]
fn test_list_transactions_by_month() {
let db = Database::open_in_memory().unwrap();
make_expense(&db);
make_income(&db);
let march = db.list_transactions_by_month(2026, 3).unwrap();
assert_eq!(march.len(), 2);
let feb = db.list_transactions_by_month(2026, 2).unwrap();
assert_eq!(feb.len(), 0);
}
#[test]
fn test_list_recent_transactions() {
let db = Database::open_in_memory().unwrap();
make_expense(&db);
make_income(&db);
let recent = db.list_recent_transactions(1).unwrap();
assert_eq!(recent.len(), 1);
let all = db.list_recent_transactions(10).unwrap();
assert_eq!(all.len(), 2);
}
#[test]
fn test_list_categories_by_type() {
let db = Database::open_in_memory().unwrap();
let expense = db.list_categories(Some(TransactionType::Expense)).unwrap();
assert_eq!(expense.len(), 14);
let income = db.list_categories(Some(TransactionType::Income)).unwrap();
assert_eq!(income.len(), 6);
let all = db.list_categories(None).unwrap();
assert_eq!(all.len(), 20);
}
#[test]
fn test_insert_custom_category() {
let db = Database::open_in_memory().unwrap();
let cat = NewCategory {
name: "Pets".to_string(),
icon: Some("\u{1f436}".to_string()),
color: Some("#a0522d".to_string()),
transaction_type: TransactionType::Expense,
sort_order: 99,
};
let id = db.insert_category(&cat).unwrap();
let fetched = db.get_category(id).unwrap();
assert_eq!(fetched.name, "Pets");
assert!(!fetched.is_default);
}
#[test]
fn test_monthly_totals_by_category() {
let db = Database::open_in_memory().unwrap();
make_expense(&db);
make_expense(&db);
let totals = db.get_monthly_totals_by_category(2026, 3, TransactionType::Expense).unwrap();
assert_eq!(totals.len(), 1);
assert_eq!(totals[0].1, 25.0); // 12.50 * 2
}
#[test]
fn test_monthly_total() {
let db = Database::open_in_memory().unwrap();
make_expense(&db);
make_income(&db);
let expenses = db.get_monthly_total(2026, 3, TransactionType::Expense).unwrap();
assert_eq!(expenses, 12.50);
let income = db.get_monthly_total(2026, 3, TransactionType::Income).unwrap();
assert_eq!(income, 3000.0);
}
#[test]
fn test_daily_totals() {
let db = Database::open_in_memory().unwrap();
make_expense(&db);
make_income(&db);
let daily = db.get_daily_totals(2026, 3).unwrap();
assert_eq!(daily.len(), 1);
let (date, inc, exp) = &daily[0];
assert_eq!(*date, NaiveDate::from_ymd_opt(2026, 3, 1).unwrap());
assert_eq!(*inc, 3000.0);
assert_eq!(*exp, 12.50);
}
#[test]
fn test_settings_crud() {
let db = Database::open_in_memory().unwrap();
assert_eq!(db.get_setting("base_currency").unwrap(), None);
db.set_setting("base_currency", "EUR").unwrap();
assert_eq!(db.get_setting("base_currency").unwrap(), Some("EUR".to_string()));
db.set_setting("base_currency", "GBP").unwrap();
assert_eq!(db.get_setting("base_currency").unwrap(), Some("GBP".to_string()));
}
#[test] #[test]
fn test_idempotent_init() { fn test_idempotent_init() {
let db = Database::open_in_memory().unwrap(); let db = Database::open_in_memory().unwrap();