diff --git a/src-tauri/src/services/accounts/category.rs b/src-tauri/src/services/accounts/category.rs deleted file mode 100644 index 18bb544..0000000 --- a/src-tauri/src/services/accounts/category.rs +++ /dev/null @@ -1,217 +0,0 @@ -// TODO: validation for all dto -use crate::db::{ - Db, - entities::account_category::{ - ActiveModel as AccountCategoryActiveModel, Entity as AccountCategoryEntity, - }, -}; -use migration::OnConflict; -use sea_orm::{ - ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, - IntoActiveModel, ModelTrait, PaginatorTrait, QueryFilter, -}; -use serde::{Deserialize, Serialize}; - -pub struct AccountCategory { - pub id: i64, - pub name: String, - pub account_type: AccountType, - // - pub created_at: String, - pub updated_at: String, -} - -#[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, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UpdateAccountCategoryRequest { - pub name: Option, - pub account_type: Option, -} - -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"), - } - } -} - -#[async_trait::async_trait] -pub trait AccountCategoryService: Send + Sync + 'static { - async fn get_categories(&self) -> Result, String>; - 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<(), String>; - - async fn update_category_with_tx( - &self, - id: &i64, - update_request: UpdateAccountCategoryRequest, - tx: &Db, - ) -> Result<(), String>; - - async fn delete_category(&self, id: &i64) -> Result<(), String>; -} - -pub struct AccountCategoryServiceImpl { - db: DatabaseConnection, -} - -impl AccountCategoryServiceImpl { - pub fn new(db: DatabaseConnection) -> Self { - Self { db } - } -} - -#[async_trait::async_trait] -impl AccountCategoryService for AccountCategoryServiceImpl { - async fn get_categories(&self) -> Result, String> { - let categories = AccountCategoryEntity::find() - .all(&self.db) - .await - .map_err(|e| e.to_string())?; - Ok(categories - .into_iter() - .map(|model| AccountCategory { - id: model.id, - name: model.name, - account_type: AccountType::from(model.account_type), - created_at: model.created_at, - updated_at: model.updated_at, - }) - .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 { - let new_category = AccountCategoryActiveModel { - name: Set(create_request.name), - account_type: Set(create_request.account_type.to_string()), - created_at: Set(chrono::Utc::now().to_rfc3339()), - updated_at: Set(chrono::Utc::now().to_rfc3339()), - ..Default::default() - }; - 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 - .map_err(|e| e.to_string())?; - Ok(res.id) - } - - async fn delete_category(&self, id: &i64) -> Result<(), String> { - // 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 - .map_err(|e| e.to_string())?; - - if accounts_using_category > 0 { - return Err("Cannot delete category that is in use by accounts".to_string()); - } - - let category = AccountCategoryEntity::find_by_id(*id) - .one(&self.db) - .await - .map_err(|e| e.to_string())?; - if let Some(category) = category { - category.delete(&self.db).await.map_err(|e| e.to_string())?; - Ok(()) - } else { - Err("Category not found".to_string()) - } - } - - async fn update_category( - &self, - id: &i64, - update_request: UpdateAccountCategoryRequest, - ) -> Result<(), String> { - 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<(), String> { - let category = AccountCategoryEntity::find_by_id(*id) - .one(&self.db) - .await - .map_err(|e| e.to_string()) - .map(|opt| opt.map(|model| model.into_active_model()))?; - - if let Some(mut category) = category { - if let Some(name) = update_request.name { - category.name = Set(name); - } - if let Some(account_type) = update_request.account_type { - category.account_type = Set(account_type.to_string()); - } - category.updated_at = Set(chrono::Utc::now().to_rfc3339()); - category.update(tx).await.map_err(|e| e.to_string())?; - Ok(()) - } else { - Err("Category not found".to_string()) - } - } -} diff --git a/src-tauri/src/services/accounts/dto.rs b/src-tauri/src/services/accounts/dto.rs new file mode 100644 index 0000000..344f76c --- /dev/null +++ b/src-tauri/src/services/accounts/dto.rs @@ -0,0 +1,130 @@ +use sea_orm::ActiveValue::Set; +use serde::{Deserialize, Serialize}; + +use crate::{ + db::{ + DbServiceError, + entities::{ + account::{ActiveModel as AccountActiveModel, Model as AccountModel}, + account_category::Model as AccountCategoryModel, + }, + }, + services::accounts::category::{ + AccountCategoryServiceError, AccountType, CreateAccountCategoryRequest, + UpdateAccountCategoryRequest, + }, +}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AccountsServiceError { + #[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 AccountsServiceError { + fn from(err: sea_orm::DbErr) -> Self { + AccountsServiceError::DbErr(DbServiceError::from(err)) + } +} + +impl From for AccountsServiceError { + fn from(err: AccountCategoryServiceError) -> Self { + match err { + AccountCategoryServiceError::DbErr(db_err) => AccountsServiceError::DbErr(db_err), + AccountCategoryServiceError::Validation(msg) => AccountsServiceError::Validation(msg), + AccountCategoryServiceError::TargetNotFound(msg) => { + AccountsServiceError::TargetNotFound(msg) + } + AccountCategoryServiceError::Unknown(msg) => AccountsServiceError::Unknown(msg), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Account { + pub id: i64, + pub name: String, + pub account_type: AccountType, + pub account_category: String, + // + pub created_at: String, + pub updated_at: String, +} + +impl From<(AccountModel, AccountCategoryModel)> for Account { + fn from((account_model, category_model): (AccountModel, AccountCategoryModel)) -> Self { + Account { + id: account_model.id, + name: account_model.name, + account_type: AccountType::from(category_model.account_type), + account_category: category_model.name, + created_at: account_model.created_at, + updated_at: account_model.updated_at, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateAccountRequest { + pub name: String, + pub category: CreateAccountCategoryRequest, +} + +impl CreateAccountRequest { + pub fn validate(&self) -> Result<(), AccountsServiceError> { + if self.name.trim().is_empty() { + return Err(AccountsServiceError::Validation( + "Account name cannot be empty".to_string(), + )); + } + self.category.validate()?; + Ok(()) + } +} + +impl From<(CreateAccountRequest, i64)> for AccountActiveModel { + fn from((request, category_id): (CreateAccountRequest, i64)) -> Self { + AccountActiveModel { + name: Set(request.name), + account_category_id: Set(category_id), + 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 UpdateAccountRequest { + pub name: Option, + pub category: Option, +} + +impl UpdateAccountRequest { + pub fn validate(&self) -> Result<(), AccountsServiceError> { + if let Some(name) = &self.name { + if name.trim().is_empty() { + return Err(AccountsServiceError::Validation( + "Account name cannot be empty".to_string(), + )); + } + } + if let Some(category) = &self.category { + category.validate()?; + } + Ok(()) + } + + pub fn apply_to(&self, account: &mut AccountActiveModel) { + if let Some(name) = &self.name { + account.name = Set(name.clone()); + } + } +} diff --git a/src-tauri/src/services/accounts/mod.rs b/src-tauri/src/services/accounts/mod.rs index 15cb159..4169cfb 100644 --- a/src-tauri/src/services/accounts/mod.rs +++ b/src-tauri/src/services/accounts/mod.rs @@ -1,191 +1,7 @@ -// TODO: validation for all dto -use std::sync::Arc; - -use crate::{ - db::entities::{ - account::{ - ActiveModel as AccountActiveModel, Entity as AccountEntity, Model as AccountModel, - }, - account_category::{ - ActiveModel as AccountCategoryActiveModel, Entity as AccountCategoryEntity, - Model as AccountCategoryModel, - }, - }, - services::accounts::category::{ - AccountType, CreateAccountCategoryRequest, UpdateAccountCategoryRequest, - }, -}; -use sea_orm::{ - ActiveModelTrait, ActiveValue::Set, DatabaseConnection, EntityTrait, TransactionTrait, -}; -use serde::{Deserialize, Serialize}; - pub mod category; +mod dto; +mod repo; +mod service; -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Account { - pub id: i64, - pub name: String, - pub account_type: AccountType, - pub account_category: String, - // - pub created_at: String, - pub updated_at: String, -} - -impl From<(AccountModel, AccountCategoryModel)> for Account { - fn from((account_model, category_model): (AccountModel, AccountCategoryModel)) -> Self { - Account { - id: account_model.id, - name: account_model.name, - account_type: AccountType::from(category_model.account_type), - account_category: category_model.name, - created_at: account_model.created_at, - updated_at: account_model.updated_at, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CreateAccountRequest { - pub name: String, - pub category: CreateAccountCategoryRequest, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UpdateAccountRequest { - pub name: Option, - pub category: Option, -} - -#[async_trait::async_trait] -pub trait AccountsService: Send + Sync + 'static { - async fn get_accounts(&self) -> Result, String>; - async fn get_account(&self, id: &i64) -> Result, String>; - async fn create_account(&self, create_request: CreateAccountRequest) -> Result; - async fn update_account( - &self, - id: &i64, - update_request: UpdateAccountRequest, - ) -> Result<(), String>; - async fn delete_account(&self, id: &i64) -> Result<(), String>; -} - -pub struct AccountsServiceImpl { - db: DatabaseConnection, - account_category_service: Arc, -} - -impl AccountsServiceImpl { - pub fn new( - db: DatabaseConnection, - account_category_service: Arc, - ) -> Self { - Self { - db, - account_category_service, - } - } -} - -#[async_trait::async_trait] -impl AccountsService for AccountsServiceImpl { - async fn get_accounts(&self) -> Result, String> { - let accounts = AccountEntity::find() - .find_both_related(AccountCategoryEntity) - .all(&self.db) - .await; - match accounts { - Ok(accounts) => Ok(accounts.into_iter().map(|a| a.into()).collect()), - Err(e) => Err(format!("Failed to fetch accounts: {}", e)), - } - } - - async fn get_account(&self, id: &i64) -> Result, String> { - let result = AccountEntity::find_by_id(*id) - .find_both_related(AccountCategoryEntity) - .one(&self.db) - .await; - - match result { - Ok(Some(account)) => Ok(Some(account.into())), - Ok(None) => Ok(None), - Err(e) => Err(format!("Failed to fetch account: {}", e)), - } - } - - async fn create_account(&self, create_request: CreateAccountRequest) -> Result { - let tx = self.db.begin().await.map_err(|e| e.to_string())?; - - let category_id = self - .account_category_service - .create_category_with_tx( - CreateAccountCategoryRequest { - name: create_request.category.name, - account_type: create_request.category.account_type, - }, - &(&tx).into(), - ) - .await?; - - let new_account = AccountEntity::insert(AccountActiveModel { - name: Set(create_request.name), - created_at: Set(chrono::Utc::now().to_rfc3339()), - updated_at: Set(chrono::Utc::now().to_rfc3339()), - account_category_id: Set(category_id), - ..Default::default() - }) - .exec(&tx) - .await; - - tx.commit().await.map_err(|e| e.to_string())?; - - match new_account { - Ok(res) => Ok(res.last_insert_id), - Err(e) => Err(format!("Failed to create account: {}", e)), - } - } - - async fn update_account( - &self, - id: &i64, - update_request: UpdateAccountRequest, - ) -> Result<(), String> { - let tx = self.db.begin().await.map_err(|e| e.to_string())?; - let account = AccountEntity::find_by_id(*id).one(&tx).await; - match account { - Ok(Some(account)) => { - let mut active_model: AccountActiveModel = account.into(); - // Only update fields that are provided in the request - if let Some(name) = update_request.name { - active_model.name = Set(name); - } - // - if let Some(category_request) = update_request.category { - // update the category if it is provided in the request - self.account_category_service - .update_category_with_tx(id, category_request, &(&tx).into()) - .await?; - } - active_model.updated_at = Set(chrono::Utc::now().to_rfc3339()); - // - active_model.update(&tx).await.map_err(|e| e.to_string())?; - let res = tx.commit().await.map_err(|e| e.to_string()); - match res { - Ok(_) => Ok(()), - Err(e) => Err(format!("Failed to update account: {}", e)), - } - } - Ok(None) => Err("Account not found".to_string()), - Err(e) => Err(format!("Failed to fetch account: {}", e)), - } - } - - async fn delete_account(&self, id: &i64) -> Result<(), String> { - let res = AccountEntity::delete_by_id(*id).exec(&self.db).await; - match res { - Ok(_) => Ok(()), - Err(e) => Err(format!("Failed to delete account: {}", e)), - } - } -} +pub use dto::*; +pub use service::*; diff --git a/src-tauri/src/services/accounts/repo.rs b/src-tauri/src/services/accounts/repo.rs new file mode 100644 index 0000000..5e27b13 --- /dev/null +++ b/src-tauri/src/services/accounts/repo.rs @@ -0,0 +1,150 @@ +use std::sync::Arc; + +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, DatabaseConnection, EntityTrait, TransactionTrait, +}; + +use crate::{ + db::entities::{ + account::{ActiveModel as AccountActiveModel, Entity as AccountEntity}, + account_category::Entity as AccountCategoryEntity, + }, + services::accounts::{ + category::CreateAccountCategoryRequest, + dto::{Account, AccountsServiceError, CreateAccountRequest, UpdateAccountRequest}, + }, +}; + +#[async_trait::async_trait] +pub trait AccountsRepoService: Send + Sync + 'static { + async fn get_accounts(&self) -> Result, AccountsServiceError>; + async fn get_account(&self, id: &i64) -> Result, AccountsServiceError>; + async fn create_account( + &self, + create_request: CreateAccountRequest, + ) -> Result; + async fn update_account( + &self, + id: &i64, + update_request: UpdateAccountRequest, + ) -> Result<(), AccountsServiceError>; + async fn delete_account(&self, id: &i64) -> Result<(), AccountsServiceError>; +} + +pub struct AccountsRepoServiceImpl +where + T: super::category::AccountCategoryService + ?Sized, +{ + db: DatabaseConnection, + account_category_service: Arc, +} + +impl AccountsRepoServiceImpl +where + T: super::category::AccountCategoryService + ?Sized, +{ + pub fn new(db: DatabaseConnection, account_category_service: Arc) -> Self { + Self { + db, + account_category_service, + } + } +} + +#[async_trait::async_trait] +impl AccountsRepoService for AccountsRepoServiceImpl +where + T: super::category::AccountCategoryService + ?Sized, +{ + async fn get_accounts(&self) -> Result, AccountsServiceError> { + let accounts = AccountEntity::find() + .find_both_related(AccountCategoryEntity) + .all(&self.db) + .await?; + Ok(accounts.into_iter().map(|a| a.into()).collect()) + } + + async fn get_account(&self, id: &i64) -> Result, AccountsServiceError> { + let result = AccountEntity::find_by_id(*id) + .find_both_related(AccountCategoryEntity) + .one(&self.db) + .await; + + match result { + Ok(Some(account)) => Ok(Some(account.into())), + Ok(None) => Ok(None), + Err(e) => Err(e.into()), + } + } + + async fn create_account( + &self, + create_request: CreateAccountRequest, + ) -> Result { + let tx = self.db.begin().await?; + + let category_id = self + .account_category_service + .create_category_with_tx( + CreateAccountCategoryRequest { + name: create_request.category.name, + account_type: create_request.category.account_type, + }, + &(&tx).into(), + ) + .await?; + + let new_account = AccountEntity::insert(AccountActiveModel { + name: Set(create_request.name), + created_at: Set(chrono::Utc::now().to_rfc3339()), + updated_at: Set(chrono::Utc::now().to_rfc3339()), + account_category_id: Set(category_id), + ..Default::default() + }) + .exec(&tx) + .await?; + + tx.commit().await?; + + Ok(new_account.last_insert_id) + } + + async fn update_account( + &self, + id: &i64, + update_request: UpdateAccountRequest, + ) -> Result<(), AccountsServiceError> { + let tx = self.db.begin().await?; + let account = AccountEntity::find_by_id(*id).one(&tx).await; + match account { + Ok(Some(account)) => { + let mut active_model: AccountActiveModel = account.into(); + // Only update fields that are provided in the request + if let Some(name) = update_request.name { + active_model.name = Set(name); + } + // + if let Some(category_request) = update_request.category { + // update the category if it is provided in the request + self.account_category_service + .update_category_with_tx(id, category_request, &(&tx).into()) + .await?; + } + active_model.updated_at = Set(chrono::Utc::now().to_rfc3339()); + // + active_model.update(&tx).await?; + tx.commit().await?; + Ok(()) + } + Ok(None) => Err(AccountsServiceError::TargetNotFound( + "Account not found".to_string(), + )), + Err(e) => Err(e.into()), + } + } + + async fn delete_account(&self, id: &i64) -> Result<(), AccountsServiceError> { + AccountEntity::delete_by_id(*id).exec(&self.db).await?; + Ok(()) + } +} diff --git a/src-tauri/src/services/accounts/service.rs b/src-tauri/src/services/accounts/service.rs new file mode 100644 index 0000000..3981244 --- /dev/null +++ b/src-tauri/src/services/accounts/service.rs @@ -0,0 +1,78 @@ +use std::sync::Arc; + +use crate::services::accounts::{ + Account, AccountsServiceError, CreateAccountRequest, UpdateAccountRequest, +}; +use sea_orm::DatabaseConnection; + +#[async_trait::async_trait] +pub trait AccountsService: Send + Sync + 'static { + async fn get_accounts(&self) -> Result, AccountsServiceError>; + async fn get_account(&self, id: &i64) -> Result, AccountsServiceError>; + async fn create_account( + &self, + create_request: CreateAccountRequest, + ) -> Result; + async fn update_account( + &self, + id: &i64, + update_request: UpdateAccountRequest, + ) -> Result<(), AccountsServiceError>; + async fn delete_account(&self, id: &i64) -> Result<(), AccountsServiceError>; +} + +pub struct AccountsServiceImpl +where + T: super::repo::AccountsRepoService + ?Sized, +{ + db: DatabaseConnection, + repo: Arc, +} + +impl AccountsServiceImpl> +where + T: super::category::AccountCategoryService + ?Sized, +{ + pub fn new(db: DatabaseConnection, account_category_service: Arc) -> Self { + Self { + db: db.clone(), + repo: Arc::new(super::repo::AccountsRepoServiceImpl::new( + db.clone(), + account_category_service, + )), + } + } +} + +#[async_trait::async_trait] +impl AccountsService for AccountsServiceImpl +where + T: super::repo::AccountsRepoService + ?Sized, +{ + async fn get_accounts(&self) -> Result, AccountsServiceError> { + self.repo.get_accounts().await + } + + async fn get_account(&self, id: &i64) -> Result, AccountsServiceError> { + self.repo.get_account(id).await + } + + async fn create_account( + &self, + create_request: CreateAccountRequest, + ) -> Result { + self.repo.create_account(create_request).await + } + + async fn update_account( + &self, + id: &i64, + update_request: UpdateAccountRequest, + ) -> Result<(), AccountsServiceError> { + self.repo.update_account(id, update_request).await + } + + async fn delete_account(&self, id: &i64) -> Result<(), AccountsServiceError> { + self.repo.delete_account(id).await + } +} diff --git a/src-tauri/src/services/transaction/dto.rs b/src-tauri/src/services/transaction/dto.rs index cb6f1ff..8e04d2e 100644 --- a/src-tauri/src/services/transaction/dto.rs +++ b/src-tauri/src/services/transaction/dto.rs @@ -7,7 +7,10 @@ use crate::{ DbServiceError, entities::{account::Column::Id, transaction::Model as TransactionModel}, }, - services::transaction::{category::CategoryServiceError, tag::TagServiceError}, + services::{ + accounts::{Account, AccountsServiceError}, + transaction::{category::CategoryServiceError, tag::TagServiceError}, + }, }; use crate::db::{ @@ -61,6 +64,19 @@ impl From for TransactionServiceError { } } +impl From for TransactionServiceError { + fn from(err: AccountsServiceError) -> Self { + match err { + AccountsServiceError::DbErr(db_err) => TransactionServiceError::DbErr(db_err), + AccountsServiceError::TargetNotFound(msg) => { + TransactionServiceError::TargetNotFound(msg) + } + AccountsServiceError::Validation(msg) => TransactionServiceError::Validation(msg), + AccountsServiceError::Unknown(msg) => TransactionServiceError::Unknown(msg), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub enum TransactionType { #[serde(rename = "STANDARD")] @@ -158,12 +174,7 @@ async fn validate_account_exists( where T: crate::services::accounts::AccountsService + ?Sized, { - let account_exists = account_service - .get_account(&account_id) - .await - // TODO: add error from account service to error chain - .map_err(|err| TransactionServiceError::Unknown(err))? - .is_some(); + let account_exists = account_service.get_account(&account_id).await?.is_some(); if !account_exists { return Err(TransactionServiceError::Validation(format!( "Account with id {} does not exist",