Files
otter/src-tauri/src/services/transaction/dto.rs

382 lines
13 KiB
Rust

// 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(())
}
}