Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backend/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,20 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> {
user_id INTEGER NOT NULL,
label TEXT NOT NULL,
default_amount REAL NOT NULL,
color TEXT NOT NULL DEFAULT '#71717a',
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"#,
)
.execute(pool)
.await?;

let _ = sqlx::query(
"ALTER TABLE budget_categories ADD COLUMN color TEXT NOT NULL DEFAULT '#71717a'",
)
.execute(pool)
.await;

sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS months (
Expand Down
28 changes: 19 additions & 9 deletions backend/src/handlers/budget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub struct CreateCategory {
pub label: String,
#[validate(range(min = 0.0))]
pub default_amount: f64,
pub color: Option<String>,
}

#[derive(Deserialize, ToSchema, Validate)]
Expand All @@ -26,6 +27,7 @@ pub struct UpdateCategory {
pub label: Option<String>,
#[validate(range(min = 0.0))]
pub default_amount: Option<f64>,
pub color: Option<String>,
}

#[derive(Deserialize, ToSchema, Validate)]
Expand All @@ -50,7 +52,7 @@ pub async fn list_categories(
axum::Extension(claims): axum::Extension<Claims>,
) -> Result<Json<Vec<BudgetCategory>>, PaymeError> {
let categories: Vec<BudgetCategory> = sqlx::query_as(
"SELECT id, user_id, label, default_amount FROM budget_categories WHERE user_id = ?",
"SELECT id, user_id, label, default_amount, color FROM budget_categories WHERE user_id = ?",
)
.bind(claims.sub)
.fetch_all(&pool)
Expand All @@ -77,12 +79,14 @@ pub async fn create_category(
Json(payload): Json<CreateCategory>,
) -> Result<Json<BudgetCategory>, PaymeError> {
payload.validate()?;
let color = payload.color.unwrap_or_else(|| "#71717a".to_string());
let id: i64 = sqlx::query_scalar(
"INSERT INTO budget_categories (user_id, label, default_amount) VALUES (?, ?, ?) RETURNING id",
"INSERT INTO budget_categories (user_id, label, default_amount, color) VALUES (?, ?, ?, ?) RETURNING id",
)
.bind(claims.sub)
.bind(&payload.label)
.bind(payload.default_amount)
.bind(&color)
.fetch_one(&pool)
.await?;

Expand All @@ -109,6 +113,7 @@ pub async fn create_category(
user_id: claims.sub,
label: payload.label,
default_amount: payload.default_amount,
color,
}))
}

Expand All @@ -133,7 +138,7 @@ pub async fn update_category(
) -> Result<Json<BudgetCategory>, PaymeError> {
payload.validate()?;
let existing: BudgetCategory = sqlx::query_as(
"SELECT id, user_id, label, default_amount FROM budget_categories WHERE id = ? AND user_id = ?",
"SELECT id, user_id, label, default_amount, color FROM budget_categories WHERE id = ? AND user_id = ?",
)
.bind(category_id)
.bind(claims.sub)
Expand All @@ -143,19 +148,24 @@ pub async fn update_category(

let label = payload.label.unwrap_or(existing.label);
let default_amount = payload.default_amount.unwrap_or(existing.default_amount);
let color = payload.color.unwrap_or(existing.color);

sqlx::query("UPDATE budget_categories SET label = ?, default_amount = ? WHERE id = ?")
.bind(&label)
.bind(default_amount)
.bind(category_id)
.execute(&pool)
.await?;
sqlx::query(
"UPDATE budget_categories SET label = ?, default_amount = ?, color = ? WHERE id = ?",
)
.bind(&label)
.bind(default_amount)
.bind(&color)
.bind(category_id)
.execute(&pool)
.await?;

Ok(Json(BudgetCategory {
id: category_id,
user_id: claims.sub,
label,
default_amount,
color,
}))
}

Expand Down
7 changes: 5 additions & 2 deletions backend/src/handlers/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub struct FixedExpenseExport {
pub struct CategoryExport {
pub label: String,
pub default_amount: f64,
pub color: String,
}

#[derive(Serialize, Deserialize, ToSchema)]
Expand Down Expand Up @@ -95,7 +96,7 @@ pub async fn export_json(
.await?;

let categories: Vec<BudgetCategory> = sqlx::query_as(
"SELECT id, user_id, label, default_amount FROM budget_categories WHERE user_id = ?",
"SELECT id, user_id, label, default_amount, color FROM budget_categories WHERE user_id = ?",
)
.bind(claims.sub)
.fetch_all(&pool)
Expand Down Expand Up @@ -188,6 +189,7 @@ pub async fn export_json(
.map(|c| CategoryExport {
label: c.label,
default_amount: c.default_amount,
color: c.color,
})
.collect(),
months: month_exports,
Expand Down Expand Up @@ -278,11 +280,12 @@ pub async fn import_json(
let mut category_map: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
for cat in &data.categories {
let id: i64 = sqlx::query_scalar(
"INSERT INTO budget_categories (user_id, label, default_amount) VALUES (?, ?, ?) RETURNING id",
"INSERT INTO budget_categories (user_id, label, default_amount, color) VALUES (?, ?, ?, ?) RETURNING id",
)
.bind(claims.sub)
.bind(&cat.label)
.bind(cat.default_amount)
.bind(&cat.color)
.fetch_one(&mut *tx)
.await?;
category_map.insert(cat.label.clone(), id);
Expand Down
2 changes: 1 addition & 1 deletion backend/src/handlers/items.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ pub async fn list_items(

let items: Vec<ItemWithCategory> = sqlx::query_as(
r#"
SELECT i.id, i.month_id, i.category_id, bc.label as category_label, i.description, i.amount, i.spent_on, i.savings_destination
SELECT i.id, i.month_id, i.category_id, bc.label as category_label, bc.color as category_color, i.description, i.amount, i.spent_on, i.savings_destination
FROM items i
JOIN budget_categories bc ON i.category_id = bc.id
WHERE i.month_id = ?
Expand Down
9 changes: 5 additions & 4 deletions backend/src/handlers/months.rs
Original file line number Diff line number Diff line change
Expand Up @@ -334,9 +334,9 @@ async fn get_month_summary(
.await?;

let budgets: Vec<MonthlyBudgetWithCategory> =
sqlx::query_as::<_, (i64, i64, i64, String, f64)>(
sqlx::query_as::<_, (i64, i64, i64, String, String, f64)>(
r#"
SELECT mb.id, mb.month_id, mb.category_id, bc.label, mb.allocated_amount
SELECT mb.id, mb.month_id, mb.category_id, bc.label, bc.color, mb.allocated_amount
FROM monthly_budgets mb
JOIN budget_categories bc ON mb.category_id = bc.id
WHERE mb.month_id = ?
Expand All @@ -347,12 +347,13 @@ async fn get_month_summary(
.await?
.into_iter()
.map(
|(id, month_id, category_id, category_label, allocated_amount)| {
|(id, month_id, category_id, category_label, category_color, allocated_amount)| {
MonthlyBudgetWithCategory {
id,
month_id,
category_id,
category_label,
category_color,
allocated_amount,
spent_amount: 0.0,
}
Expand All @@ -362,7 +363,7 @@ async fn get_month_summary(

let items: Vec<ItemWithCategory> = sqlx::query_as(
r#"
SELECT i.id, i.month_id, i.category_id, bc.label as category_label, i.description, i.amount, i.spent_on, i.savings_destination
SELECT i.id, i.month_id, i.category_id, bc.label as category_label, bc.color as category_color, i.description, i.amount, i.spent_on, i.savings_destination
FROM items i
JOIN budget_categories bc ON i.category_id = bc.id
WHERE i.month_id = ?
Expand Down
7 changes: 4 additions & 3 deletions backend/src/handlers/stats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,13 @@ pub async fn get_stats(
let current_month_id = months[0].0;
let previous_month_id = months.get(1).map(|m| m.0);

let categories: Vec<(i64, String)> =
sqlx::query_as("SELECT id, label FROM budget_categories WHERE user_id = ?")
let categories: Vec<(i64, String, String)> =
sqlx::query_as("SELECT id, label, color FROM budget_categories WHERE user_id = ?")
.bind(claims.sub)
.fetch_all(&pool)
.await?;

for (cat_id, cat_label) in categories {
for (cat_id, cat_label, cat_color) in categories {
let current_spent: (f64,) = sqlx::query_as(
"SELECT COALESCE(SUM(amount), 0.0) FROM items WHERE month_id = ? AND category_id = ? AND savings_destination = 'none'",
)
Expand Down Expand Up @@ -130,6 +130,7 @@ pub async fn get_stats(
category_comparisons.push(CategoryStats {
category_id: cat_id,
category_label: cat_label,
category_color: cat_color,
current_month_spent: current_spent.0,
previous_month_spent: previous_spent,
change_amount,
Expand Down
4 changes: 4 additions & 0 deletions backend/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pub struct BudgetCategory {
pub user_id: i64,
pub label: String,
pub default_amount: f64,
pub color: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, ToSchema)]
Expand Down Expand Up @@ -78,6 +79,7 @@ pub struct MonthlyBudgetWithCategory {
pub month_id: i64,
pub category_id: i64,
pub category_label: String,
pub category_color: String,
pub allocated_amount: f64,
pub spent_amount: f64,
}
Expand All @@ -103,6 +105,7 @@ pub struct ItemWithCategory {
pub month_id: i64,
pub category_id: i64,
pub category_label: String,
pub category_color: String,
pub description: String,
pub amount: f64,
pub spent_on: NaiveDate,
Expand All @@ -113,6 +116,7 @@ pub struct ItemWithCategory {
pub struct CategoryStats {
pub category_id: i64,
pub category_label: String,
pub category_color: String,
pub current_month_spent: f64,
pub previous_month_spent: f64,
pub change_amount: f64,
Expand Down
2 changes: 2 additions & 0 deletions backend/src/pdf/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ mod tests {
month_id: 1,
category_id: 1,
category_label: "Food".to_string(),
category_color: "#71717a".to_string(),
allocated_amount: 500.0,
spent_amount: 300.0,
}],
Expand All @@ -154,6 +155,7 @@ mod tests {
month_id: 1,
category_id: 1,
category_label: "Food".to_string(),
category_color: "#71717a".to_string(),
description: "Groceries".to_string(),
amount: 150.0,
spent_on: NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
Expand Down
4 changes: 3 additions & 1 deletion backend/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ async fn run_migrations(pool: &SqlitePool) {
user_id INTEGER NOT NULL,
label TEXT NOT NULL,
default_amount REAL NOT NULL,
color TEXT NOT NULL DEFAULT '#71717a',
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"#,
Expand Down Expand Up @@ -260,11 +261,12 @@ pub async fn create_test_category(
default_amount: f64,
) -> i64 {
sqlx::query_scalar::<_, i64>(
"INSERT INTO budget_categories (user_id, label, default_amount) VALUES (?, ?, ?) RETURNING id",
"INSERT INTO budget_categories (user_id, label, default_amount, color) VALUES (?, ?, ?, ?) RETURNING id",
)
.bind(user_id)
.bind(label)
.bind(default_amount)
.bind("#71717a")
.fetch_one(pool)
.await
.expect("Failed to create test category")
Expand Down
6 changes: 3 additions & 3 deletions backend/tests/export_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ async fn test_import_json() {
{"label": "Internet", "amount": 80.0}
],
"categories": [
{"label": "Food", "default_amount": 500.0},
{"label": "Transport", "default_amount": 200.0}
{"label": "Food", "default_amount": 500.0, "color": "#ef4444"},
{"label": "Transport", "default_amount": 200.0, "color": "#3b82f6"}
],
"months": [
{
Expand Down Expand Up @@ -162,7 +162,7 @@ async fn test_import_json_replaces_existing() {
{"label": "New Expense", "amount": 200.0}
],
"categories": [
{"label": "New Category", "default_amount": 300.0}
{"label": "New Category", "default_amount": 300.0, "color": "#71717a"}
],
"months": []
});
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,12 @@ export const api = {

categories: {
list: () => request<BudgetCategory[]>("/categories"),
create: (data: { label: string; default_amount: number }) =>
create: (data: { label: string; default_amount: number; color?: string }) =>
request<BudgetCategory>("/categories", {
method: "POST",
body: JSON.stringify(data),
}),
update: (id: number, data: { label?: string; default_amount?: number }) =>
update: (id: number, data: { label?: string; default_amount?: number; color?: string }) =>
request<BudgetCategory>(`/categories/${id}`, {
method: "PUT",
body: JSON.stringify(data),
Expand Down Expand Up @@ -282,6 +282,7 @@ export interface BudgetCategory {
user_id: number;
label: string;
default_amount: number;
color: string;
}

export interface MonthlyBudget {
Expand All @@ -296,6 +297,7 @@ export interface MonthlyBudgetWithCategory {
month_id: number;
category_id: number;
category_label: string;
category_color: string;
allocated_amount: number;
spent_amount: number;
}
Expand All @@ -319,6 +321,7 @@ export interface Item {

export interface ItemWithCategory extends Item {
category_label: string;
category_color: string;
}

export interface MonthlySavings {
Expand Down Expand Up @@ -346,6 +349,7 @@ export interface MonthSummary {
export interface CategoryStats {
category_id: number;
category_label: string;
category_color: string;
current_month_spent: number;
previous_month_spent: number;
change_amount: number;
Expand Down
Loading