-
Notifications
You must be signed in to change notification settings - Fork 2
Implement budget tracking data structures and database functions #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -6,7 +6,8 @@ use duckdb::{params, Connection}; | |||||||||||||||
| use std::sync::{Arc, Mutex}; | ||||||||||||||||
|
|
||||||||||||||||
| use crate::cloud::{ | ||||||||||||||||
| CloudAccount, CloudProvider, CostData, CostSummary, CostTrend, DailyCost, ServiceCost, | ||||||||||||||||
| BudgetInfo, BudgetStatus, CloudAccount, CloudProvider, CostData, CostSummary, CostTrend, | ||||||||||||||||
| DailyCost, ServiceCost, | ||||||||||||||||
| }; | ||||||||||||||||
| use crate::config::get_database_path; | ||||||||||||||||
| use crate::crypto::get_crypto_manager; | ||||||||||||||||
|
|
@@ -97,6 +98,22 @@ pub fn init_database() -> Result<()> { | |||||||||||||||
| [], | ||||||||||||||||
| )?; | ||||||||||||||||
|
|
||||||||||||||||
| // Create budgets table | ||||||||||||||||
| conn.execute( | ||||||||||||||||
| r#" | ||||||||||||||||
| CREATE TABLE IF NOT EXISTS budgets ( | ||||||||||||||||
| account_id VARCHAR PRIMARY KEY, | ||||||||||||||||
| monthly_budget DOUBLE NOT NULL, | ||||||||||||||||
| currency VARCHAR NOT NULL, | ||||||||||||||||
| alert_threshold DOUBLE NOT NULL DEFAULT 80.0, | ||||||||||||||||
| created_at VARCHAR NOT NULL, | ||||||||||||||||
| updated_at VARCHAR NOT NULL, | ||||||||||||||||
| FOREIGN KEY (account_id) REFERENCES cloud_accounts(id) | ||||||||||||||||
| ) | ||||||||||||||||
| "#, | ||||||||||||||||
| [], | ||||||||||||||||
| )?; | ||||||||||||||||
|
|
||||||||||||||||
| let mut db = DB_CONNECTION.lock().unwrap(); | ||||||||||||||||
| *db = Some(conn); | ||||||||||||||||
|
|
||||||||||||||||
|
|
@@ -604,3 +621,173 @@ pub fn clear_all_cache() -> Result<()> { | |||||||||||||||
| tracing::info!("Cleared all cost cache"); | ||||||||||||||||
| Ok(()) | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // ==================== Budget Functions ==================== | ||||||||||||||||
|
|
||||||||||||||||
| /// Save or update budget for an account | ||||||||||||||||
| pub fn save_budget(budget: &BudgetInfo) -> Result<()> { | ||||||||||||||||
| let db = get_connection()?; | ||||||||||||||||
| let conn = db.as_ref().unwrap(); | ||||||||||||||||
|
|
||||||||||||||||
| conn.execute( | ||||||||||||||||
| r#" | ||||||||||||||||
| INSERT OR REPLACE INTO budgets | ||||||||||||||||
| (account_id, monthly_budget, currency, alert_threshold, created_at, updated_at) | ||||||||||||||||
| VALUES (?, ?, ?, ?, ?, ?) | ||||||||||||||||
| "#, | ||||||||||||||||
| params![ | ||||||||||||||||
| budget.account_id, | ||||||||||||||||
| budget.monthly_budget, | ||||||||||||||||
| budget.currency, | ||||||||||||||||
| budget.alert_threshold, | ||||||||||||||||
| budget.created_at.to_rfc3339(), | ||||||||||||||||
| budget.updated_at.to_rfc3339(), | ||||||||||||||||
| ], | ||||||||||||||||
| )?; | ||||||||||||||||
|
|
||||||||||||||||
| tracing::info!("Saved budget for account {}", budget.account_id); | ||||||||||||||||
| Ok(()) | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| /// Get budget for an account | ||||||||||||||||
| pub fn get_budget(account_id: &str) -> Result<Option<BudgetInfo>> { | ||||||||||||||||
| let db = get_connection()?; | ||||||||||||||||
| let conn = db.as_ref().unwrap(); | ||||||||||||||||
|
|
||||||||||||||||
| let mut stmt = conn.prepare( | ||||||||||||||||
| "SELECT account_id, monthly_budget, currency, alert_threshold, created_at, updated_at | ||||||||||||||||
| FROM budgets WHERE account_id = ?", | ||||||||||||||||
| )?; | ||||||||||||||||
|
|
||||||||||||||||
| let result = stmt.query_row(params![account_id], |row| { | ||||||||||||||||
| let created_at_str: String = row.get(4)?; | ||||||||||||||||
| let updated_at_str: String = row.get(5)?; | ||||||||||||||||
|
|
||||||||||||||||
| let created_at = DateTime::parse_from_rfc3339(&created_at_str) | ||||||||||||||||
| .map(|dt| dt.with_timezone(&Utc)) | ||||||||||||||||
| .unwrap_or_else(|_| Utc::now()); | ||||||||||||||||
| let updated_at = DateTime::parse_from_rfc3339(&updated_at_str) | ||||||||||||||||
| .map(|dt| dt.with_timezone(&Utc)) | ||||||||||||||||
| .unwrap_or_else(|_| Utc::now()); | ||||||||||||||||
|
|
||||||||||||||||
| Ok(BudgetInfo { | ||||||||||||||||
| account_id: row.get(0)?, | ||||||||||||||||
| monthly_budget: row.get(1)?, | ||||||||||||||||
| currency: row.get(2)?, | ||||||||||||||||
| alert_threshold: row.get(3)?, | ||||||||||||||||
| created_at, | ||||||||||||||||
| updated_at, | ||||||||||||||||
| }) | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| match result { | ||||||||||||||||
| Ok(budget) => Ok(Some(budget)), | ||||||||||||||||
| Err(duckdb::Error::QueryReturnedNoRows) => Ok(None), | ||||||||||||||||
| Err(e) => Err(anyhow::anyhow!("Failed to get budget: {}", e)), | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| /// Get all budgets | ||||||||||||||||
| pub fn get_all_budgets() -> Result<Vec<BudgetInfo>> { | ||||||||||||||||
| let db = get_connection()?; | ||||||||||||||||
| let conn = db.as_ref().unwrap(); | ||||||||||||||||
|
|
||||||||||||||||
| let mut stmt = conn.prepare( | ||||||||||||||||
| "SELECT account_id, monthly_budget, currency, alert_threshold, created_at, updated_at | ||||||||||||||||
| FROM budgets", | ||||||||||||||||
| )?; | ||||||||||||||||
|
|
||||||||||||||||
| let budgets = stmt | ||||||||||||||||
| .query_map([], |row| { | ||||||||||||||||
| let created_at_str: String = row.get(4)?; | ||||||||||||||||
| let updated_at_str: String = row.get(5)?; | ||||||||||||||||
|
|
||||||||||||||||
| let created_at = DateTime::parse_from_rfc3339(&created_at_str) | ||||||||||||||||
| .map(|dt| dt.with_timezone(&Utc)) | ||||||||||||||||
| .unwrap_or_else(|_| Utc::now()); | ||||||||||||||||
| let updated_at = DateTime::parse_from_rfc3339(&updated_at_str) | ||||||||||||||||
| .map(|dt| dt.with_timezone(&Utc)) | ||||||||||||||||
| .unwrap_or_else(|_| Utc::now()); | ||||||||||||||||
|
|
||||||||||||||||
| Ok(BudgetInfo { | ||||||||||||||||
| account_id: row.get(0)?, | ||||||||||||||||
| monthly_budget: row.get(1)?, | ||||||||||||||||
| currency: row.get(2)?, | ||||||||||||||||
| alert_threshold: row.get(3)?, | ||||||||||||||||
| created_at, | ||||||||||||||||
| updated_at, | ||||||||||||||||
| }) | ||||||||||||||||
| })? | ||||||||||||||||
| .collect::<Result<Vec<_>, _>>()?; | ||||||||||||||||
|
|
||||||||||||||||
| Ok(budgets) | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| /// Delete budget for an account | ||||||||||||||||
| pub fn delete_budget(account_id: &str) -> Result<()> { | ||||||||||||||||
| let db = get_connection()?; | ||||||||||||||||
| let conn = db.as_ref().unwrap(); | ||||||||||||||||
|
|
||||||||||||||||
| conn.execute("DELETE FROM budgets WHERE account_id = ?", params![account_id])?; | ||||||||||||||||
|
|
||||||||||||||||
| tracing::info!("Deleted budget for account {}", account_id); | ||||||||||||||||
| Ok(()) | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| /// Get budget status (compares budget with current costs) | ||||||||||||||||
| pub fn get_budget_status(account_id: &str) -> Result<Option<BudgetStatus>> { | ||||||||||||||||
| // Get budget | ||||||||||||||||
| let budget = match get_budget(account_id)? { | ||||||||||||||||
| Some(b) => b, | ||||||||||||||||
| None => return Ok(None), | ||||||||||||||||
| }; | ||||||||||||||||
|
|
||||||||||||||||
| // Get account info | ||||||||||||||||
| let accounts = get_all_accounts()?; | ||||||||||||||||
| let account = accounts | ||||||||||||||||
| .iter() | ||||||||||||||||
| .find(|a| a.id == account_id) | ||||||||||||||||
| .ok_or_else(|| anyhow::anyhow!("Account not found"))?; | ||||||||||||||||
|
Comment on lines
+745
to
+750
|
||||||||||||||||
| .ok_or_else(|| anyhow::anyhow!("Account not found"))?; | |
| .ok_or_else(|| { | |
| anyhow::anyhow!( | |
| "Account with id '{}' not found in cloud_accounts while computing budget status", | |
| account_id | |
| ) | |
| })?; |
Copilot
AI
Mar 30, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
get_all_budget_statuses() calls get_budget_status() per budget, and get_budget_status() re-queries the budget and re-loads all accounts each time. This creates an N+1 pattern and can result in a lot of redundant DB/keyring work. Consider computing statuses in batch: fetch all accounts once (or build a map), reuse the BudgetInfo already loaded, and then look up cached summaries per account.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new
budgetstable adds a foreign key tocloud_accounts(id), butdelete_account()currently deletes fromcost_dataonly before removing the account. If foreign keys are enforced, deleting an account with an existing budget row will fail. Consider deleting frombudgetsindelete_account()as well (or addingON DELETE CASCADEfor the FK if that matches the intended behavior).