Initial commit

This commit is contained in:
arrelin
2025-12-09 13:23:14 +03:00
commit 9f86d8eeec
26 changed files with 4787 additions and 0 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
target/
.git/
.gitignore
.env
.env.*
!.env.example
*.md
Dockerfile
docker-compose.yml
.dockerignore
.idea/
*.swp
*.swo
*~

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
POSTGRES_USER=your_db_user
POSTGRES_PASSWORD=your_secure_password
POSTGRES_DB=family_budget
POSTGRES_PORT=5435
DATABASE_URL=postgresql://your_db_user:your_secure_password@localhost:5435/family_budget
APP_PORT=8080
RUST_LOG=info

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target
.env
CLAUDE.md

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

11
.idea/family_budget.iml generated Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/family_budget.iml" filepath="$PROJECT_DIR$/.idea/family_budget.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

3472
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

16
Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[package]
name = "family_budget"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { version = "1.48.0", features = ["full"] }
sea-orm = { version = "2.0.0-rc.20", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] }
sea-orm-migration = "2.0.0-rc.20"
dotenvy = "0.15.7"
axum = { version = "0.8.7", features = ["json"] }
chrono = { version = "0.4.42", features = ["serde"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0"
utoipa = { version = "5.4.0", features = ["axum_extras", "chrono", "decimal_float"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"] }

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM rust:1.83 AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src
COPY src ./src
RUN touch src/main.rs
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y \
libssl3 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/family_budget .
ENV RUST_LOG=info
EXPOSE 8080
CMD ["./family_budget"]

45
docker-compose.yml Normal file
View File

@@ -0,0 +1,45 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: family_budget_db
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
ports:
- "${POSTGRES_PORT}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app_network
app:
build:
context: .
dockerfile: Dockerfile
container_name: family_budget_app
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
RUST_LOG: ${RUST_LOG:-info}
ports:
- "${APP_PORT:-8080}:8080"
depends_on:
postgres:
condition: service_healthy
networks:
- app_network
volumes:
postgres_data:
driver: local
networks:
app_network:
driver: bridge

113
src/main.rs Normal file
View File

@@ -0,0 +1,113 @@
use axum::{
routing::{get, post, put, delete},
Router,
};
use sea_orm::{Database, DatabaseConnection, DbErr};
use sea_orm_migration::prelude::*;
use std::net::SocketAddr;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
mod models;
mod services;
mod migration;
mod routes;
#[derive(OpenApi)]
#[openapi(
paths(
routes::family::create_family,
routes::family::get_family,
routes::family::get_all_families,
routes::family::update_family,
routes::family::delete_family,
routes::category::create_category,
routes::category::get_category,
routes::category::get_categories_by_family,
routes::category::update_category,
routes::category::delete_category,
routes::expense::create_expense,
routes::expense::get_expense,
routes::expense::get_expenses_by_category,
routes::expense::update_expense,
routes::expense::delete_expense,
routes::expense::get_remaining_limit,
),
components(
schemas(
models::family::Model,
models::category::Model,
models::expense::Model,
routes::family::CreateFamilyRequest,
routes::family::UpdateFamilyRequest,
routes::category::CreateCategoryRequest,
routes::category::UpdateCategoryRequest,
routes::expense::CreateExpenseRequest,
routes::expense::UpdateExpenseRequest,
routes::expense::RemainingLimitResponse,
)
),
tags(
(name = "families", description = "Family management endpoints"),
(name = "categories", description = "Category management endpoints"),
(name = "expenses", description = "Expense management endpoints")
),
info(
title = "Family Budget API",
version = "0.1.0",
description = "REST API"
)
)]
struct ApiDoc;
async fn establish_connection() -> Result<DatabaseConnection, DbErr> {
dotenvy::dotenv().ok();
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set in .env file");
Database::connect(&database_url).await
}
#[tokio::main]
async fn main() -> Result<(), DbErr> {
let db = establish_connection().await?;
println!("Successfully connected to database!");
println!("Running migrations...");
crate::migration::Migrator::up(&db, None).await?;
println!("Migrations completed!");
let api_routes = Router::new()
.route("/families", post(routes::family::create_family))
.route("/families", get(routes::family::get_all_families))
.route("/families/{id}", get(routes::family::get_family))
.route("/families/{id}", put(routes::family::update_family))
.route("/families/{id}", delete(routes::family::delete_family))
.route("/families/{family_id}/categories", post(routes::category::create_category))
.route("/families/{family_id}/categories", get(routes::category::get_categories_by_family))
.route("/families/{family_id}/categories/{category_id}", get(routes::category::get_category))
.route("/families/{family_id}/categories/{category_id}", put(routes::category::update_category))
.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/{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))
.route("/families/{family_id}/categories/{category_id}/remaining", get(routes::expense::get_remaining_limit))
.with_state(db);
let app = api_routes
.merge(SwaggerUi::new("/swagger-ui")
.url("/api-docs/openapi.json", ApiDoc::openapi()));
let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
println!("Server running on http://{}", addr);
println!("Swagger UI available at http://{}/swagger-ui", addr);
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
Ok(())
}

View File

@@ -0,0 +1,137 @@
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
.create_table(
Table::create()
.table(Family::Table)
.if_not_exists()
.col(
ColumnDef::new(Family::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Family::Name).string().not_null())
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Category::Table)
.if_not_exists()
.col(
ColumnDef::new(Category::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Category::FamilyId).integer().not_null())
.col(ColumnDef::new(Category::Name).string().not_null())
.col(ColumnDef::new(Category::LimitAmount).decimal().not_null())
.col(
ColumnDef::new(Category::CreatedAt)
.timestamp()
.not_null()
.default(Expr::current_timestamp()),
)
.foreign_key(
ForeignKey::create()
.name("fk_category_family")
.from(Category::Table, Category::FamilyId)
.to(Family::Table, Family::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(Expense::Table)
.if_not_exists()
.col(
ColumnDef::new(Expense::Id)
.integer()
.not_null()
.auto_increment()
.primary_key(),
)
.col(ColumnDef::new(Expense::CategoryId).integer().not_null())
.col(ColumnDef::new(Expense::Amount).decimal().not_null())
.col(ColumnDef::new(Expense::Description).string())
.col(
ColumnDef::new(Expense::CreatedAt)
.timestamp()
.not_null()
.default(Expr::current_timestamp()),
)
.foreign_key(
ForeignKey::create()
.name("fk_expense_category")
.from(Expense::Table, Expense::CategoryId)
.to(Category::Table, Category::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(Table::drop().table(Expense::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Category::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(Family::Table).to_owned())
.await?;
Ok(())
}
}
#[derive(DeriveIden)]
enum Family {
Table,
Id,
Name,
}
#[derive(DeriveIden)]
enum Category {
Table,
Id,
FamilyId,
Name,
LimitAmount,
CreatedAt,
}
#[derive(DeriveIden)]
enum Expense {
Table,
Id,
CategoryId,
Amount,
Description,
CreatedAt,
}

12
src/migration/mod.rs Normal file
View File

@@ -0,0 +1,12 @@
pub use sea_orm_migration::prelude::*;
mod m20241209_000001_create_tables;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(m20241209_000001_create_tables::Migration)]
}
}

41
src/models/category.rs Normal file
View File

@@ -0,0 +1,41 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, ToSchema)]
#[sea_orm(table_name = "category")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub family_id: i32,
pub name: String,
pub limit_amount: Decimal,
pub created_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::family::Entity",
from = "Column::FamilyId",
to = "super::family::Column::Id"
)]
Family,
#[sea_orm(has_many = "super::expense::Entity")]
Expense,
}
impl Related<super::family::Entity> for Entity {
fn to() -> RelationDef {
Relation::Family.def()
}
}
impl Related<super::expense::Entity> for Entity {
fn to() -> RelationDef {
Relation::Expense.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

32
src/models/expense.rs Normal file
View File

@@ -0,0 +1,32 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, ToSchema)]
#[sea_orm(table_name = "expense")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub category_id: i32,
pub amount: Decimal,
pub description: Option<String>,
pub created_at: DateTime,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::category::Entity",
from = "Column::CategoryId",
to = "super::category::Column::Id"
)]
Category,
}
impl Related<super::category::Entity> for Entity {
fn to() -> RelationDef {
Relation::Category.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

25
src/models/family.rs Normal file
View File

@@ -0,0 +1,25 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, ToSchema)]
#[sea_orm(table_name = "family")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::category::Entity")]
Category,
}
impl Related<super::category::Entity> for Entity {
fn to() -> RelationDef {
Relation::Category.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

7
src/models/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod family;
pub mod category;
pub mod expense;
pub use family::Entity as Family;
pub use category::Entity as Category;
pub use expense::Entity as Expense;

170
src/routes/category.rs Normal file
View File

@@ -0,0 +1,170 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use sea_orm::{prelude::Decimal, DatabaseConnection};
use serde::Deserialize;
use serde_json::json;
use utoipa::ToSchema;
use crate::models::category::Model as CategoryModel;
use crate::services::CategoryService;
#[derive(Debug, Deserialize, ToSchema)]
#[schema(example = json!({"name": "Groceries", "limit_amount": 5000.00}))]
pub struct CreateCategoryRequest {
pub name: String,
pub limit_amount: Decimal,
}
#[derive(Debug, Deserialize, ToSchema)]
#[schema(example = json!({"name": "Monthly Groceries", "limit_amount": 6000.00}))]
pub struct UpdateCategoryRequest {
pub name: Option<String>,
pub limit_amount: Option<Decimal>,
}
#[utoipa::path(
post,
path = "/families/{family_id}/categories",
tag = "categories",
params(
("family_id" = i32, Path, description = "Family ID")
),
request_body = CreateCategoryRequest,
responses(
(status = 200, description = "Category created successfully", body = CategoryModel),
(status = 500, description = "Internal server error")
)
)]
pub async fn create_category(
State(db): State<DatabaseConnection>,
Path(family_id): Path<i32>,
Json(payload): Json<CreateCategoryRequest>,
) -> Result<Json<CategoryModel>, StatusCode> {
CategoryService::create(&db, family_id, payload.name, payload.limit_amount)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
get,
path = "/families/{family_id}/categories/{category_id}",
tag = "categories",
params(
("family_id" = i32, Path, description = "Family ID"),
("category_id" = i32, Path, description = "Category ID")
),
responses(
(status = 200, description = "Category found", body = CategoryModel),
(status = 404, description = "Category not found"),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_category(
State(db): State<DatabaseConnection>,
Path((family_id, category_id)): Path<(i32, i32)>,
) -> Result<Json<CategoryModel>, 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);
}
Ok(Json(category))
}
#[utoipa::path(
get,
path = "/families/{family_id}/categories",
tag = "categories",
params(
("family_id" = i32, Path, description = "Family ID")
),
responses(
(status = 200, description = "List of categories for the family", body = Vec<CategoryModel>),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_categories_by_family(
State(db): State<DatabaseConnection>,
Path(family_id): Path<i32>,
) -> Result<Json<Vec<CategoryModel>>, StatusCode> {
CategoryService::find_by_family_id(&db, family_id)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
put,
path = "/families/{family_id}/categories/{category_id}",
tag = "categories",
params(
("family_id" = i32, Path, description = "Family ID"),
("category_id" = i32, Path, description = "Category ID")
),
request_body = UpdateCategoryRequest,
responses(
(status = 200, description = "Category updated successfully", body = CategoryModel),
(status = 404, description = "Category not found"),
(status = 500, description = "Internal server error")
)
)]
pub async fn update_category(
State(db): State<DatabaseConnection>,
Path((family_id, category_id)): Path<(i32, i32)>,
Json(payload): Json<UpdateCategoryRequest>,
) -> Result<Json<CategoryModel>, 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);
}
CategoryService::update(&db, category_id, payload.name, payload.limit_amount)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
delete,
path = "/families/{family_id}/categories/{category_id}",
tag = "categories",
params(
("family_id" = i32, Path, description = "Family ID"),
("category_id" = i32, Path, description = "Category ID")
),
responses(
(status = 204, description = "Category deleted successfully"),
(status = 404, description = "Category not found"),
(status = 500, description = "Internal server error")
)
)]
pub async fn delete_category(
State(db): State<DatabaseConnection>,
Path((family_id, category_id)): Path<(i32, i32)>,
) -> Result<StatusCode, 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);
}
CategoryService::delete(&db, category_id)
.await
.map(|_| StatusCode::NO_CONTENT)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}

266
src/routes/expense.rs Normal file
View File

@@ -0,0 +1,266 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use sea_orm::{prelude::Decimal, DatabaseConnection};
use serde::{Deserialize, Serialize};
use serde_json::json;
use utoipa::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,
}
#[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(
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 = 204, description = "Expense deleted successfully"),
(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<StatusCode, 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(|_| StatusCode::NO_CONTENT)
.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,
}))
}

132
src/routes/family.rs Normal file
View File

@@ -0,0 +1,132 @@
use axum::{
extract::{Path, State},
http::StatusCode,
Json,
};
use sea_orm::DatabaseConnection;
use serde::Deserialize;
use serde_json::json;
use utoipa::ToSchema;
use crate::models::family::Model as FamilyModel;
use crate::services::FamilyService;
#[derive(Debug, Deserialize, ToSchema)]
#[schema(example = json!({"name": "Smith Family"}))]
pub struct CreateFamilyRequest {
pub name: String,
}
#[derive(Debug, Deserialize, ToSchema)]
#[schema(example = json!({"name": "Updated Family Name"}))]
pub struct UpdateFamilyRequest {
pub name: String,
}
#[utoipa::path(
post,
path = "/families",
tag = "families",
request_body = CreateFamilyRequest,
responses(
(status = 200, description = "Family created successfully", body = FamilyModel),
(status = 500, description = "Internal server error")
)
)]
pub async fn create_family(
State(db): State<DatabaseConnection>,
Json(payload): Json<CreateFamilyRequest>,
) -> Result<Json<FamilyModel>, StatusCode> {
FamilyService::create(&db, payload.name)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
get,
path = "/families/{id}",
tag = "families",
params(
("id" = i32, Path, description = "Family ID")
),
responses(
(status = 200, description = "Family found", body = FamilyModel),
(status = 404, description = "Family not found"),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_family(
State(db): State<DatabaseConnection>,
Path(id): Path<i32>,
) -> Result<Json<FamilyModel>, StatusCode> {
FamilyService::find_by_id(&db, id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
#[utoipa::path(
get,
path = "/families",
tag = "families",
responses(
(status = 200, description = "List of all families", body = Vec<FamilyModel>),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_all_families(
State(db): State<DatabaseConnection>,
) -> Result<Json<Vec<FamilyModel>>, StatusCode> {
FamilyService::find_all(&db)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
put,
path = "/families/{id}",
tag = "families",
params(
("id" = i32, Path, description = "Family ID")
),
request_body = UpdateFamilyRequest,
responses(
(status = 200, description = "Family updated successfully", body = FamilyModel),
(status = 500, description = "Internal server error")
)
)]
pub async fn update_family(
State(db): State<DatabaseConnection>,
Path(id): Path<i32>,
Json(payload): Json<UpdateFamilyRequest>,
) -> Result<Json<FamilyModel>, StatusCode> {
FamilyService::update(&db, id, payload.name)
.await
.map(Json)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
#[utoipa::path(
delete,
path = "/families/{id}",
tag = "families",
params(
("id" = i32, Path, description = "Family ID")
),
responses(
(status = 204, description = "Family deleted successfully"),
(status = 500, description = "Internal server error")
)
)]
pub async fn delete_family(
State(db): State<DatabaseConnection>,
Path(id): Path<i32>,
) -> Result<StatusCode, StatusCode> {
FamilyService::delete(&db, id)
.await
.map(|_| StatusCode::NO_CONTENT)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}

3
src/routes/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod family;
pub mod category;
pub mod expense;

View File

@@ -0,0 +1,77 @@
use sea_orm::*;
use sea_orm::prelude::Decimal;
use chrono::Utc;
use crate::models::category::{self, Entity as Category, Model as CategoryModel};
pub struct CategoryService;
impl CategoryService {
pub async fn create(
db: &DatabaseConnection,
family_id: i32,
name: String,
limit_amount: Decimal,
) -> Result<CategoryModel, DbErr> {
let category = category::ActiveModel {
family_id: Set(family_id),
name: Set(name),
limit_amount: Set(limit_amount),
created_at: Set(Utc::now().naive_utc()),
..Default::default()
};
category.insert(db).await
}
pub async fn find_by_id(db: &DatabaseConnection, id: i32) -> Result<Option<CategoryModel>, DbErr> {
Category::find_by_id(id).one(db).await
}
pub async fn find_all(db: &DatabaseConnection) -> Result<Vec<CategoryModel>, DbErr> {
Category::find().all(db).await
}
pub async fn find_by_family_id(
db: &DatabaseConnection,
family_id: i32,
) -> Result<Vec<CategoryModel>, DbErr> {
Category::find()
.filter(category::Column::FamilyId.eq(family_id))
.all(db)
.await
}
pub async fn update(
db: &DatabaseConnection,
id: i32,
name: Option<String>,
limit_amount: Option<Decimal>,
) -> Result<CategoryModel, DbErr> {
let category = Category::find_by_id(id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound("Category not found".to_string()))?;
let mut category: category::ActiveModel = category.into();
if let Some(name) = name {
category.name = Set(name);
}
if let Some(limit_amount) = limit_amount {
category.limit_amount = Set(limit_amount);
}
category.update(db).await
}
pub async fn delete(db: &DatabaseConnection, id: i32) -> Result<DeleteResult, DbErr> {
let category = Category::find_by_id(id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound("Category not found".to_string()))?;
let category: category::ActiveModel = category.into();
category.delete(db).await
}
}

View File

@@ -0,0 +1,95 @@
use sea_orm::*;
use sea_orm::prelude::Decimal;
use chrono::Utc;
use crate::models::expense::{self, Entity as Expense, Model as ExpenseModel};
use crate::models::category::{Entity as Category};
pub struct ExpenseService;
impl ExpenseService {
pub async fn create(
db: &DatabaseConnection,
category_id: i32,
amount: Decimal,
description: Option<String>,
) -> Result<ExpenseModel, DbErr> {
let expense = expense::ActiveModel {
category_id: Set(category_id),
amount: Set(amount),
description: Set(description),
created_at: Set(Utc::now().naive_utc()),
..Default::default()
};
expense.insert(db).await
}
pub async fn find_by_id(db: &DatabaseConnection, id: i32) -> Result<Option<ExpenseModel>, DbErr> {
Expense::find_by_id(id).one(db).await
}
pub async fn find_all(db: &DatabaseConnection) -> Result<Vec<ExpenseModel>, DbErr> {
Expense::find().all(db).await
}
pub async fn find_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 update(
db: &DatabaseConnection,
id: i32,
amount: Option<Decimal>,
description: Option<String>,
) -> Result<ExpenseModel, DbErr> {
let expense = Expense::find_by_id(id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound("Expense not found".to_string()))?;
let mut expense: expense::ActiveModel = expense.into();
if let Some(amount) = amount {
expense.amount = Set(amount);
}
if let Some(desc) = description {
expense.description = Set(Some(desc));
}
expense.update(db).await
}
pub async fn delete(db: &DatabaseConnection, id: i32) -> Result<DeleteResult, 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
}
pub async fn calculate_remaining_limit(
db: &DatabaseConnection,
category_id: i32,
) -> Result<Decimal, DbErr> {
let category = Category::find_by_id(category_id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound("Category not found".to_string()))?;
let expenses = Self::find_by_category_id(db, category_id).await?;
let total_spent: Decimal = expenses.iter().map(|e| e.amount).sum();
let remaining = category.limit_amount - total_spent;
Ok(remaining)
}
}

View File

@@ -0,0 +1,49 @@
use sea_orm::*;
use crate::models::family::{self, Entity as Family, Model as FamilyModel};
pub struct FamilyService;
impl FamilyService {
pub async fn create(db: &DatabaseConnection, name: String) -> Result<FamilyModel, DbErr> {
let family = family::ActiveModel {
name: Set(name),
..Default::default()
};
family.insert(db).await
}
pub async fn find_by_id(db: &DatabaseConnection, id: i32) -> Result<Option<FamilyModel>, DbErr> {
Family::find_by_id(id).one(db).await
}
pub async fn find_all(db: &DatabaseConnection) -> Result<Vec<FamilyModel>, DbErr> {
Family::find().all(db).await
}
pub async fn update(
db: &DatabaseConnection,
id: i32,
name: String,
) -> Result<FamilyModel, DbErr> {
let family = Family::find_by_id(id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound("Family not found".to_string()))?;
let mut family: family::ActiveModel = family.into();
family.name = Set(name);
family.update(db).await
}
pub async fn delete(db: &DatabaseConnection, id: i32) -> Result<DeleteResult, DbErr> {
let family = Family::find_by_id(id)
.one(db)
.await?
.ok_or(DbErr::RecordNotFound("Family not found".to_string()))?;
let family: family::ActiveModel = family.into();
family.delete(db).await
}
}

7
src/services/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
pub mod family_service;
pub mod category_service;
pub mod expense_service;
pub use family_service::FamilyService;
pub use category_service::CategoryService;
pub use expense_service::ExpenseService;