try to do better
This commit is contained in:
@@ -50,6 +50,7 @@ pub use middleware::{require_admin, require_family_access};
|
||||
routes::expense::update_expense,
|
||||
routes::expense::delete_expense,
|
||||
routes::expense::get_remaining_limit,
|
||||
routes::expense::get_history,
|
||||
routes::shopping_item::create_shopping_item,
|
||||
routes::shopping_item::get_shopping_items_by_family,
|
||||
routes::shopping_item::get_shopping_item,
|
||||
@@ -87,6 +88,8 @@ pub use middleware::{require_admin, require_family_access};
|
||||
routes::expense::CreateExpenseRequest,
|
||||
routes::expense::UpdateExpenseRequest,
|
||||
routes::expense::RemainingLimitResponse,
|
||||
routes::expense::ExpenseHistoryResponse,
|
||||
routes::expense::MonthlyExpenseGroup,
|
||||
routes::shopping_item::CreateShoppingItemRequest,
|
||||
routes::shopping_item::UpdateShoppingItemRequest,
|
||||
routes::shopping_item::MarkAsPurchasedRequest,
|
||||
@@ -187,6 +190,7 @@ pub async fn create_app(db: DatabaseConnection) -> Result<Router, DbErr> {
|
||||
.route("/families/{family_id}/categories/{category_id}", delete(routes::category::delete_category))
|
||||
.route("/families/{family_id}/categories/{category_id}/expenses", post(routes::expense::create_expense))
|
||||
.route("/families/{family_id}/categories/{category_id}/expenses", get(routes::expense::get_expenses_by_category))
|
||||
.route("/families/{family_id}/categories/{category_id}/expenses/history", get(routes::expense::get_history))
|
||||
.route("/families/{family_id}/categories/{category_id}/expenses/{expense_id}", get(routes::expense::get_expense))
|
||||
.route("/families/{family_id}/categories/{category_id}/expenses/{expense_id}", put(routes::expense::update_expense))
|
||||
.route("/families/{family_id}/categories/{category_id}/expenses/{expense_id}", delete(routes::expense::delete_expense))
|
||||
|
||||
40
backend/src/migration/m20260212_000001_add_expense_active.rs
Normal file
40
backend/src/migration/m20260212_000001_add_expense_active.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Expense::Table)
|
||||
.add_column(
|
||||
ColumnDef::new(Expense::Active)
|
||||
.boolean()
|
||||
.not_null()
|
||||
.default(true)
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
manager
|
||||
.alter_table(
|
||||
Table::alter()
|
||||
.table(Expense::Table)
|
||||
.drop_column(Expense::Active)
|
||||
.to_owned(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(DeriveIden)]
|
||||
enum Expense {
|
||||
Table,
|
||||
Active,
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod m20241215_000001_add_family_password;
|
||||
mod m20241224_000001_create_shopping_items;
|
||||
mod m20250116_000001_add_oauth_fields;
|
||||
mod m20250117_000001_create_invite_links;
|
||||
mod m20260212_000001_add_expense_active;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
@@ -21,6 +22,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20241224_000001_create_shopping_items::Migration),
|
||||
Box::new(m20250116_000001_add_oauth_fields::Migration),
|
||||
Box::new(m20250117_000001_create_invite_links::Migration),
|
||||
Box::new(m20260212_000001_add_expense_active::Migration),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ pub struct Model {
|
||||
pub amount: Decimal,
|
||||
pub description: Option<String>,
|
||||
pub created_at: DateTime,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use sea_orm::{prelude::Decimal, DatabaseConnection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
use crate::models::expense::Model as ExpenseModel;
|
||||
use crate::services::{CategoryService, ExpenseService};
|
||||
|
||||
@@ -31,6 +30,27 @@ pub struct RemainingLimitResponse {
|
||||
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",
|
||||
@@ -183,6 +203,53 @@ pub async fn update_expense(
|
||||
.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,
|
||||
@@ -194,7 +261,7 @@ pub async fn update_expense(
|
||||
("expense_id" = i32, Path, description = "Expense ID")
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "Expense deleted successfully"),
|
||||
(status = 200, description = "Expense deactivated successfully", body = ExpenseModel),
|
||||
(status = 404, description = "Expense not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
)
|
||||
@@ -202,7 +269,7 @@ pub async fn update_expense(
|
||||
pub async fn delete_expense(
|
||||
State(db): State<DatabaseConnection>,
|
||||
Path((family_id, category_id, expense_id)): Path<(i32, i32, i32)>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
) -> Result<Json<ExpenseModel>, StatusCode> {
|
||||
let category = CategoryService::find_by_id(&db, category_id)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
@@ -223,7 +290,7 @@ pub async fn delete_expense(
|
||||
|
||||
ExpenseService::delete(&db, expense_id)
|
||||
.await
|
||||
.map(|_| StatusCode::NO_CONTENT)
|
||||
.map(Json)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
use sea_orm::*;
|
||||
use sea_orm::prelude::Decimal;
|
||||
use chrono::Utc;
|
||||
use chrono::{Utc, Datelike};
|
||||
use std::collections::HashMap;
|
||||
use crate::models::expense::{self, Entity as Expense, Model as ExpenseModel};
|
||||
use crate::models::category::{Entity as Category};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MonthlyExpenseGroup {
|
||||
pub year: i32,
|
||||
pub month: u32,
|
||||
pub total_amount: Decimal,
|
||||
pub expenses: Vec<ExpenseModel>,
|
||||
}
|
||||
|
||||
pub struct ExpenseService;
|
||||
|
||||
impl ExpenseService {
|
||||
@@ -18,6 +27,7 @@ impl ExpenseService {
|
||||
amount: Set(amount),
|
||||
description: Set(description),
|
||||
created_at: Set(Utc::now().naive_utc()),
|
||||
active: Set(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -38,6 +48,28 @@ impl ExpenseService {
|
||||
) -> Result<Vec<ExpenseModel>, DbErr> {
|
||||
Expense::find()
|
||||
.filter(expense::Column::CategoryId.eq(category_id))
|
||||
.filter(expense::Column::Active.eq(true))
|
||||
.all(db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_all_by_category_id(
|
||||
db: &DatabaseConnection,
|
||||
category_id: i32,
|
||||
) -> Result<Vec<ExpenseModel>, DbErr> {
|
||||
Expense::find()
|
||||
.filter(expense::Column::CategoryId.eq(category_id))
|
||||
.all(db)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_inactive_by_category_id(
|
||||
db: &DatabaseConnection,
|
||||
category_id: i32,
|
||||
) -> Result<Vec<ExpenseModel>, DbErr> {
|
||||
Expense::find()
|
||||
.filter(expense::Column::CategoryId.eq(category_id))
|
||||
.filter(expense::Column::Active.eq(false))
|
||||
.all(db)
|
||||
.await
|
||||
}
|
||||
@@ -66,14 +98,15 @@ impl ExpenseService {
|
||||
expense.update(db).await
|
||||
}
|
||||
|
||||
pub async fn delete(db: &DatabaseConnection, id: i32) -> Result<DeleteResult, DbErr> {
|
||||
pub async fn delete(db: &DatabaseConnection, id: i32) -> Result<ExpenseModel, DbErr> {
|
||||
let expense = Expense::find_by_id(id)
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or(DbErr::RecordNotFound("Expense not found".to_string()))?;
|
||||
|
||||
let expense: expense::ActiveModel = expense.into();
|
||||
expense.delete(db).await
|
||||
let mut expense: expense::ActiveModel = expense.into();
|
||||
expense.active = Set(false);
|
||||
expense.update(db).await
|
||||
}
|
||||
|
||||
pub async fn calculate_remaining_limit(
|
||||
@@ -92,4 +125,60 @@ impl ExpenseService {
|
||||
|
||||
Ok(remaining)
|
||||
}
|
||||
|
||||
pub async fn get_expense_history(
|
||||
db: &DatabaseConnection,
|
||||
category_id: i32,
|
||||
sort_order: Option<String>,
|
||||
show_archive: bool,
|
||||
) -> Result<Vec<MonthlyExpenseGroup>, DbErr> {
|
||||
let expenses = if show_archive {
|
||||
Self::find_inactive_by_category_id(db, category_id).await?
|
||||
} else {
|
||||
Self::find_by_category_id(db, category_id).await?
|
||||
};
|
||||
|
||||
let mut grouped: HashMap<(i32, u32), Vec<ExpenseModel>> = HashMap::new();
|
||||
|
||||
for expense in expenses {
|
||||
let year = expense.created_at.year();
|
||||
let month = expense.created_at.month();
|
||||
grouped.entry((year, month))
|
||||
.or_insert_with(Vec::new)
|
||||
.push(expense);
|
||||
}
|
||||
|
||||
let mut result: Vec<MonthlyExpenseGroup> = grouped
|
||||
.into_iter()
|
||||
.map(|((year, month), expenses)| {
|
||||
let total_amount: Decimal = expenses
|
||||
.iter()
|
||||
.filter(|e| e.active)
|
||||
.map(|e| e.amount)
|
||||
.sum();
|
||||
MonthlyExpenseGroup {
|
||||
year,
|
||||
month,
|
||||
total_amount,
|
||||
expenses,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let sort_desc = sort_order
|
||||
.as_deref()
|
||||
.map(|s| s.to_lowercase() == "desc")
|
||||
.unwrap_or(true);
|
||||
|
||||
result.sort_by(|a, b| {
|
||||
let cmp = a.year.cmp(&b.year).then(a.month.cmp(&b.month));
|
||||
if sort_desc {
|
||||
cmp.reverse()
|
||||
} else {
|
||||
cmp
|
||||
}
|
||||
});
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ impl OAuthService {
|
||||
let redirect_url = std::env::var("GOOGLE_REDIRECT_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8080/api/auth/google/callback".to_string());
|
||||
|
||||
let client = Self::getClient(client_id, client_secret, redirect_url);
|
||||
let client = Self::get_client(client_id, client_secret, redirect_url);
|
||||
|
||||
let (auth_url, csrf_token) = client
|
||||
.authorize_url(CsrfToken::new_random)
|
||||
@@ -52,7 +52,7 @@ impl OAuthService {
|
||||
let redirect_url = std::env::var("GOOGLE_REDIRECT_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8080/api/auth/google/callback".to_string());
|
||||
|
||||
let client = Self::getClient(client_id, client_secret, redirect_url);
|
||||
let client = Self::get_client(client_id, client_secret, redirect_url);
|
||||
|
||||
let http_client = oauth2::reqwest::ClientBuilder::new()
|
||||
.build()
|
||||
@@ -67,7 +67,7 @@ impl OAuthService {
|
||||
Ok(token.access_token().secret().clone())
|
||||
}
|
||||
|
||||
fn getClient(client_id: String, client_secret: String, redirect_url: String) -> Client<BasicErrorResponse,
|
||||
fn get_client(client_id: String, client_secret: String, redirect_url: String) -> Client<BasicErrorResponse,
|
||||
BasicTokenResponse,
|
||||
BasicTokenIntrospectionResponse,
|
||||
StandardRevocableToken,
|
||||
|
||||
Reference in New Issue
Block a user