feat: implement transaction service with CRUD operations and validation

This commit is contained in:
GW_MC
2026-05-28 04:01:51 +00:00
parent 0d2e59a9eb
commit 79c9865823
4 changed files with 704 additions and 0 deletions

View File

@@ -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<sea_orm::DbErr> for TransactionServiceError {
fn from(err: sea_orm::DbErr) -> Self {
TransactionServiceError::DbErr(DbServiceError::from(err))
}
}
impl From<TagServiceError> 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<CategoryServiceError> 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<String> for TransactionType {
fn from(s: String) -> Self {
match serde_json::from_str::<TransactionType>(&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<String>,
pub transaction_date: String,
pub metadata: TransactionMetadata,
pub created_at: String,
pub updated_at: String,
pub from_account_id: Option<i64>,
pub category_id: Option<i64>,
}
impl From<TransactionModel> 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<String> for TransactionMetadata {
fn from(s: String) -> Self {
serde_json::from_str(&s).unwrap_or_default()
}
}
impl From<Option<String>> for TransactionMetadata {
fn from(s: Option<String>) -> 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<T>(
account_id: i64,
account_service: Arc<T>,
) -> 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<T>(
category_id: i64,
category_service: Arc<T>,
) -> 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<String>,
pub transaction_date: String,
pub metadata: Option<TransactionMetadata>,
pub from_account_id: Option<i64>,
pub category_id: Option<i64>,
}
impl CreateTransactionRequest {
pub async fn validate<T, U>(
&self,
account_service: Arc<T>,
category_service: Arc<U>,
) -> 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::<rust_decimal::Decimal>().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<CreateTransactionRequest> 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<i64>,
pub amount: Option<String>,
pub transaction_type: Option<TransactionType>,
pub currency_code: Option<String>,
pub exchange_rate: Option<Option<String>>,
pub transaction_date: Option<String>,
pub metadata: Option<Option<TransactionMetadata>>,
pub from_account_id: Option<Option<i64>>,
pub category_id: Option<Option<i64>>,
}
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<T, U>(
&self,
account_service: Arc<T>,
category_service: Arc<U>,
) -> 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::<rust_decimal::Decimal>().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(())
}
}

View File

@@ -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::*;

View File

@@ -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<T> = TransactionResult<T>;
#[async_trait::async_trait]
pub trait TransactionRepoService: Send + Sync + 'static {
async fn get_transaction(&self, id: i64) -> Result<Option<Transaction>>;
// TODO: pagination
// TODO: filter
// TODO: sort
async fn get_transactions_by_account(&self, account_id: i64) -> Result<Vec<Transaction>>;
async fn create_transaction(&self, request: CreateTransactionRequest) -> Result<i64>;
async fn create_transaction_with_tx(
&self,
request: CreateTransactionRequest,
tx: &Db,
) -> Result<i64>;
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<T, U, K>
where
T: super::super::accounts::AccountsService + ?Sized,
U: super::category::CategoryService + ?Sized,
K: super::tag::TagService + ?Sized,
{
db: DatabaseConnection,
account_service: Arc<T>,
category_service: Arc<U>,
tag_service: Arc<K>,
}
impl<T, U, K> TransactionRepoServiceImpl<T, U, K>
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<T>,
category_service: Arc<U>,
tag_service: Arc<K>,
) -> Self {
Self {
db: db.clone(),
account_service,
tag_service,
category_service,
}
}
}
#[async_trait::async_trait]
impl<T, U, K> TransactionRepoService for TransactionRepoServiceImpl<T, U, K>
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<Option<Transaction>> {
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<Vec<Transaction>> {
let transactions = TransactionEntity::find()
.filter(AccountId.eq(account_id))
.all(&self.db)
.await?
.into_iter()
.map(|model| model.into())
.collect::<Vec<Transaction>>();
Ok(transactions)
}
async fn create_transaction(&self, request: CreateTransactionRequest) -> Result<i64> {
self.create_transaction_with_tx(request, &(&self.db).into())
.await
}
async fn create_transaction_with_tx(
&self,
request: CreateTransactionRequest,
outer_tx: &Db,
) -> Result<i64> {
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(())
}
}

View File

@@ -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<T> = std::result::Result<T, TransactionServiceError>;
type Result<T> = TransactionResult<T>;
#[async_trait::async_trait]
pub trait TransactionService: Send + Sync + 'static {
async fn get_transaction(&self, id: i64) -> Result<Option<Transaction>>;
// TODO: pagination
// TODO: filter
// TODO: sort
async fn get_transactions_by_account(&self, account_id: i64) -> Result<Vec<Transaction>>;
async fn create_transaction(&self, request: CreateTransactionRequest) -> Result<i64>;
async fn create_transaction_with_tx(
&self,
request: CreateTransactionRequest,
tx: &Db,
) -> Result<i64>;
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<T>
where
T: super::repo::TransactionRepoService + ?Sized,
{
db: DatabaseConnection,
repo: Arc<T>,
}
impl<T, U, K> TransactionServiceImpl<super::repo::TransactionRepoServiceImpl<T, U, K>>
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<T>,
category_service: Arc<U>,
tag_service: Arc<K>,
) -> Self {
Self {
db: db.clone(),
repo: Arc::new(super::repo::TransactionRepoServiceImpl::new(
db,
account_service,
category_service,
tag_service,
)),
}
}
}
#[async_trait::async_trait]
impl<T, U, K> TransactionService
for TransactionServiceImpl<super::repo::TransactionRepoServiceImpl<T, U, K>>
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<Option<Transaction>> {
self.repo.get_transaction(id).await
}
async fn get_transactions_by_account(&self, account_id: i64) -> Result<Vec<Transaction>> {
self.repo.get_transactions_by_account(account_id).await
}
async fn create_transaction(&self, request: CreateTransactionRequest) -> Result<i64> {
self.repo.create_transaction(request).await
}
async fn create_transaction_with_tx(
&self,
request: CreateTransactionRequest,
tx: &Db,
) -> Result<i64> {
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
}
}