From 79c98658235d81d5ccc5c16ec6ea25c95aaaf8aa Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Thu, 28 May 2026 04:01:51 +0000 Subject: [PATCH] feat: implement transaction service with CRUD operations and validation --- src-tauri/src/services/transaction/dto.rs | 381 ++++++++++++++++++ src-tauri/src/services/transaction/mod.rs | 9 + src-tauri/src/services/transaction/repo.rs | 199 +++++++++ src-tauri/src/services/transaction/service.rs | 115 ++++++ 4 files changed, 704 insertions(+) create mode 100644 src-tauri/src/services/transaction/dto.rs create mode 100644 src-tauri/src/services/transaction/mod.rs create mode 100644 src-tauri/src/services/transaction/repo.rs create mode 100644 src-tauri/src/services/transaction/service.rs diff --git a/src-tauri/src/services/transaction/dto.rs b/src-tauri/src/services/transaction/dto.rs new file mode 100644 index 0000000..cb6f1ff --- /dev/null +++ b/src-tauri/src/services/transaction/dto.rs @@ -0,0 +1,381 @@ +// 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(()) + } +} diff --git a/src-tauri/src/services/transaction/mod.rs b/src-tauri/src/services/transaction/mod.rs new file mode 100644 index 0000000..25c2222 --- /dev/null +++ b/src-tauri/src/services/transaction/mod.rs @@ -0,0 +1,9 @@ +pub mod category; +mod dto; +mod repo; +mod service; +pub mod tag; + +// re-export as a single module +pub use dto::*; +pub use service::*; diff --git a/src-tauri/src/services/transaction/repo.rs b/src-tauri/src/services/transaction/repo.rs new file mode 100644 index 0000000..fb00a41 --- /dev/null +++ b/src-tauri/src/services/transaction/repo.rs @@ -0,0 +1,199 @@ +use std::sync::Arc; + +use crate::{ + db::{ + Db, + entities::transaction::{ + ActiveModel as TransactionActiveModel, Column::AccountId, Entity as TransactionEntity, + }, + }, + services::transaction::{ + CreateTransactionRequest, Transaction, TransactionResult, TransactionServiceError, + UpdateTransactionRequest, category::repo::CategoryRepoServiceImpl, + tag::repo::TagRepoServiceImpl, + }, +}; +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, + TransactionTrait, +}; + +type Result = TransactionResult; + +#[async_trait::async_trait] +pub trait TransactionRepoService: Send + Sync + 'static { + async fn get_transaction(&self, id: i64) -> Result>; + // TODO: pagination + // TODO: filter + // TODO: sort + async fn get_transactions_by_account(&self, account_id: i64) -> Result>; + async fn create_transaction(&self, request: CreateTransactionRequest) -> Result; + async fn create_transaction_with_tx( + &self, + request: CreateTransactionRequest, + tx: &Db, + ) -> Result; + async fn update_transaction(&self, id: i64, request: UpdateTransactionRequest) -> Result<()>; + async fn update_transaction_with_tx( + &self, + id: i64, + request: UpdateTransactionRequest, + tx: &Db, + ) -> Result<()>; + async fn delete_transaction(&self, id: i64) -> Result<()>; +} + +pub struct TransactionRepoServiceImpl +where + T: super::super::accounts::AccountsService + ?Sized, + U: super::category::CategoryService + ?Sized, + K: super::tag::TagService + ?Sized, +{ + db: DatabaseConnection, + account_service: Arc, + category_service: Arc, + tag_service: Arc, +} + +impl TransactionRepoServiceImpl +where + T: super::super::accounts::AccountsService + ?Sized, + U: super::category::CategoryService + ?Sized, + K: super::tag::TagService + ?Sized, +{ + pub fn new( + db: DatabaseConnection, + account_service: Arc, + category_service: Arc, + tag_service: Arc, + ) -> Self { + Self { + db: db.clone(), + account_service, + tag_service, + category_service, + } + } +} + +#[async_trait::async_trait] +impl TransactionRepoService for TransactionRepoServiceImpl +where + T: super::super::accounts::AccountsService + ?Sized, + U: super::category::CategoryService + ?Sized, + K: super::tag::TagService + ?Sized, +{ + async fn get_transaction(&self, id: i64) -> Result> { + let transaction = TransactionEntity::find_by_id(id) + .one(&self.db) + .await? + .map(|model| model.into()); + Ok(transaction) + } + + async fn get_transactions_by_account(&self, account_id: i64) -> Result> { + let transactions = TransactionEntity::find() + .filter(AccountId.eq(account_id)) + .all(&self.db) + .await? + .into_iter() + .map(|model| model.into()) + .collect::>(); + Ok(transactions) + } + + async fn create_transaction(&self, request: CreateTransactionRequest) -> Result { + self.create_transaction_with_tx(request, &(&self.db).into()) + .await + } + + async fn create_transaction_with_tx( + &self, + request: CreateTransactionRequest, + outer_tx: &Db, + ) -> Result { + let inner_tx = match outer_tx { + Db::Connection(conn) => Some(conn.begin().await?), + Db::Transaction(_) => None, + }; + let tx = inner_tx.as_ref().map_or_else( + || match outer_tx { + Db::Connection(conn) => Db::Connection(conn), + Db::Transaction(tx) => Db::Transaction(tx), + }, + Db::Transaction, + ); + + request + .validate(self.account_service.clone(), self.category_service.clone()) + .await?; + + let new_transaction: TransactionActiveModel = request.into(); + let res = new_transaction.insert(&tx).await?; + + if let Some(inner_tx) = inner_tx { + inner_tx.commit().await?; + } + Ok(res.id) + } + + async fn update_transaction(&self, id: i64, request: UpdateTransactionRequest) -> Result<()> { + self.update_transaction_with_tx(id, request, &(&self.db).into()) + .await + } + + async fn update_transaction_with_tx( + &self, + id: i64, + request: UpdateTransactionRequest, + outer_tx: &Db, + ) -> Result<()> { + let inner_tx = match outer_tx { + Db::Connection(conn) => Some(conn.begin().await?), + Db::Transaction(_) => None, + }; + let tx = inner_tx.as_ref().map_or_else( + || match outer_tx { + Db::Connection(conn) => Db::Connection(conn), + Db::Transaction(tx) => Db::Transaction(tx), + }, + Db::Transaction, + ); + // + request + .validate(self.account_service.clone(), self.category_service.clone()) + .await?; + let transaction = TransactionEntity::find_by_id(id) + .one(&tx) + .await? + .ok_or_else(|| { + TransactionServiceError::TargetNotFound(format!( + "Transaction with id {} not found", + id + )) + })?; + let mut transaction: TransactionActiveModel = transaction.into(); + request.apply_to(&mut transaction); + transaction.updated_at = Set(chrono::Utc::now().to_rfc3339()); + + transaction.update(&tx).await?; + // + if let Some(inner_tx) = inner_tx { + inner_tx.commit().await?; + } + Ok(()) + } + + async fn delete_transaction(&self, id: i64) -> Result<()> { + // delete the tag-transaction relations + let tx = self.db.begin().await?; + self.tag_service + .update_tags_for_transaction_with_tx(id, vec![], &(&tx).into()) + .await?; + // delete the transaction itself + TransactionEntity::delete_by_id(id).exec(&tx).await?; + tx.commit().await?; + + Ok(()) + } +} diff --git a/src-tauri/src/services/transaction/service.rs b/src-tauri/src/services/transaction/service.rs new file mode 100644 index 0000000..9f1a469 --- /dev/null +++ b/src-tauri/src/services/transaction/service.rs @@ -0,0 +1,115 @@ +use std::sync::Arc; + +use sea_orm::DatabaseConnection; + +use crate::{ + db::Db, + services::transaction::{ + CreateTransactionRequest, Transaction, TransactionServiceError, UpdateTransactionRequest, + repo::TransactionRepoService, + }, +}; + +pub type TransactionResult = std::result::Result; +type Result = TransactionResult; + +#[async_trait::async_trait] +pub trait TransactionService: Send + Sync + 'static { + async fn get_transaction(&self, id: i64) -> Result>; + // TODO: pagination + // TODO: filter + // TODO: sort + async fn get_transactions_by_account(&self, account_id: i64) -> Result>; + async fn create_transaction(&self, request: CreateTransactionRequest) -> Result; + async fn create_transaction_with_tx( + &self, + request: CreateTransactionRequest, + tx: &Db, + ) -> Result; + async fn update_transaction(&self, id: i64, request: UpdateTransactionRequest) -> Result<()>; + async fn update_transaction_with_tx( + &self, + id: i64, + request: UpdateTransactionRequest, + tx: &Db, + ) -> Result<()>; + async fn delete_transaction(&self, id: i64) -> Result<()>; +} + +pub struct TransactionServiceImpl +where + T: super::repo::TransactionRepoService + ?Sized, +{ + db: DatabaseConnection, + repo: Arc, +} + +impl TransactionServiceImpl> +where + T: super::super::accounts::AccountsService + ?Sized, + U: super::category::CategoryService + ?Sized, + K: super::tag::TagService + ?Sized, +{ + pub fn new( + db: DatabaseConnection, + account_service: Arc, + category_service: Arc, + tag_service: Arc, + ) -> Self { + Self { + db: db.clone(), + repo: Arc::new(super::repo::TransactionRepoServiceImpl::new( + db, + account_service, + category_service, + tag_service, + )), + } + } +} + +#[async_trait::async_trait] +impl TransactionService + for TransactionServiceImpl> +where + T: super::super::accounts::AccountsService + ?Sized, + U: super::category::CategoryService + ?Sized, + K: super::tag::TagService + ?Sized, +{ + async fn get_transaction(&self, id: i64) -> Result> { + self.repo.get_transaction(id).await + } + + async fn get_transactions_by_account(&self, account_id: i64) -> Result> { + self.repo.get_transactions_by_account(account_id).await + } + + async fn create_transaction(&self, request: CreateTransactionRequest) -> Result { + self.repo.create_transaction(request).await + } + + async fn create_transaction_with_tx( + &self, + request: CreateTransactionRequest, + tx: &Db, + ) -> Result { + self.repo.create_transaction_with_tx(request, tx).await + } + + async fn update_transaction(&self, id: i64, request: UpdateTransactionRequest) -> Result<()> { + self.repo.update_transaction(id, request).await + } + + async fn update_transaction_with_tx( + &self, + id: i64, + request: UpdateTransactionRequest, + tx: &Db, + ) -> Result<()> { + self.repo.update_transaction_with_tx(id, request, tx).await + } + + async fn delete_transaction(&self, id: i64) -> Result<()> { + self.repo.delete_transaction(id).await + } +}