333 lines
11 KiB
Rust
333 lines
11 KiB
Rust
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<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, ToSchema)]
|
|
#[schema(example = json!({"amount": 200.00, "description": "Updated expense description"}))]
|
|
pub struct UpdateExpenseRequest {
|
|
pub amount: Option<Decimal>,
|
|
pub description: Option<String>,
|
|
}
|
|
|
|
#[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<ExpenseModel>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
|
pub struct ExpenseHistoryResponse {
|
|
pub months: Vec<MonthlyExpenseGroup>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, IntoParams)]
|
|
pub struct HistoryQueryParams {
|
|
#[serde(default)]
|
|
pub sort_order: Option<String>,
|
|
#[serde(default)]
|
|
pub show_archive: Option<bool>,
|
|
}
|
|
|
|
#[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<DatabaseConnection>,
|
|
Path((family_id, category_id)): Path<(i32, i32)>,
|
|
Json(payload): Json<CreateExpenseRequest>,
|
|
) -> Result<Json<ExpenseModel>, 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<DatabaseConnection>,
|
|
Path((family_id, category_id, expense_id)): Path<(i32, i32, i32)>,
|
|
) -> Result<Json<ExpenseModel>, 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<ExpenseModel>),
|
|
(status = 404, description = "Category not found"),
|
|
(status = 500, description = "Internal server error")
|
|
)
|
|
)]
|
|
pub async fn get_expenses_by_category(
|
|
State(db): State<DatabaseConnection>,
|
|
Path((family_id, category_id)): Path<(i32, i32)>,
|
|
) -> Result<Json<Vec<ExpenseModel>>, 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<DatabaseConnection>,
|
|
Path((family_id, category_id, expense_id)): Path<(i32, i32, i32)>,
|
|
Json(payload): Json<UpdateExpenseRequest>,
|
|
) -> Result<Json<ExpenseModel>, 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<DatabaseConnection>,
|
|
Path((family_id, category_id)): Path<(i32, i32)>,
|
|
Query(params): Query<HistoryQueryParams>,
|
|
) -> Result<Json<ExpenseHistoryResponse>, 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<DatabaseConnection>,
|
|
Path((family_id, category_id, expense_id)): Path<(i32, i32, i32)>,
|
|
) -> Result<Json<ExpenseModel>, 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<DatabaseConnection>,
|
|
Path((family_id, category_id)): Path<(i32, i32)>,
|
|
) -> Result<Json<RemainingLimitResponse>, 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,
|
|
}))
|
|
}
|