diff --git a/src-tauri/src/services/accounts/category/dto.rs b/src-tauri/src/services/accounts/category/dto.rs new file mode 100644 index 0000000..fcacd62 --- /dev/null +++ b/src-tauri/src/services/accounts/category/dto.rs @@ -0,0 +1,141 @@ +use sea_orm::ActiveValue::Set; +use serde::{Deserialize, Serialize}; + +use crate::db::{ + DbServiceError, + entities::account_category::{ + ActiveModel as AccountCategoryActiveModel, Model as AccountCategoryModel, + }, +}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AccountCategoryServiceError { + #[error("database error: {0}")] + DbErr(#[from] DbServiceError), + #[error("Target not found: {0}")] + TargetNotFound(String), + #[error("Validation error: {0}")] + Validation(String), + #[error("unknown error: {0}")] + Unknown(String), +} + +impl From for AccountCategoryServiceError { + fn from(err: sea_orm::DbErr) -> Self { + AccountCategoryServiceError::DbErr(DbServiceError::from(err)) + } +} + +pub struct AccountCategory { + pub id: i64, + pub name: String, + pub account_type: AccountType, + // + pub created_at: String, + pub updated_at: String, +} + +impl From for AccountCategory { + fn from(model: AccountCategoryModel) -> Self { + Self { + id: model.id, + name: model.name, + account_type: AccountType::from(model.account_type), + created_at: model.created_at, + updated_at: model.updated_at, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum AccountType { + #[serde(rename = "Asset")] + Asset, // Positive balance, e.g. Checking, Savings + #[serde(rename = "Liability")] + Liability, // Negative balance, e.g. Credit Card + #[serde(other, rename = "Unknown")] + Unknown, // Fallback for unknown types +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateAccountCategoryRequest { + pub name: String, + pub account_type: AccountType, +} + +impl CreateAccountCategoryRequest { + pub fn validate(&self) -> Result<(), AccountCategoryServiceError> { + if self.name.trim().is_empty() { + return Err(AccountCategoryServiceError::Validation( + "Name cannot be empty".to_string(), + )); + } + if let AccountType::Unknown = self.account_type { + return Err(AccountCategoryServiceError::Validation( + "Invalid account type".to_string(), + )); + } + Ok(()) + } +} + +impl From for AccountCategoryActiveModel { + fn from(request: CreateAccountCategoryRequest) -> Self { + AccountCategoryActiveModel { + name: Set(request.name), + account_type: Set(request.account_type.to_string()), + created_at: Set(chrono::Utc::now().to_rfc3339()), + updated_at: Set(chrono::Utc::now().to_rfc3339()), + ..Default::default() + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateAccountCategoryRequest { + pub name: Option, + pub account_type: Option, +} + +impl UpdateAccountCategoryRequest { + pub fn validate(&self) -> Result<(), AccountCategoryServiceError> { + if let Some(name) = &self.name { + if name.trim().is_empty() { + return Err(AccountCategoryServiceError::Validation( + "Name cannot be empty".to_string(), + )); + } + } + Ok(()) + } + + pub fn apply_to(self, transaction: &mut AccountCategoryActiveModel) { + if let Some(name) = self.name { + transaction.name = Set(name); + } + if let Some(account_type) = self.account_type { + transaction.account_type = Set(account_type.to_string()); + } + } +} + +impl From for AccountType { + fn from(s: String) -> Self { + match s.as_str() { + "Asset" => AccountType::Asset, + "Liability" => AccountType::Liability, + _ => AccountType::Unknown, + } + } +} + +impl std::fmt::Display for AccountType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AccountType::Asset => write!(f, "Asset"), + AccountType::Liability => write!(f, "Liability"), + AccountType::Unknown => write!(f, "Unknown"), + } + } +} diff --git a/src-tauri/src/services/accounts/category/mod.rs b/src-tauri/src/services/accounts/category/mod.rs new file mode 100644 index 0000000..73dad09 --- /dev/null +++ b/src-tauri/src/services/accounts/category/mod.rs @@ -0,0 +1,6 @@ +mod dto; +mod repo; +mod service; + +pub use dto::*; +pub use service::*; diff --git a/src-tauri/src/services/accounts/category/repo.rs b/src-tauri/src/services/accounts/category/repo.rs new file mode 100644 index 0000000..6407592 --- /dev/null +++ b/src-tauri/src/services/accounts/category/repo.rs @@ -0,0 +1,151 @@ +use crate::{ + db::{ + Db, + entities::account_category::{ + ActiveModel as AccountCategoryActiveModel, Entity as AccountCategoryEntity, + }, + }, + services::accounts::category::dto::{ + AccountCategory, AccountCategoryServiceError, CreateAccountCategoryRequest, + UpdateAccountCategoryRequest, + }, +}; +use migration::OnConflict; +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, + IntoActiveModel, ModelTrait, PaginatorTrait, QueryFilter, +}; + +#[async_trait::async_trait] +pub trait AccountCategoryRepoService: Send + Sync + 'static { + async fn get_categories(&self) -> Result, AccountCategoryServiceError>; + async fn create_category( + &self, + create_request: CreateAccountCategoryRequest, + ) -> Result; + + async fn create_category_with_tx( + &self, + create_request: CreateAccountCategoryRequest, + tx: &Db, + ) -> Result; + + async fn update_category( + &self, + id: &i64, + update_request: UpdateAccountCategoryRequest, + ) -> Result<(), AccountCategoryServiceError>; + + async fn update_category_with_tx( + &self, + id: &i64, + update_request: UpdateAccountCategoryRequest, + tx: &Db, + ) -> Result<(), AccountCategoryServiceError>; + + async fn delete_category(&self, id: &i64) -> Result<(), AccountCategoryServiceError>; +} + +pub struct AccountCategoryRepoServiceImpl { + db: DatabaseConnection, +} + +impl AccountCategoryRepoServiceImpl { + pub fn new(db: DatabaseConnection) -> Self { + Self { db } + } +} + +#[async_trait::async_trait] +impl AccountCategoryRepoService for AccountCategoryRepoServiceImpl { + async fn get_categories(&self) -> Result, AccountCategoryServiceError> { + let categories = AccountCategoryEntity::find().all(&self.db).await?; + Ok(categories.into_iter().map(|model| model.into()).collect()) + } + + async fn create_category( + &self, + create_request: CreateAccountCategoryRequest, + ) -> Result { + self.create_category_with_tx(create_request, &(&self.db).into()) + .await + } + + async fn create_category_with_tx( + &self, + create_request: CreateAccountCategoryRequest, + tx: &Db, + ) -> Result { + create_request.validate()?; + let new_category: AccountCategoryActiveModel = create_request.into(); + + let res = AccountCategoryEntity::insert(new_category) + .on_conflict( + // force update to ensure a record is returned + OnConflict::column(crate::db::entities::account_category::Column::Name) + .update_column(crate::db::entities::account_category::Column::Name) + .to_owned(), + ) + .exec_with_returning(tx) + .await?; + Ok(res.id) + } + + async fn delete_category(&self, id: &i64) -> Result<(), AccountCategoryServiceError> { + // check if any accounts are using this category before deleting + let accounts_using_category = crate::db::entities::account::Entity::find() + .filter(crate::db::entities::account::Column::AccountCategoryId.eq(*id)) + .count(&self.db) + .await?; + + if accounts_using_category > 0 { + return Err(AccountCategoryServiceError::Validation( + "Cannot delete category that has accounts".to_string(), + )); + } + + let category = AccountCategoryEntity::find_by_id(*id).one(&self.db).await?; + if let Some(category) = category { + category.delete(&self.db).await?; + Ok(()) + } else { + Err(AccountCategoryServiceError::TargetNotFound( + "Category not found".to_string(), + )) + } + } + + async fn update_category( + &self, + id: &i64, + update_request: UpdateAccountCategoryRequest, + ) -> Result<(), AccountCategoryServiceError> { + self.update_category_with_tx(id, update_request, &(&self.db).into()) + .await + } + + async fn update_category_with_tx( + &self, + id: &i64, + update_request: UpdateAccountCategoryRequest, + tx: &Db, + ) -> Result<(), AccountCategoryServiceError> { + update_request.validate()?; + + let category = AccountCategoryEntity::find_by_id(*id) + .one(&self.db) + .await + .map(|opt| opt.map(|model| model.into_active_model()))?; + + if let Some(mut category) = category { + update_request.apply_to(&mut category); + category.updated_at = Set(chrono::Utc::now().to_rfc3339()); + category.update(tx).await?; + Ok(()) + } else { + Err(AccountCategoryServiceError::TargetNotFound( + "Category not found".to_string(), + )) + } + } +} diff --git a/src-tauri/src/services/accounts/category/service.rs b/src-tauri/src/services/accounts/category/service.rs new file mode 100644 index 0000000..b6e9e89 --- /dev/null +++ b/src-tauri/src/services/accounts/category/service.rs @@ -0,0 +1,107 @@ +use std::sync::Arc; + +use crate::{ + db::Db, + services::accounts::category::{ + dto::{ + AccountCategory, AccountCategoryServiceError, CreateAccountCategoryRequest, + UpdateAccountCategoryRequest, + }, + repo::AccountCategoryRepoService, + }, +}; +use sea_orm::DatabaseConnection; + +#[async_trait::async_trait] +pub trait AccountCategoryService: Send + Sync + 'static { + async fn get_categories(&self) -> Result, AccountCategoryServiceError>; + async fn create_category( + &self, + create_request: CreateAccountCategoryRequest, + ) -> Result; + + async fn create_category_with_tx( + &self, + create_request: CreateAccountCategoryRequest, + tx: &Db, + ) -> Result; + + async fn update_category( + &self, + id: &i64, + update_request: UpdateAccountCategoryRequest, + ) -> Result<(), AccountCategoryServiceError>; + + async fn update_category_with_tx( + &self, + id: &i64, + update_request: UpdateAccountCategoryRequest, + tx: &Db, + ) -> Result<(), AccountCategoryServiceError>; + + async fn delete_category(&self, id: &i64) -> Result<(), AccountCategoryServiceError>; +} + +pub struct AccountCategoryServiceImpl +where + T: super::repo::AccountCategoryRepoService + ?Sized, +{ + db: DatabaseConnection, + repo: Arc, +} + +impl AccountCategoryServiceImpl { + pub fn new(db: DatabaseConnection) -> Self { + Self { + db: db.clone(), + repo: Arc::new(super::repo::AccountCategoryRepoServiceImpl::new(db.clone())), + } + } +} + +#[async_trait::async_trait] +impl AccountCategoryService + for AccountCategoryServiceImpl +{ + async fn get_categories(&self) -> Result, AccountCategoryServiceError> { + self.repo.get_categories().await + } + + async fn create_category( + &self, + create_request: CreateAccountCategoryRequest, + ) -> Result { + self.repo.create_category(create_request).await + } + + async fn create_category_with_tx( + &self, + create_request: CreateAccountCategoryRequest, + tx: &Db, + ) -> Result { + self.repo.create_category_with_tx(create_request, tx).await + } + + async fn delete_category(&self, id: &i64) -> Result<(), AccountCategoryServiceError> { + self.repo.delete_category(id).await + } + + async fn update_category( + &self, + id: &i64, + update_request: UpdateAccountCategoryRequest, + ) -> Result<(), AccountCategoryServiceError> { + self.repo.update_category(id, update_request).await + } + + async fn update_category_with_tx( + &self, + id: &i64, + update_request: UpdateAccountCategoryRequest, + tx: &Db, + ) -> Result<(), AccountCategoryServiceError> { + self.repo + .update_category_with_tx(id, update_request, tx) + .await + } +}