refactor: restructure accounts service by introducing DTOs and repository pattern

This commit is contained in:
GW_MC
2026-05-28 10:27:26 +00:00
parent 50cafa8341
commit 574d082100
6 changed files with 381 additions and 413 deletions

View File

@@ -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<String>,
pub account_type: Option<AccountType>,
}
impl From<String> 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<Vec<AccountCategory>, String>;
async fn create_category(
&self,
create_request: CreateAccountCategoryRequest,
) -> Result<i64, String>;
async fn create_category_with_tx(
&self,
create_request: CreateAccountCategoryRequest,
tx: &Db,
) -> Result<i64, String>;
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<Vec<AccountCategory>, 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<i64, String> {
self.create_category_with_tx(create_request, &(&self.db).into())
.await
}
async fn create_category_with_tx(
&self,
create_request: CreateAccountCategoryRequest,
tx: &Db,
) -> Result<i64, String> {
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())
}
}
}

View File

@@ -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<sea_orm::DbErr> for AccountsServiceError {
fn from(err: sea_orm::DbErr) -> Self {
AccountsServiceError::DbErr(DbServiceError::from(err))
}
}
impl From<AccountCategoryServiceError> 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<String>,
pub category: Option<UpdateAccountCategoryRequest>,
}
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());
}
}
}

View File

@@ -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; pub mod category;
mod dto;
mod repo;
mod service;
#[derive(Debug, Clone, Serialize, Deserialize)] pub use dto::*;
pub struct Account { pub use service::*;
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<String>,
pub category: Option<UpdateAccountCategoryRequest>,
}
#[async_trait::async_trait]
pub trait AccountsService: Send + Sync + 'static {
async fn get_accounts(&self) -> Result<Vec<Account>, String>;
async fn get_account(&self, id: &i64) -> Result<Option<Account>, String>;
async fn create_account(&self, create_request: CreateAccountRequest) -> Result<i64, String>;
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<dyn category::AccountCategoryService>,
}
impl AccountsServiceImpl {
pub fn new(
db: DatabaseConnection,
account_category_service: Arc<dyn category::AccountCategoryService>,
) -> Self {
Self {
db,
account_category_service,
}
}
}
#[async_trait::async_trait]
impl AccountsService for AccountsServiceImpl {
async fn get_accounts(&self) -> Result<Vec<Account>, 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<Option<Account>, 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<i64, String> {
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)),
}
}
}

View File

@@ -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<Vec<Account>, AccountsServiceError>;
async fn get_account(&self, id: &i64) -> Result<Option<Account>, AccountsServiceError>;
async fn create_account(
&self,
create_request: CreateAccountRequest,
) -> Result<i64, AccountsServiceError>;
async fn update_account(
&self,
id: &i64,
update_request: UpdateAccountRequest,
) -> Result<(), AccountsServiceError>;
async fn delete_account(&self, id: &i64) -> Result<(), AccountsServiceError>;
}
pub struct AccountsRepoServiceImpl<T>
where
T: super::category::AccountCategoryService + ?Sized,
{
db: DatabaseConnection,
account_category_service: Arc<T>,
}
impl<T> AccountsRepoServiceImpl<T>
where
T: super::category::AccountCategoryService + ?Sized,
{
pub fn new(db: DatabaseConnection, account_category_service: Arc<T>) -> Self {
Self {
db,
account_category_service,
}
}
}
#[async_trait::async_trait]
impl<T> AccountsRepoService for AccountsRepoServiceImpl<T>
where
T: super::category::AccountCategoryService + ?Sized,
{
async fn get_accounts(&self) -> Result<Vec<Account>, 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<Option<Account>, 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<i64, AccountsServiceError> {
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(())
}
}

View File

@@ -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<Vec<Account>, AccountsServiceError>;
async fn get_account(&self, id: &i64) -> Result<Option<Account>, AccountsServiceError>;
async fn create_account(
&self,
create_request: CreateAccountRequest,
) -> Result<i64, AccountsServiceError>;
async fn update_account(
&self,
id: &i64,
update_request: UpdateAccountRequest,
) -> Result<(), AccountsServiceError>;
async fn delete_account(&self, id: &i64) -> Result<(), AccountsServiceError>;
}
pub struct AccountsServiceImpl<T>
where
T: super::repo::AccountsRepoService + ?Sized,
{
db: DatabaseConnection,
repo: Arc<T>,
}
impl<T> AccountsServiceImpl<super::repo::AccountsRepoServiceImpl<T>>
where
T: super::category::AccountCategoryService + ?Sized,
{
pub fn new(db: DatabaseConnection, account_category_service: Arc<T>) -> Self {
Self {
db: db.clone(),
repo: Arc::new(super::repo::AccountsRepoServiceImpl::new(
db.clone(),
account_category_service,
)),
}
}
}
#[async_trait::async_trait]
impl<T> AccountsService for AccountsServiceImpl<T>
where
T: super::repo::AccountsRepoService + ?Sized,
{
async fn get_accounts(&self) -> Result<Vec<Account>, AccountsServiceError> {
self.repo.get_accounts().await
}
async fn get_account(&self, id: &i64) -> Result<Option<Account>, AccountsServiceError> {
self.repo.get_account(id).await
}
async fn create_account(
&self,
create_request: CreateAccountRequest,
) -> Result<i64, AccountsServiceError> {
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
}
}

View File

@@ -7,7 +7,10 @@ use crate::{
DbServiceError, DbServiceError,
entities::{account::Column::Id, transaction::Model as TransactionModel}, 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::{ use crate::db::{
@@ -61,6 +64,19 @@ impl From<CategoryServiceError> for TransactionServiceError {
} }
} }
impl From<AccountsServiceError> 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum TransactionType { pub enum TransactionType {
#[serde(rename = "STANDARD")] #[serde(rename = "STANDARD")]
@@ -158,12 +174,7 @@ async fn validate_account_exists<T>(
where where
T: crate::services::accounts::AccountsService + ?Sized, T: crate::services::accounts::AccountsService + ?Sized,
{ {
let account_exists = account_service let account_exists = account_service.get_account(&account_id).await?.is_some();
.get_account(&account_id)
.await
// TODO: add error from account service to error chain
.map_err(|err| TransactionServiceError::Unknown(err))?
.is_some();
if !account_exists { if !account_exists {
return Err(TransactionServiceError::Validation(format!( return Err(TransactionServiceError::Validation(format!(
"Account with id {} does not exist", "Account with id {} does not exist",