// TODO: validation for all dto use std::sync::Arc; use crate::{ db::{ DbServiceError, entities::{account::Column::Id, transaction::Model as TransactionModel}, }, services::transaction::{category::CategoryServiceError, tag::TagServiceError}, }; use crate::db::{ Db, entities::transaction::{ActiveModel as TransactionActiveModel, Column::AccountId}, }; use sea_orm::{ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter}; use serde::{Deserialize, Serialize}; use thiserror::Error; #[derive(Error, Debug)] pub enum TransactionServiceError { #[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 TransactionServiceError { fn from(err: sea_orm::DbErr) -> Self { TransactionServiceError::DbErr(DbServiceError::from(err)) } } impl From for TransactionServiceError { fn from(err: TagServiceError) -> Self { match err { TagServiceError::DbErr(db_err) => TransactionServiceError::DbErr(db_err), TagServiceError::TargetNotFound(msg) => TransactionServiceError::TargetNotFound(msg), TagServiceError::Validation(msg) => TransactionServiceError::Validation(msg), TagServiceError::Unknown(msg) => TransactionServiceError::Unknown(msg), } } } impl From for TransactionServiceError { fn from(err: CategoryServiceError) -> Self { match err { CategoryServiceError::DbErr(db_err) => TransactionServiceError::DbErr(db_err), CategoryServiceError::TargetNotFound(msg) => { TransactionServiceError::TargetNotFound(msg) } CategoryServiceError::Validation(msg) => TransactionServiceError::Validation(msg), CategoryServiceError::Unknown(msg) => TransactionServiceError::Unknown(msg), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum TransactionType { #[serde(rename = "STANDARD")] Standard, #[serde(rename = "RECONCILIATION")] Reconciliation, #[serde(rename = "OPENING_BALANCE")] OpeningBalance, #[serde(other, rename = "UNKNOWN")] Unknown, } impl From for TransactionType { fn from(s: String) -> Self { match serde_json::from_str::(&format!("\"{}\"", s)) { Ok(t) => t, Err(_) => TransactionType::Unknown, } } } impl std::fmt::Display for TransactionType { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let s = match self { TransactionType::Standard => "STANDARD", TransactionType::Reconciliation => "RECONCILIATION", TransactionType::OpeningBalance => "OPENING_BALANCE", TransactionType::Unknown => "UNKNOWN", }; write!(f, "{}", s) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Transaction { pub id: i64, pub account_id: i64, pub amount: String, pub transaction_type: TransactionType, pub currency_code: String, pub exchange_rate: Option, pub transaction_date: String, pub metadata: TransactionMetadata, pub created_at: String, pub updated_at: String, pub from_account_id: Option, pub category_id: Option, } impl From for Transaction { fn from(model: TransactionModel) -> Self { Self { id: model.id, account_id: model.account_id, amount: model.amount, transaction_type: model.transaction_type.into(), currency_code: model.currency_code, exchange_rate: model.exchange_rate, transaction_date: model.transaction_date, metadata: TransactionMetadata::from(model.metadata), created_at: model.created_at.to_string(), updated_at: model.updated_at.to_string(), from_account_id: model.from_account_id, category_id: model.category_id, } } } #[derive(Default, Debug, Clone, Deserialize, Serialize)] pub struct TransactionMetadata {} impl From for TransactionMetadata { fn from(s: String) -> Self { serde_json::from_str(&s).unwrap_or_default() } } impl From> for TransactionMetadata { fn from(s: Option) -> Self { s.map(|s| serde_json::from_str(&s).unwrap_or_default()) .unwrap_or_default() } } impl TransactionMetadata { pub fn validate(&self) -> Result<(), TransactionServiceError> { Ok(()) } } async fn validate_account_exists( account_id: i64, account_service: Arc, ) -> Result<(), TransactionServiceError> 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(); if !account_exists { return Err(TransactionServiceError::Validation(format!( "Account with id {} does not exist", account_id ))); } Ok(()) } async fn validate_category_exists( category_id: i64, category_service: Arc, ) -> Result<(), TransactionServiceError> where T: crate::services::CategoryService + ?Sized, { let category_exists = category_service.get_category(category_id).await?.is_some(); if !category_exists { return Err(TransactionServiceError::Validation(format!( "Category with id {} does not exist", category_id ))); } Ok(()) } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateTransactionRequest { pub account_id: i64, pub amount: String, pub transaction_type: TransactionType, pub currency_code: String, pub exchange_rate: Option, pub transaction_date: String, pub metadata: Option, pub from_account_id: Option, pub category_id: Option, } impl CreateTransactionRequest { pub async fn validate( &self, account_service: Arc, category_service: Arc, ) -> Result<(), TransactionServiceError> where T: crate::services::accounts::AccountsService + ?Sized, U: crate::services::CategoryService + ?Sized, { if self.amount.trim().is_empty() { return Err(TransactionServiceError::Validation( "Amount cannot be empty".to_string(), )); } // check if amount is a valid decimal if self.amount.parse::().is_err() { return Err(TransactionServiceError::Validation( "Amount must be a valid decimal".to_string(), )); } if self.currency_code.trim().is_empty() { return Err(TransactionServiceError::Validation( "Currency code cannot be empty".to_string(), )); } if self.transaction_date.trim().is_empty() { return Err(TransactionServiceError::Validation( "Transaction date cannot be empty".to_string(), )); } // check if transaction_date is a valid date if chrono::DateTime::parse_from_rfc3339(&self.transaction_date).is_err() { return Err(TransactionServiceError::Validation( "Transaction date must be a valid RFC3339 date".to_string(), )); } if let Some(metadata) = &self.metadata { metadata.validate()?; } validate_account_exists(self.account_id, account_service).await?; if let Some(category_id) = self.category_id { validate_category_exists(category_id, category_service).await?; } Ok(()) } } impl From for TransactionActiveModel { fn from(request: CreateTransactionRequest) -> Self { Self { account_id: Set(request.account_id), amount: Set(request.amount), transaction_type: Set(request.transaction_type.to_string()), currency_code: Set(request.currency_code), exchange_rate: Set(request.exchange_rate), transaction_date: Set(request.transaction_date), metadata: Set(request .metadata .map(|m| serde_json::to_string(&m).unwrap_or_default())), from_account_id: Set(request.from_account_id), category_id: Set(request.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 UpdateTransactionRequest { pub account_id: Option, pub amount: Option, pub transaction_type: Option, pub currency_code: Option, pub exchange_rate: Option>, pub transaction_date: Option, pub metadata: Option>, pub from_account_id: Option>, pub category_id: Option>, } impl UpdateTransactionRequest { pub fn apply_to(self, transaction: &mut TransactionActiveModel) { if let Some(account_id) = self.account_id { transaction.account_id = Set(account_id); } if let Some(amount) = self.amount { transaction.amount = Set(amount); } if let Some(transaction_type) = self.transaction_type { transaction.transaction_type = Set(transaction_type.to_string()); } if let Some(currency_code) = self.currency_code { transaction.currency_code = Set(currency_code); } if let Some(exchange_rate) = self.exchange_rate { transaction.exchange_rate = Set(exchange_rate); } if let Some(transaction_date) = self.transaction_date { transaction.transaction_date = Set(transaction_date); } if let Some(metadata) = self.metadata { transaction.metadata = Set(metadata.map(|m| serde_json::to_string(&m).unwrap_or_default())); } if let Some(from_account_id) = self.from_account_id { transaction.from_account_id = Set(from_account_id); } if let Some(category_id) = self.category_id { transaction.category_id = Set(category_id); } } pub async fn validate( &self, account_service: Arc, category_service: Arc, ) -> Result<(), TransactionServiceError> where T: crate::services::accounts::AccountsService + ?Sized, U: crate::services::CategoryService + ?Sized, { if let Some(amount) = self.amount.as_ref() && amount.trim().is_empty() { return Err(TransactionServiceError::Validation( "Amount cannot be empty".to_string(), )); } // check if amount is a valid decimal if let Some(amount) = self.amount.as_ref() && amount.parse::().is_err() { return Err(TransactionServiceError::Validation( "Amount must be a valid decimal".to_string(), )); } if let Some(currency_code) = self.currency_code.as_ref() && currency_code.trim().is_empty() { return Err(TransactionServiceError::Validation( "Currency code cannot be empty".to_string(), )); } if let Some(transaction_date) = self.transaction_date.as_ref() && transaction_date.trim().is_empty() { return Err(TransactionServiceError::Validation( "Transaction date cannot be empty".to_string(), )); } // check if transaction_date is a valid date if let Some(transaction_date) = self.transaction_date.as_ref() && chrono::DateTime::parse_from_rfc3339(transaction_date).is_err() { return Err(TransactionServiceError::Validation( "Transaction date must be a valid RFC3339 date".to_string(), )); } if let Some(Some(metadata)) = self.metadata.as_ref() { metadata.validate()?; } if let Some(account_id) = self.account_id { validate_account_exists(account_id, account_service).await?; } if let Some(Some(category_id)) = self.category_id { validate_category_exists(category_id, category_service).await?; } Ok(()) } }