use axum::{ extract::{Path, Query, State}, http::StatusCode, Json, }; use sea_orm::{prelude::Decimal, DatabaseConnection}; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; use crate::models::expense::Model as ExpenseModel; use crate::services::{CategoryService, ExpenseService}; #[derive(Debug, Deserialize, ToSchema)] #[schema(example = json!({"amount": 150.50, "description": "Weekly grocery shopping"}))] pub struct CreateExpenseRequest { pub amount: Decimal, pub description: Option, } #[derive(Debug, Deserialize, ToSchema)] #[schema(example = json!({"amount": 200.00, "description": "Updated expense description"}))] pub struct UpdateExpenseRequest { pub amount: Option, pub description: Option, } #[derive(Debug, Serialize, ToSchema)] #[schema(example = json!({"category_id": 1, "remaining_limit": 4500.00}))] pub struct RemainingLimitResponse { pub category_id: i32, pub remaining_limit: Decimal, } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct MonthlyExpenseGroup { pub year: i32, pub month: u32, pub total_amount: Decimal, pub expenses: Vec, } #[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct ExpenseHistoryResponse { pub months: Vec, } #[derive(Debug, Deserialize, IntoParams)] pub struct HistoryQueryParams { #[serde(default)] pub sort_order: Option, #[serde(default)] pub show_archive: Option, } #[utoipa::path( post, path = "/families/{family_id}/categories/{category_id}/expenses", tag = "expenses", params( ("family_id" = i32, Path, description = "Family ID"), ("category_id" = i32, Path, description = "Category ID") ), request_body = CreateExpenseRequest, responses( (status = 200, description = "Expense created successfully", body = ExpenseModel), (status = 404, description = "Category not found"), (status = 500, description = "Internal server error") ) )] pub async fn create_expense( State(db): State, Path((family_id, category_id)): Path<(i32, i32)>, Json(payload): Json, ) -> Result, StatusCode> { let category = CategoryService::find_by_id(&db, category_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; if category.family_id != family_id { return Err(StatusCode::NOT_FOUND); } ExpenseService::create(&db, category_id, payload.amount, payload.description) .await .map(Json) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } #[utoipa::path( get, path = "/families/{family_id}/categories/{category_id}/expenses/{expense_id}", tag = "expenses", params( ("family_id" = i32, Path, description = "Family ID"), ("category_id" = i32, Path, description = "Category ID"), ("expense_id" = i32, Path, description = "Expense ID") ), responses( (status = 200, description = "Expense found", body = ExpenseModel), (status = 404, description = "Expense not found"), (status = 500, description = "Internal server error") ) )] pub async fn get_expense( State(db): State, Path((family_id, category_id, expense_id)): Path<(i32, i32, i32)>, ) -> Result, StatusCode> { let category = CategoryService::find_by_id(&db, category_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; if category.family_id != family_id { return Err(StatusCode::NOT_FOUND); } let expense = ExpenseService::find_by_id(&db, expense_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; if expense.category_id != category_id { return Err(StatusCode::NOT_FOUND); } Ok(Json(expense)) } #[utoipa::path( get, path = "/families/{family_id}/categories/{category_id}/expenses", tag = "expenses", params( ("family_id" = i32, Path, description = "Family ID"), ("category_id" = i32, Path, description = "Category ID") ), responses( (status = 200, description = "List of expenses for the category", body = Vec), (status = 404, description = "Category not found"), (status = 500, description = "Internal server error") ) )] pub async fn get_expenses_by_category( State(db): State, Path((family_id, category_id)): Path<(i32, i32)>, ) -> Result>, StatusCode> { let category = CategoryService::find_by_id(&db, category_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; if category.family_id != family_id { return Err(StatusCode::NOT_FOUND); } ExpenseService::find_by_category_id(&db, category_id) .await .map(Json) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } #[utoipa::path( put, path = "/families/{family_id}/categories/{category_id}/expenses/{expense_id}", tag = "expenses", params( ("family_id" = i32, Path, description = "Family ID"), ("category_id" = i32, Path, description = "Category ID"), ("expense_id" = i32, Path, description = "Expense ID") ), request_body = UpdateExpenseRequest, responses( (status = 200, description = "Expense updated successfully", body = ExpenseModel), (status = 404, description = "Expense not found"), (status = 500, description = "Internal server error") ) )] pub async fn update_expense( State(db): State, Path((family_id, category_id, expense_id)): Path<(i32, i32, i32)>, Json(payload): Json, ) -> Result, StatusCode> { let category = CategoryService::find_by_id(&db, category_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; if category.family_id != family_id { return Err(StatusCode::NOT_FOUND); } let expense = ExpenseService::find_by_id(&db, expense_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; if expense.category_id != category_id { return Err(StatusCode::NOT_FOUND); } ExpenseService::update(&db, expense_id, payload.amount, payload.description) .await .map(Json) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } #[utoipa::path( get, path = "/families/{family_id}/categories/{category_id}/expenses/history", tag = "expenses", params( ("family_id" = i32, Path, description = "Family ID"), ("category_id" = i32, Path, description = "Category ID"), HistoryQueryParams ), responses( (status = 200, description = "Expense history grouped by month", body = ExpenseHistoryResponse), (status = 404, description = "Category not found"), (status = 500, description = "Internal server error") ) )] pub async fn get_history( State(db): State, Path((family_id, category_id)): Path<(i32, i32)>, Query(params): Query, ) -> Result, StatusCode> { let category = CategoryService::find_by_id(&db, category_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; if category.family_id != family_id { return Err(StatusCode::NOT_FOUND); } let groups = ExpenseService::get_expense_history( &db, category_id, params.sort_order, params.show_archive.unwrap_or(false) ) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let months = groups.into_iter().map(|g| MonthlyExpenseGroup { year: g.year, month: g.month, total_amount: g.total_amount, expenses: g.expenses, }).collect(); Ok(Json(ExpenseHistoryResponse { months })) } #[utoipa::path( delete, path = "/families/{family_id}/categories/{category_id}/expenses/{expense_id}", tag = "expenses", params( ("family_id" = i32, Path, description = "Family ID"), ("category_id" = i32, Path, description = "Category ID"), ("expense_id" = i32, Path, description = "Expense ID") ), responses( (status = 200, description = "Expense deactivated successfully", body = ExpenseModel), (status = 404, description = "Expense not found"), (status = 500, description = "Internal server error") ) )] pub async fn delete_expense( State(db): State, Path((family_id, category_id, expense_id)): Path<(i32, i32, i32)>, ) -> Result, StatusCode> { let category = CategoryService::find_by_id(&db, category_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; if category.family_id != family_id { return Err(StatusCode::NOT_FOUND); } let expense = ExpenseService::find_by_id(&db, expense_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; if expense.category_id != category_id { return Err(StatusCode::NOT_FOUND); } ExpenseService::delete(&db, expense_id) .await .map(Json) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } #[utoipa::path( get, path = "/families/{family_id}/categories/{category_id}/remaining", tag = "expenses", params( ("family_id" = i32, Path, description = "Family ID"), ("category_id" = i32, Path, description = "Category ID") ), responses( (status = 200, description = "Remaining budget limit", body = RemainingLimitResponse), (status = 404, description = "Category not found"), (status = 500, description = "Internal server error") ) )] pub async fn get_remaining_limit( State(db): State, Path((family_id, category_id)): Path<(i32, i32)>, ) -> Result, StatusCode> { let category = CategoryService::find_by_id(&db, category_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::NOT_FOUND)?; if category.family_id != family_id { return Err(StatusCode::NOT_FOUND); } let remaining = ExpenseService::calculate_remaining_limit(&db, category_id) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(RemainingLimitResponse { category_id, remaining_limit: remaining, })) }