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
29 changes: 29 additions & 0 deletions backend/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,35 @@ pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::Error> {
.execute(pool)
.await?;

sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS custom_savings_goals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
current_amount REAL NOT NULL DEFAULT 0,
target_amount REAL NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"#,
)
.execute(pool)
.await?;

sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS retirement_breakdown_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
label TEXT NOT NULL,
amount REAL NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"#,
)
.execute(pool)
.await?;

// Migration: Backfill existing months with current fixed expenses and savings
// This ensures existing data is preserved when upgrading
let existing_months: Vec<(i64, i64)> = sqlx::query_as(
Expand Down
2 changes: 2 additions & 0 deletions backend/src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@ pub mod income;
pub mod items;
pub mod monthly_data;
pub mod months;
pub mod retirement_breakdown;
pub mod savings;
pub mod savings_goals;
pub mod stats;
117 changes: 117 additions & 0 deletions backend/src/handlers/retirement_breakdown.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde::Deserialize;
use sqlx::SqlitePool;
use utoipa::ToSchema;
use validator::Validate;

use crate::error::PaymeError;
use crate::middleware::auth::Claims;
use crate::models::RetirementBreakdownItem;

#[derive(Deserialize, ToSchema, Validate)]
pub struct CreateRetirementBreakdownItem {
#[validate(length(min = 1, max = 100))]
pub label: String,
#[validate(range(min = 0.0))]
pub amount: f64,
}

#[derive(Deserialize, ToSchema, Validate)]
pub struct UpdateRetirementBreakdownItem {
#[validate(length(min = 1, max = 100))]
pub label: Option<String>,
#[validate(range(min = 0.0))]
pub amount: Option<f64>,
}

pub async fn list_retirement_breakdown(
State(pool): State<SqlitePool>,
axum::Extension(claims): axum::Extension<Claims>,
) -> Result<Json<Vec<RetirementBreakdownItem>>, PaymeError> {
let items: Vec<RetirementBreakdownItem> = sqlx::query_as(
"SELECT id, user_id, label, amount FROM retirement_breakdown_items WHERE user_id = ? ORDER BY id ASC",
)
.bind(claims.sub)
.fetch_all(&pool)
.await?;

Ok(Json(items))
}

pub async fn create_retirement_breakdown_item(
State(pool): State<SqlitePool>,
axum::Extension(claims): axum::Extension<Claims>,
Json(payload): Json<CreateRetirementBreakdownItem>,
) -> Result<(StatusCode, Json<RetirementBreakdownItem>), PaymeError> {
payload.validate()?;
let id: i64 = sqlx::query_scalar(
"INSERT INTO retirement_breakdown_items (user_id, label, amount) VALUES (?, ?, ?) RETURNING id",
)
.bind(claims.sub)
.bind(&payload.label)
.bind(payload.amount)
.fetch_one(&pool)
.await?;

Ok((
StatusCode::CREATED,
Json(RetirementBreakdownItem {
id,
user_id: claims.sub,
label: payload.label,
amount: payload.amount,
}),
))
}

pub async fn update_retirement_breakdown_item(
State(pool): State<SqlitePool>,
axum::Extension(claims): axum::Extension<Claims>,
Path(item_id): Path<i64>,
Json(payload): Json<UpdateRetirementBreakdownItem>,
) -> Result<Json<RetirementBreakdownItem>, PaymeError> {
payload.validate()?;
let existing: RetirementBreakdownItem = sqlx::query_as(
"SELECT id, user_id, label, amount FROM retirement_breakdown_items WHERE id = ? AND user_id = ?",
)
.bind(item_id)
.bind(claims.sub)
.fetch_optional(&pool)
.await?
.ok_or(PaymeError::NotFound)?;

let label = payload.label.unwrap_or(existing.label);
let amount = payload.amount.unwrap_or(existing.amount);

sqlx::query("UPDATE retirement_breakdown_items SET label = ?, amount = ? WHERE id = ?")
.bind(&label)
.bind(amount)
.bind(item_id)
.execute(&pool)
.await?;

Ok(Json(RetirementBreakdownItem {
id: item_id,
user_id: claims.sub,
label,
amount,
}))
}

pub async fn delete_retirement_breakdown_item(
State(pool): State<SqlitePool>,
axum::Extension(claims): axum::Extension<Claims>,
Path(item_id): Path<i64>,
) -> Result<StatusCode, PaymeError> {
sqlx::query("DELETE FROM retirement_breakdown_items WHERE id = ? AND user_id = ?")
.bind(item_id)
.bind(claims.sub)
.execute(&pool)
.await?;

Ok(StatusCode::NO_CONTENT)
}
129 changes: 129 additions & 0 deletions backend/src/handlers/savings_goals.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use serde::Deserialize;
use sqlx::SqlitePool;
use utoipa::ToSchema;
use validator::Validate;

use crate::error::PaymeError;
use crate::middleware::auth::Claims;
use crate::models::CustomSavingsGoal;

#[derive(Deserialize, ToSchema, Validate)]
pub struct CreateSavingsGoal {
#[validate(length(min = 1, max = 100))]
pub name: String,
#[validate(range(min = 0.0))]
pub current_amount: Option<f64>,
#[validate(range(min = 0.01))]
pub target_amount: f64,
}

#[derive(Deserialize, ToSchema, Validate)]
pub struct UpdateSavingsGoal {
#[validate(length(min = 1, max = 100))]
pub name: Option<String>,
#[validate(range(min = 0.0))]
pub current_amount: Option<f64>,
#[validate(range(min = 0.01))]
pub target_amount: Option<f64>,
}

pub async fn list_savings_goals(
State(pool): State<SqlitePool>,
axum::Extension(claims): axum::Extension<Claims>,
) -> Result<Json<Vec<CustomSavingsGoal>>, PaymeError> {
let goals: Vec<CustomSavingsGoal> = sqlx::query_as(
"SELECT id, user_id, name, current_amount, target_amount FROM custom_savings_goals WHERE user_id = ? ORDER BY id ASC",
)
.bind(claims.sub)
.fetch_all(&pool)
.await?;

Ok(Json(goals))
}

pub async fn create_savings_goal(
State(pool): State<SqlitePool>,
axum::Extension(claims): axum::Extension<Claims>,
Json(payload): Json<CreateSavingsGoal>,
) -> Result<(StatusCode, Json<CustomSavingsGoal>), PaymeError> {
payload.validate()?;
let current_amount = payload.current_amount.unwrap_or(0.0);
let id: i64 = sqlx::query_scalar(
"INSERT INTO custom_savings_goals (user_id, name, current_amount, target_amount) VALUES (?, ?, ?, ?) RETURNING id",
)
.bind(claims.sub)
.bind(&payload.name)
.bind(current_amount)
.bind(payload.target_amount)
.fetch_one(&pool)
.await?;

Ok((
StatusCode::CREATED,
Json(CustomSavingsGoal {
id,
user_id: claims.sub,
name: payload.name,
current_amount,
target_amount: payload.target_amount,
}),
))
}

pub async fn update_savings_goal(
State(pool): State<SqlitePool>,
axum::Extension(claims): axum::Extension<Claims>,
Path(goal_id): Path<i64>,
Json(payload): Json<UpdateSavingsGoal>,
) -> Result<Json<CustomSavingsGoal>, PaymeError> {
payload.validate()?;
let existing: CustomSavingsGoal = sqlx::query_as(
"SELECT id, user_id, name, current_amount, target_amount FROM custom_savings_goals WHERE id = ? AND user_id = ?",
)
.bind(goal_id)
.bind(claims.sub)
.fetch_optional(&pool)
.await?
.ok_or(PaymeError::NotFound)?;

let name = payload.name.unwrap_or(existing.name);
let current_amount = payload.current_amount.unwrap_or(existing.current_amount);
let target_amount = payload.target_amount.unwrap_or(existing.target_amount);

sqlx::query(
"UPDATE custom_savings_goals SET name = ?, current_amount = ?, target_amount = ? WHERE id = ?",
)
.bind(&name)
.bind(current_amount)
.bind(target_amount)
.bind(goal_id)
.execute(&pool)
.await?;

Ok(Json(CustomSavingsGoal {
id: goal_id,
user_id: claims.sub,
name,
current_amount,
target_amount,
}))
}

pub async fn delete_savings_goal(
State(pool): State<SqlitePool>,
axum::Extension(claims): axum::Extension<Claims>,
Path(goal_id): Path<i64>,
) -> Result<StatusCode, PaymeError> {
sqlx::query("DELETE FROM custom_savings_goals WHERE id = ? AND user_id = ?")
.bind(goal_id)
.bind(claims.sub)
.execute(&pool)
.await?;

Ok(StatusCode::NO_CONTENT)
}
33 changes: 31 additions & 2 deletions backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ use sqlx::SqlitePool;
use tower_http::cors::{Any, CorsLayer};

use handlers::{
auth, budget, export, fixed_expenses, health, income, items, monthly_data, months, savings,
stats,
auth, budget, export, fixed_expenses, health, income, items, monthly_data, months,
retirement_breakdown, savings, savings_goals, stats,
};
use middleware::auth::auth_middleware;

Expand Down Expand Up @@ -124,6 +124,35 @@ pub fn create_app(pool: SqlitePool) -> Router {
)
.route("/api/export/json", get(export::export_json))
.route("/api/import/json", post(export::import_json))
.route("/api/savings-goals", get(savings_goals::list_savings_goals))
.route(
"/api/savings-goals",
post(savings_goals::create_savings_goal),
)
.route(
"/api/savings-goals/{id}",
put(savings_goals::update_savings_goal),
)
.route(
"/api/savings-goals/{id}",
delete(savings_goals::delete_savings_goal),
)
.route(
"/api/retirement-breakdown",
get(retirement_breakdown::list_retirement_breakdown),
)
.route(
"/api/retirement-breakdown",
post(retirement_breakdown::create_retirement_breakdown_item),
)
.route(
"/api/retirement-breakdown/{id}",
put(retirement_breakdown::update_retirement_breakdown_item),
)
.route(
"/api/retirement-breakdown/{id}",
delete(retirement_breakdown::delete_retirement_breakdown_item),
)
.layer(from_fn(auth_middleware));

let cors = CorsLayer::new()
Expand Down
17 changes: 17 additions & 0 deletions backend/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,23 @@ pub struct MonthlyStats {
pub net: f64,
}

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, ToSchema)]
pub struct CustomSavingsGoal {
pub id: i64,
pub user_id: i64,
pub name: String,
pub current_amount: f64,
pub target_amount: f64,
}

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, ToSchema)]
pub struct RetirementBreakdownItem {
pub id: i64,
pub user_id: i64,
pub label: String,
pub amount: f64,
}

#[derive(Debug, Serialize, ToSchema)]
pub struct StatsResponse {
pub category_comparisons: Vec<CategoryStats>,
Expand Down
Loading