feat: implement transaction service with CRUD operations and validation
This commit is contained in:
381
src-tauri/src/services/transaction/dto.rs
Normal file
381
src-tauri/src/services/transaction/dto.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user