Merge branch 'feature/transaction'

This commit is contained in:
GW_MC
2026-05-28 04:03:26 +00:00
29 changed files with 2173 additions and 48 deletions

29
Cargo.lock generated
View File

@@ -1166,20 +1166,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "decimal"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a8ab77e91baeb15034c3be91e87bff4665c9036216148e4996d9a9f5792114d"
dependencies = [
"bitflags 1.3.2",
"cc",
"libc",
"ord_subset",
"rustc-serialize",
"serde",
]
[[package]]
name = "der"
version = "0.7.10"
@@ -3270,12 +3256,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ord_subset"
version = "3.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdcf5505c0f054ce51fa0fa74142738930a45d5ac1faacae4dd4e2f54afe00fa"
[[package]]
name = "ordered-float"
version = "4.6.0"
@@ -3301,14 +3281,15 @@ version = "0.1.0"
dependencies = [
"async-trait",
"chrono",
"decimal",
"migration",
"rust_decimal",
"sea-orm",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-opener",
"thiserror 2.0.18",
]
[[package]]
@@ -4007,12 +3988,6 @@ version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustc-serialize"
version = "0.3.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe834bc780604f4674073badbad26d7219cadfb4a2275802db12cbae17498401"
[[package]]
name = "rustc_version"
version = "0.4.1"

View File

@@ -1,6 +1,7 @@
pub use sea_orm_migration::prelude::*;
mod m20260526_062833_create_account_table;
mod m20260526_110957_create_transcations_table;
pub struct Migrator;
@@ -8,8 +9,8 @@ pub struct Migrator;
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![
//
Box::new(m20260526_062833_create_account_table::Migration),
Box::new(m20260526_110957_create_transcations_table::Migration),
]
}
}

View File

@@ -0,0 +1,253 @@
use sea_orm_migration::{prelude::*, schema::*};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
// Create tag table
manager
.create_table(
TableCreateStatement::new()
.table("tag")
.if_not_exists()
.col(pk_auto("id").not_null())
.col(string("name").not_null().unique_key())
.col(string("color_code").not_null())
.col(string("created_at").not_null())
.col(string("updated_at").not_null())
.to_owned(),
)
.await?;
// Create indexes
manager
.create_index(
Index::create()
.name("idx-tag-name")
.table("tag")
.col("name")
.to_owned(),
)
.await?;
// Create category table
manager
.create_table(
TableCreateStatement::new()
.table("category")
.if_not_exists()
.col(pk_auto("id").not_null())
.col(
integer("parent_id")
.null()
.comment("the parent category id, can be null for root categories"),
)
.col(string("name").not_null())
.col(string("color_code").not_null())
.col(string("created_at").not_null())
.col(string("updated_at").not_null())
.foreign_key(
ForeignKey::create()
.name("fk-category-parent_id")
.from("category", "parent_id")
.to("category", "id")
.on_delete(ForeignKeyAction::NoAction)
.on_update(ForeignKeyAction::Restrict),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx-category-name_parent_id")
.table("category")
.col("name")
.col("parent_id")
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx-category-name")
.table("category")
.col("name")
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx-category-parent_id")
.table("category")
.col("parent_id")
.to_owned(),
)
.await?;
// Create transaction table
manager
.create_table(
TableCreateStatement::new()
.table("transaction")
.if_not_exists()
.col(pk_auto("id").not_null())
// for debt transaction, amount is negative; for credit transaction, amount is positive. The actual effect on the account balance depends on the account type. For example, a $100 income transaction will have amount = 100, transaction_type = STANDARD, and it will increase the balance of an asset account but decrease the balance of a debt account. A $50 expense transaction will have amount = -50, transaction_type = STANDARD, and it will decrease the balance of an asset account but increase the balance of a debt account. A $200 transfer from account A to account B will have two transactions: one with amount = -200, transaction_type = STANDARD for account A, and another with amount = 200, transaction_type = STANDARD for account B. An opening balance transaction will have transaction_type = OPENING_BALANCE, and its amount can be positive or negative depending on the initial balance of the account.
.col(string("amount").not_null().comment("the amount of this transaction in the original currency. Positive for assets increase or debts decrease, negative for assets decrease or debts increase"))
.col(string("transaction_type").not_null().comment("One of: STANDARD, RECONCILIATION, OPENING_BALANCE"))
.col(string("currency_code").not_null())
.col(string("exchange_rate").null())
.col(string("transaction_date").not_null())
.col(string("metadata").null())
.col(string("created_at").not_null())
.col(string("updated_at").not_null())
// fks
.col(integer("account_id").not_null().comment("the account this transaction belongs to"))
// default null
.col(integer("from_account_id").null().comment("the account this transaction is from, can be null for income and expense transactions"))
// transaction category
.col(integer("category_id").null().comment("the category of this transaction, can be null"))
// Add foreign key constraint to account
.foreign_key(
ForeignKey::create()
.name("fk-transaction-account")
.from("transaction", "account_id")
.to("account", "id")
.on_delete(ForeignKeyAction::NoAction)
.on_update(ForeignKeyAction::Restrict),
)
.foreign_key(
ForeignKey::create()
.name("fk-transaction-from_account")
.from("transaction", "from_account_id")
.to("account", "id")
.on_delete(ForeignKeyAction::NoAction)
.on_update(ForeignKeyAction::Restrict),
)
.foreign_key(
ForeignKey::create()
.name("fk-transaction-category")
.from("transaction", "category_id")
.to("category", "id")
.on_delete(ForeignKeyAction::NoAction)
.on_update(ForeignKeyAction::Restrict),
)
.to_owned(),
)
.await?;
// Create transaction_tag table
manager
.create_table(
TableCreateStatement::new()
.table("transaction_tag")
.if_not_exists()
.col(integer("transaction_id").not_null())
.col(integer("tag_id").not_null())
.primary_key(
Index::create()
.name("pk-transaction_tag")
.col("transaction_id")
.col("tag_id"),
)
.foreign_key(
ForeignKey::create()
.name("fk-transaction_tag-transaction")
.from("transaction_tag", "transaction_id")
.to("transaction", "id")
.on_delete(ForeignKeyAction::NoAction)
.on_update(ForeignKeyAction::Restrict),
)
.foreign_key(
ForeignKey::create()
.name("fk-transaction_tag-tag")
.from("transaction_tag", "tag_id")
.to("tag", "id")
.on_delete(ForeignKeyAction::NoAction)
.on_update(ForeignKeyAction::Restrict),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx-transaction-account_id")
.table("transaction")
.col("account_id")
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx-transaction-from_account_id")
.table("transaction")
.col("from_account_id")
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx-transaction-category_id")
.table("transaction")
.col("category_id")
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx-transaction-transaction_date")
.table("transaction")
.col("transaction_date")
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("idx-transaction-transaction_type")
.table("transaction")
.col("transaction_type")
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(
TableDropStatement::new()
.table("transaction_tag")
.to_owned(),
)
.await?;
manager
.drop_table(TableDropStatement::new().table("transaction").to_owned())
.await?;
manager
.drop_table(TableDropStatement::new().table("category").to_owned())
.await?;
manager
.drop_table(TableDropStatement::new().table("tag").to_owned())
.await?;
Ok(())
}
}

View File

@@ -3,7 +3,7 @@ name = "otter"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -23,9 +23,10 @@ tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
async-trait = "0.1.89"
decimal = "2.1.0"
rust_decimal = "1.42.0"
sea-orm.workspace = true
migration = { path = "../crates/migration" }
chrono.workspace = true
thiserror = "2.0.18"

View File

@@ -0,0 +1,34 @@
#[tauri::command]
pub async fn get_account(
id: i64,
services: tauri::State<'_, crate::services::AppState>,
) -> Result<Option<crate::services::accounts::Account>, String> {
let accounts_service = &services.accounts;
accounts_service
.get_account(&id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_accounts(
services: tauri::State<'_, crate::services::AppState>,
) -> Result<Vec<crate::services::accounts::Account>, String> {
let accounts_service = &services.accounts;
accounts_service
.get_accounts()
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn create_account(
request: crate::services::accounts::CreateAccountRequest,
services: tauri::State<'_, crate::services::AppState>,
) -> Result<i64, String> {
let accounts_service = &services.accounts;
accounts_service
.create_account(request)
.await
.map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,5 @@
mod account;
mod transactions;
pub use account::*;
pub use transactions::*;

View File

@@ -0,0 +1,36 @@
#[tauri::command]
pub async fn get_transactions_for_account(
id: i64,
services: tauri::State<'_, crate::services::AppState>,
) -> Result<Vec<crate::services::transaction::Transaction>, String> {
let transaction_service = &services.transaction;
transaction_service
.get_transactions_by_account(id)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn create_transaction(
request: crate::services::transaction::CreateTransactionRequest,
services: tauri::State<'_, crate::services::AppState>,
) -> Result<i64, String> {
let transaction_service = &services.transaction;
transaction_service
.create_transaction(request)
.await
.map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn update_transaction(
id: i64,
request: crate::services::transaction::UpdateTransactionRequest,
services: tauri::State<'_, crate::services::AppState>,
) -> Result<(), String> {
let transaction_service = &services.transaction;
transaction_service
.update_transaction(id, request)
.await
.map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,39 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "category")]
#[allow(dead_code)]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub parent_id: Option<i64>,
pub name: String,
pub color_code: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "Entity",
from = "Column::ParentId",
to = "Column::Id",
on_update = "Restrict",
on_delete = "NoAction"
)]
SelfRef,
#[sea_orm(has_many = "super::transaction::Entity")]
Transaction,
}
impl Related<super::transaction::Entity> for Entity {
fn to() -> RelationDef {
Relation::Transaction.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -4,3 +4,7 @@ pub mod prelude;
pub mod account;
pub mod account_category;
pub mod category;
pub mod tag;
pub mod transaction;
pub mod transaction_tag;

View File

@@ -4,3 +4,7 @@
pub use super::account::Entity as Account;
pub use super::account_category::Entity as AccountCategory;
pub use super::category::Entity as Category;
pub use super::tag::Entity as Tag;
pub use super::transaction::Entity as Transaction;
pub use super::transaction_tag::Entity as TransactionTag;

View File

@@ -0,0 +1,40 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "tag")]
#[allow(dead_code)]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
#[sea_orm(unique)]
pub name: String,
pub color_code: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::transaction_tag::Entity")]
TransactionTag,
}
impl Related<super::transaction_tag::Entity> for Entity {
fn to() -> RelationDef {
Relation::TransactionTag.def()
}
}
impl Related<super::transaction::Entity> for Entity {
fn to() -> RelationDef {
super::transaction_tag::Relation::Transaction.def()
}
fn via() -> Option<RelationDef> {
Some(super::transaction_tag::Relation::Tag.def().rev())
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,76 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "transaction")]
#[allow(dead_code)]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i64,
pub amount: String,
pub transaction_type: String,
pub currency_code: String,
pub exchange_rate: Option<String>,
pub transaction_date: String,
pub metadata: Option<String>,
pub created_at: String,
pub updated_at: String,
pub account_id: i64,
pub from_account_id: Option<i64>,
pub category_id: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::account::Entity",
from = "Column::FromAccountId",
to = "super::account::Column::Id",
on_update = "Restrict",
on_delete = "NoAction"
)]
Account2,
#[sea_orm(
belongs_to = "super::account::Entity",
from = "Column::AccountId",
to = "super::account::Column::Id",
on_update = "Restrict",
on_delete = "NoAction"
)]
Account1,
#[sea_orm(
belongs_to = "super::category::Entity",
from = "Column::CategoryId",
to = "super::category::Column::Id",
on_update = "Restrict",
on_delete = "NoAction"
)]
Category,
#[sea_orm(has_many = "super::transaction_tag::Entity")]
TransactionTag,
}
impl Related<super::category::Entity> for Entity {
fn to() -> RelationDef {
Relation::Category.def()
}
}
impl Related<super::transaction_tag::Entity> for Entity {
fn to() -> RelationDef {
Relation::TransactionTag.def()
}
}
impl Related<super::tag::Entity> for Entity {
fn to() -> RelationDef {
super::transaction_tag::Relation::Tag.def()
}
fn via() -> Option<RelationDef> {
Some(super::transaction_tag::Relation::Transaction.def().rev())
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,48 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "transaction_tag")]
#[allow(dead_code)]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub transaction_id: i64,
#[sea_orm(primary_key, auto_increment = false)]
pub tag_id: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::tag::Entity",
from = "Column::TagId",
to = "super::tag::Column::Id",
on_update = "Restrict",
on_delete = "NoAction"
)]
Tag,
#[sea_orm(
belongs_to = "super::transaction::Entity",
from = "Column::TransactionId",
to = "super::transaction::Column::Id",
on_update = "Restrict",
on_delete = "NoAction"
)]
Transaction,
}
impl Related<super::tag::Entity> for Entity {
fn to() -> RelationDef {
Relation::Tag.def()
}
}
impl Related<super::transaction::Entity> for Entity {
fn to() -> RelationDef {
Relation::Transaction.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -3,9 +3,59 @@ pub mod entities;
use async_trait::async_trait;
use sea_orm::{
ConnectionTrait, DatabaseConnection, DatabaseTransaction, DbBackend, DbErr, ExecResult,
QueryResult, Statement, StatementBuilder,
QueryResult, SqlxError, Statement, StatementBuilder,
};
#[derive(thiserror::Error, Debug)]
pub enum DbServiceError {
#[error("unique constraint violation: {0}")]
UniqueConstraintViolation(String),
#[error("check constraint violation: {0}")]
CheckConstraintViolation(String),
#[error("foreign key constraint violation: {0}")]
ForeignKeyConstraintViolation(String),
// catchall
#[error("unknown database error: {0}")]
Unknown(String),
}
impl From<&SqlxError> for DbServiceError {
fn from(err: &SqlxError) -> Self {
match err {
SqlxError::Database(db_err) => {
if db_err.is_check_violation() {
DbServiceError::CheckConstraintViolation(db_err.to_string())
} else if db_err.is_unique_violation() {
DbServiceError::UniqueConstraintViolation(db_err.to_string())
} else if db_err.is_foreign_key_violation() {
DbServiceError::ForeignKeyConstraintViolation(db_err.to_string())
} else {
DbServiceError::Unknown(db_err.to_string())
}
}
other => DbServiceError::Unknown(other.to_string()),
}
}
}
impl From<sea_orm::RuntimeErr> for DbServiceError {
fn from(err: sea_orm::RuntimeErr) -> Self {
match err {
sea_orm::RuntimeErr::SqlxError(sqlx_err) => DbServiceError::from(&*sqlx_err),
other => DbServiceError::Unknown(other.to_string()),
}
}
}
impl From<DbErr> for DbServiceError {
fn from(err: DbErr) -> Self {
match err {
DbErr::Exec(exec_err) => DbServiceError::from(exec_err),
other => DbServiceError::Unknown(other.to_string()),
}
}
}
// 1. Create a uniform enum wrapper
pub enum Db<'a> {
Connection(&'a DatabaseConnection),

View File

@@ -12,6 +12,7 @@ use tauri::Manager;
use crate::services::create_services;
mod commands;
mod db;
mod services;
@@ -27,8 +28,31 @@ pub fn run() {
tauri::Builder::default()
.setup(|app| {
tauri::async_runtime::block_on(async {
let mut connect_options =
ConnectOptions::new("sqlite://finance_manager.db".to_string());
// Get the app's data directory
let app_data_dir = app
.path()
.app_data_dir()
.expect("Failed to get app data directory");
// Create the directory if it doesn't exist
std::fs::create_dir_all(&app_data_dir)
.expect("Failed to create app data directory");
// Construct the database path
let db_path = app_data_dir.join("finance_manager.db");
// Ensure the database file exists
if !db_path.exists() {
std::fs::File::create(&db_path).expect("Failed to create database file");
}
// Get the canonical path
let canonical_path = db_path
.canonicalize()
.expect("Failed to get canonical path");
let db_url = format!("sqlite://{}", canonical_path.to_string_lossy());
let mut connect_options = ConnectOptions::new(db_url);
connect_options.after_connect(|conn| {
Box::pin(async move {
// Enable foreign key support for SQLite
@@ -50,12 +74,22 @@ pub fn run() {
migration::Migrator::up(&db, None)
.await
.expect("Failed to run migrations");
app.manage(create_services(db))
let services = create_services(db);
app.manage(services)
});
Ok(())
})
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.invoke_handler(tauri::generate_handler![
greet,
commands::get_account,
commands::get_accounts,
commands::create_account,
commands::get_transactions_for_account,
commands::create_transaction,
commands::update_transaction
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@@ -2,22 +2,65 @@ use std::sync::Arc;
use sea_orm::DatabaseConnection;
pub mod accounts;
use crate::services::{
accounts::{
AccountsService, AccountsServiceImpl,
category::{AccountCategoryService, AccountCategoryServiceImpl},
},
transaction::{
TransactionService, TransactionServiceImpl,
category::{CategoryService, CategoryServiceImpl},
tag::{TagService, TagServiceImpl},
},
};
pub struct Services {
pub account_category: Arc<dyn accounts::category::AccountCategoryService>,
pub accounts: Arc<dyn accounts::AccountsService>,
pub mod accounts;
pub mod transaction;
pub type AppState = Services<
dyn AccountCategoryService,
dyn AccountsService,
dyn TransactionService,
dyn CategoryService,
dyn TagService,
>;
#[derive(Clone)]
pub struct Services<AC, A, T, TC, TT>
where
AC: AccountCategoryService + ?Sized,
A: AccountsService + ?Sized,
T: TransactionService + ?Sized,
TC: CategoryService + ?Sized,
TT: TagService + ?Sized,
{
pub account_category: Arc<AC>,
pub accounts: Arc<A>,
pub transaction: Arc<T>,
pub transaction_category: Arc<TC>,
pub transaction_tag: Arc<TT>,
}
pub fn create_services(db: DatabaseConnection) -> Services {
let account_category_service = Arc::new(accounts::category::AccountCategoryServiceImpl::new(
pub fn create_services(db: DatabaseConnection) -> AppState {
let account_category_service: Arc<dyn AccountCategoryService> =
Arc::new(AccountCategoryServiceImpl::new(db.clone()));
let account_service: Arc<dyn AccountsService> = Arc::new(AccountsServiceImpl::new(
db.clone(),
Arc::new(AccountCategoryServiceImpl::new(db.clone())),
));
let transaction_category_service: Arc<dyn CategoryService> =
Arc::new(CategoryServiceImpl::new(db.clone()));
let transaction_tag_service: Arc<dyn TagService> = Arc::new(TagServiceImpl::new(db.clone()));
Services {
account_category: account_category_service.clone(),
accounts: Arc::new(accounts::AccountsServiceImpl::new(
db,
account_category_service,
)),
accounts: account_service.clone(),
transaction: Arc::new(TransactionServiceImpl::new(
db.clone(),
account_service.clone(),
transaction_category_service.clone(),
transaction_tag_service.clone(),
)) as Arc<dyn TransactionService>,
transaction_category: transaction_category_service,
transaction_tag: transaction_tag_service,
}
}

View File

@@ -0,0 +1,165 @@
use std::sync::Arc;
use sea_orm::ActiveValue::Set;
use crate::{
db::entities::category::{ActiveModel as CategoryActiveModel, Model as CategoryModel},
services::transaction::category::{repo::CategoryRepoService, CategoryServiceError},
};
pub struct Category {
pub id: i64,
pub name: String,
pub color_code: String,
pub parent_id: Option<i64>,
pub created_at: String,
pub updated_at: String,
}
impl From<CategoryModel> for Category {
fn from(model: CategoryModel) -> Self {
Self {
id: model.id,
name: model.name,
color_code: model.color_code,
parent_id: model.parent_id,
created_at: model.created_at.to_string(),
updated_at: model.updated_at.to_string(),
}
}
}
pub struct CategoryWithParent {
pub id: i64,
pub name: String,
pub color_code: String,
pub parent_id: Option<i64>,
pub parent: Option<Box<Category>>,
pub created_at: String,
pub updated_at: String,
}
impl From<(CategoryModel, Option<CategoryModel>)> for CategoryWithParent {
fn from((model, parent): (CategoryModel, Option<CategoryModel>)) -> Self {
Self {
id: model.id,
name: model.name,
color_code: model.color_code,
parent_id: model.parent_id,
parent: parent.map(|parent_model| Box::new(parent_model.into())),
created_at: model.created_at.to_string(),
updated_at: model.updated_at.to_string(),
}
}
}
pub struct CreateCategoryRequest {
pub name: String,
pub color_code: String,
pub parent_id: Option<i64>,
}
impl CreateCategoryRequest {
pub async fn validate<T>(
&self,
category_repo: Arc<T>,
) -> std::result::Result<(), CategoryServiceError>
where
T: CategoryRepoService + ?Sized,
{
if self.name.trim().is_empty() {
return Err(CategoryServiceError::Validation(
"Name cannot be empty".to_string(),
));
}
if self.color_code.trim().is_empty() {
return Err(CategoryServiceError::Validation(
"Color code cannot be empty".to_string(),
));
}
if let Some(parent_id) = self.parent_id {
let res = category_repo.get_category(parent_id).await?;
if res.is_none() {
return Err(CategoryServiceError::Validation(format!(
"Parent category with id {} does not exist",
parent_id
)));
}
}
Ok(())
}
}
impl From<CreateCategoryRequest> for CategoryActiveModel {
fn from(request: CreateCategoryRequest) -> Self {
Self {
name: Set(request.name),
color_code: Set(request.color_code),
parent_id: Set(request.parent_id),
created_at: Set(chrono::Utc::now().to_rfc3339()),
updated_at: Set(chrono::Utc::now().to_rfc3339()),
..Default::default()
}
}
}
pub struct UpdateCategoryRequest {
pub name: Option<String>,
pub color_code: Option<String>,
pub parent_id: Option<Option<i64>>,
}
impl UpdateCategoryRequest {
pub async fn validate<T>(
&self,
category_repo: Arc<T>,
) -> std::result::Result<(), CategoryServiceError>
where
T: CategoryRepoService + ?Sized,
{
if let Some(name) = &self.name {
if name.trim().is_empty() {
return Err(CategoryServiceError::Validation(
"Name cannot be empty".to_string(),
));
}
}
if let Some(color_code) = &self.color_code {
if color_code.trim().is_empty() {
return Err(CategoryServiceError::Validation(
"Color code cannot be empty".to_string(),
));
}
}
if let Some(Some(parent_id)) = self.parent_id {
let res = category_repo.get_category(parent_id).await?;
if res.is_none() {
return Err(CategoryServiceError::Validation(format!(
"Parent category with id {} does not exist",
parent_id
)));
}
}
Ok(())
}
pub fn apply_to(self, category: &mut CategoryActiveModel) {
if let Some(name) = self.name {
category.name = Set(name);
}
if let Some(color_code) = self.color_code {
category.color_code = Set(color_code);
}
if let Some(Some(parent_id)) = self.parent_id {
category.parent_id = Set(Some(parent_id));
} else if let Some(None) = self.parent_id {
category.parent_id = Set(None);
}
}
}

View File

@@ -0,0 +1,6 @@
mod dto;
pub(super) mod repo;
mod service;
pub use dto::*;
pub use service::*;

View File

@@ -0,0 +1,124 @@
use std::sync::Arc;
use crate::{
db::{
entities::category::{ActiveModel as CategoryActiveModel, Entity as CategoryEntity},
Db,
},
services::transaction::category::{
Category, CategoryServiceError, CategoryServiceResult, CategoryWithParent,
CreateCategoryRequest, UpdateCategoryRequest,
},
};
use sea_orm::{
ActiveModelTrait, DatabaseConnection, EntityTrait, Linked, ModelTrait, RelationTrait,
};
pub type Result<T> = CategoryServiceResult<T>;
#[async_trait::async_trait]
pub trait CategoryRepoService: Send + Sync + 'static {
async fn get_categories(&self) -> Result<Vec<Category>>;
async fn get_category(&self, id: i64) -> Result<Option<CategoryWithParent>>;
async fn create_category(&self, request: CreateCategoryRequest) -> Result<i64>;
async fn create_category_with_tx(&self, request: CreateCategoryRequest, tx: &Db)
-> Result<i64>;
async fn update_category(&self, id: i64, request: UpdateCategoryRequest) -> Result<()>;
async fn update_category_with_tx(
&self,
id: i64,
request: UpdateCategoryRequest,
tx: &Db,
) -> Result<()>;
async fn delete_category(&self, id: i64) -> Result<()>;
}
#[derive(Clone)]
pub struct CategoryRepoServiceImpl {
pub db: DatabaseConnection,
}
struct CategorySelfRefLink;
impl Linked for CategorySelfRefLink {
type FromEntity = CategoryEntity;
type ToEntity = CategoryEntity;
fn link(&self) -> Vec<sea_orm::RelationDef> {
vec![
// Use the BelongsTo or HasMany relation you defined in your enum
crate::db::entities::category::Relation::SelfRef.def(),
]
}
}
#[async_trait::async_trait]
impl CategoryRepoService for CategoryRepoServiceImpl {
async fn get_categories(&self) -> Result<Vec<Category>> {
let categories = CategoryEntity::find().all(&self.db).await?;
Ok(categories.into_iter().map(|model| model.into()).collect())
}
async fn get_category(&self, id: i64) -> Result<Option<CategoryWithParent>> {
let category = CategoryEntity::find_by_id(id)
.find_also_linked(CategorySelfRefLink)
.one(&self.db)
.await?;
Ok(category.map(|res| res.into()))
}
async fn create_category(&self, request: CreateCategoryRequest) -> Result<i64> {
self.create_category_with_tx(request, &(&self.db).into())
.await
}
async fn create_category_with_tx(
&self,
request: CreateCategoryRequest,
tx: &Db,
) -> Result<i64> {
request.validate(Arc::new(self.clone())).await?;
let new_category: CategoryActiveModel = request.into();
let res = new_category.insert(tx).await?;
Ok(res.id)
}
async fn update_category(&self, id: i64, request: UpdateCategoryRequest) -> Result<()> {
self.update_category_with_tx(id, request, &(&self.db).into())
.await
}
async fn update_category_with_tx(
&self,
id: i64,
request: UpdateCategoryRequest,
tx: &Db,
) -> Result<()> {
let category = CategoryEntity::find_by_id(id)
.one(tx)
.await?
.ok_or_else(|| {
CategoryServiceError::TargetNotFound(format!("Category with id {} not found", id))
})?;
let mut category: CategoryActiveModel = category.into();
request.validate(Arc::new(self.clone())).await?;
request.apply_to(&mut category);
category.update(tx).await?;
Ok(())
}
async fn delete_category(&self, id: i64) -> Result<()> {
let category = CategoryEntity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| {
CategoryServiceError::TargetNotFound(format!("Category with id {} not found", id))
})?;
category.delete(&self.db).await?;
Ok(())
}
}

View File

@@ -0,0 +1,115 @@
use std::sync::Arc;
use sea_orm::DatabaseConnection;
use thiserror::Error;
use crate::{
db::{Db, DbServiceError},
services::transaction::category::{
repo::{CategoryRepoService, CategoryRepoServiceImpl},
Category, CategoryWithParent, CreateCategoryRequest, UpdateCategoryRequest,
},
};
#[derive(Error, Debug)]
pub enum CategoryServiceError {
#[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 CategoryServiceError {
fn from(err: sea_orm::DbErr) -> Self {
CategoryServiceError::DbErr(DbServiceError::from(err))
}
}
pub type CategoryServiceResult<T> = std::result::Result<T, CategoryServiceError>;
type Result<T> = CategoryServiceResult<T>;
#[async_trait::async_trait]
pub trait CategoryService: Send + Sync + 'static {
async fn get_categories(&self) -> Result<Vec<Category>>;
async fn get_category(&self, id: i64) -> Result<Option<CategoryWithParent>>;
async fn create_category(&self, request: CreateCategoryRequest) -> Result<i64>;
async fn create_category_with_tx(&self, request: CreateCategoryRequest, tx: &Db)
-> Result<i64>;
async fn update_category(&self, id: i64, request: UpdateCategoryRequest) -> Result<()>;
async fn update_category_with_tx(
&self,
id: i64,
request: UpdateCategoryRequest,
tx: &Db,
) -> Result<()>;
async fn delete_category(&self, id: i64) -> Result<()>;
}
pub struct CategoryServiceImpl<T>
where
T: CategoryRepoService + ?Sized,
{
db: DatabaseConnection,
category_repo: Arc<T>,
}
impl CategoryServiceImpl<CategoryRepoServiceImpl> {
pub fn new(db: DatabaseConnection) -> Self {
Self {
db: db.clone(),
category_repo: Arc::new(CategoryRepoServiceImpl { db }),
}
}
}
#[async_trait::async_trait]
impl<T> CategoryService for CategoryServiceImpl<T>
where
T: CategoryRepoService + ?Sized,
{
async fn get_categories(&self) -> Result<Vec<Category>> {
self.category_repo.get_categories().await
}
async fn get_category(&self, id: i64) -> Result<Option<CategoryWithParent>> {
self.category_repo.get_category(id).await
}
async fn create_category(&self, request: CreateCategoryRequest) -> Result<i64> {
self.category_repo.create_category(request).await
}
async fn create_category_with_tx(
&self,
request: CreateCategoryRequest,
tx: &Db,
) -> Result<i64> {
self.category_repo
.create_category_with_tx(request, tx)
.await
}
async fn update_category(&self, id: i64, request: UpdateCategoryRequest) -> Result<()> {
self.category_repo.update_category(id, request).await
}
async fn update_category_with_tx(
&self,
id: i64,
request: UpdateCategoryRequest,
tx: &Db,
) -> Result<()> {
self.category_repo
.update_category_with_tx(id, request, tx)
.await
}
async fn delete_category(&self, id: i64) -> Result<()> {
self.category_repo.delete_category(id).await
}
}

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
}
}

View File

@@ -0,0 +1,84 @@
use sea_orm::ActiveValue::Set;
use crate::db::entities::tag::Model as TagModel;
pub struct Tag {
pub id: i64,
pub name: String,
pub color_code: String,
pub created_at: String,
pub updated_at: String,
}
impl From<TagModel> for Tag {
fn from(model: TagModel) -> Self {
Self {
id: model.id,
name: model.name,
color_code: model.color_code,
created_at: model.created_at.to_string(),
updated_at: model.updated_at.to_string(),
}
}
}
pub struct CreateTagRequest {
pub name: String,
pub color_code: String,
}
impl CreateTagRequest {
pub fn validate(&self) -> Result<(), String> {
if self.name.trim().is_empty() {
return Err("Tag name cannot be empty".to_string());
}
if self.color_code.trim().is_empty() {
return Err("Color code cannot be empty".to_string());
}
// Optionally, add more validation for color code format (e.g., hex code)
Ok(())
}
}
impl From<CreateTagRequest> for crate::db::entities::tag::ActiveModel {
fn from(request: CreateTagRequest) -> Self {
Self {
name: Set(request.name),
color_code: Set(request.color_code),
created_at: Set(chrono::Utc::now().to_rfc3339()),
updated_at: Set(chrono::Utc::now().to_rfc3339()),
..Default::default()
}
}
}
pub struct UpdateTagRequest {
pub name: Option<String>,
pub color_code: Option<String>,
}
impl UpdateTagRequest {
pub fn validate(&self) -> Result<(), String> {
if let Some(name) = &self.name {
if name.trim().is_empty() {
return Err("Tag name cannot be empty".to_string());
}
}
if let Some(color_code) = &self.color_code {
if color_code.trim().is_empty() {
return Err("Color code cannot be empty".to_string());
}
// Optionally, add more validation for color code format (e.g., hex code)
}
Ok(())
}
pub fn apply_to(&self, tag: &mut crate::db::entities::tag::ActiveModel) {
if let Some(name) = &self.name {
tag.name = Set(name.clone());
}
if let Some(color_code) = &self.color_code {
tag.color_code = Set(color_code.clone());
}
}
}

View File

@@ -0,0 +1,6 @@
mod dto;
pub(super) mod repo;
mod service;
pub use dto::*;
pub use service::*;

View File

@@ -0,0 +1,150 @@
use crate::{
db::{
entities::{
tag::{ActiveModel as TagActiveModel, Entity as TagEntity},
transaction_tag::{
ActiveModel as TransactionTagActiveModel, Column::TransactionId,
Entity as TransactionTagEntity,
},
},
Db,
},
services::transaction::tag::{
CreateTagRequest, Tag, TagServiceError, TagServiceResult, UpdateTagRequest,
},
};
use sea_orm::{
ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, ModelTrait,
QueryFilter, TransactionTrait,
};
type Result<T> = TagServiceResult<T>;
#[async_trait::async_trait]
pub trait TagRepoService: Send + Sync + 'static {
async fn get_tags(&self) -> Result<Vec<Tag>>;
async fn get_tag(&self, id: i64) -> Result<Option<Tag>>;
async fn create_tag(&self, request: CreateTagRequest) -> Result<i64>;
async fn create_tag_with_tx(&self, request: CreateTagRequest, tx: &Db) -> Result<i64>;
async fn update_tag(&self, id: i64, request: UpdateTagRequest) -> Result<()>;
async fn update_tag_with_tx(&self, id: i64, request: UpdateTagRequest, tx: &Db) -> Result<()>;
async fn delete_tag(&self, id: i64) -> Result<()>;
//
async fn update_tags_for_transaction(
&self,
transaction_id: i64,
tag_ids: Vec<i64>,
) -> Result<()>;
async fn update_tags_for_transaction_with_tx(
&self,
transaction_id: i64,
new_tag_ids: Vec<i64>,
tx: &Db,
) -> Result<()>;
}
#[derive(Clone)]
pub struct TagRepoServiceImpl {
pub db: DatabaseConnection,
}
#[async_trait::async_trait]
impl TagRepoService for TagRepoServiceImpl {
async fn get_tags(&self) -> Result<Vec<Tag>> {
let tags = TagEntity::find().all(&self.db).await?;
Ok(tags.into_iter().map(|model| model.into()).collect())
}
async fn get_tag(&self, id: i64) -> Result<Option<Tag>> {
let tag = TagEntity::find_by_id(id).one(&self.db).await?;
Ok(tag.map(|model| model.into()))
}
async fn create_tag(&self, request: CreateTagRequest) -> Result<i64> {
self.create_tag_with_tx(request, &(&self.db).into()).await
}
async fn create_tag_with_tx(&self, request: CreateTagRequest, tx: &Db) -> Result<i64> {
request.validate().map_err(TagServiceError::Validation)?;
let new_tag: TagActiveModel = request.into();
let res = new_tag.insert(tx).await?;
Ok(res.id)
}
async fn update_tag(&self, id: i64, request: UpdateTagRequest) -> Result<()> {
self.update_tag_with_tx(id, request, &(&self.db).into())
.await
}
async fn update_tag_with_tx(&self, id: i64, request: UpdateTagRequest, tx: &Db) -> Result<()> {
let tag = TagEntity::find_by_id(id).one(tx).await?.ok_or_else(|| {
TagServiceError::TargetNotFound(format!("Tag with id {} not found", id))
})?;
let mut tag: TagActiveModel = tag.into();
request.validate().map_err(TagServiceError::Validation)?;
request.apply_to(&mut tag);
tag.updated_at = Set(chrono::Utc::now().to_rfc3339());
tag.update(tx).await?;
Ok(())
}
async fn delete_tag(&self, id: i64) -> Result<()> {
let tag = TagEntity::find_by_id(id)
.one(&self.db)
.await?
.ok_or_else(|| {
TagServiceError::TargetNotFound(format!("Tag with id {} not found", id))
})?;
tag.delete(&self.db).await?;
Ok(())
}
async fn update_tags_for_transaction(
&self,
transaction_id: i64,
tag_ids: Vec<i64>,
) -> Result<()> {
self.update_tags_for_transaction_with_tx(transaction_id, tag_ids, &(&self.db).into())
.await
}
async fn update_tags_for_transaction_with_tx(
&self,
transaction_id: i64,
new_tag_ids: Vec<i64>,
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,
);
// delete existing tags for the transaction
TransactionTagEntity::delete_many()
.filter(TransactionId.eq(transaction_id))
.exec(&tx)
.await?;
// insert new tags for the transaction
let models = new_tag_ids
.iter()
.map(|tag_id| TransactionTagActiveModel {
transaction_id: Set(transaction_id),
tag_id: Set(*tag_id),
})
.collect::<Vec<_>>();
TransactionTagEntity::insert_many(models).exec(&tx).await?;
if let Some(inner_tx) = inner_tx {
inner_tx.commit().await?;
}
Ok(())
}
}

View File

@@ -0,0 +1,128 @@
use std::sync::Arc;
use sea_orm::DatabaseConnection;
use thiserror::Error;
use crate::{
db::{Db, DbServiceError},
services::transaction::tag::{
repo::{TagRepoService, TagRepoServiceImpl},
CreateTagRequest, Tag, UpdateTagRequest,
},
};
#[derive(Error, Debug)]
pub enum TagServiceError {
#[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 TagServiceError {
fn from(err: sea_orm::DbErr) -> Self {
TagServiceError::DbErr(DbServiceError::from(err))
}
}
pub type TagServiceResult<T> = std::result::Result<T, TagServiceError>;
type Result<T> = TagServiceResult<T>;
#[async_trait::async_trait]
pub trait TagService: Send + Sync + 'static {
async fn get_tags(&self) -> Result<Vec<Tag>>;
async fn get_tag(&self, id: i64) -> Result<Option<Tag>>;
async fn create_tag(&self, request: CreateTagRequest) -> Result<i64>;
async fn create_tag_with_tx(&self, request: CreateTagRequest, tx: &Db) -> Result<i64>;
async fn update_tag(&self, id: i64, request: UpdateTagRequest) -> Result<()>;
async fn update_tag_with_tx(&self, id: i64, request: UpdateTagRequest, tx: &Db) -> Result<()>;
async fn delete_tag(&self, id: i64) -> Result<()>;
//
async fn update_tags_for_transaction(
&self,
transaction_id: i64,
tag_ids: Vec<i64>,
) -> Result<()>;
async fn update_tags_for_transaction_with_tx(
&self,
transaction_id: i64,
new_tag_ids: Vec<i64>,
tx: &Db,
) -> Result<()>;
}
pub struct TagServiceImpl<T>
where
T: TagRepoService + ?Sized,
{
db: DatabaseConnection,
tag_repo: Arc<T>,
}
impl TagServiceImpl<TagRepoServiceImpl> {
pub fn new(db: DatabaseConnection) -> Self {
Self {
db: db.clone(),
tag_repo: Arc::new(TagRepoServiceImpl { db }),
}
}
}
#[async_trait::async_trait]
impl<T> TagService for TagServiceImpl<T>
where
T: TagRepoService + ?Sized,
{
async fn get_tags(&self) -> Result<Vec<Tag>> {
self.tag_repo.get_tags().await
}
async fn get_tag(&self, id: i64) -> Result<Option<Tag>> {
self.tag_repo.get_tag(id).await
}
async fn create_tag(&self, request: CreateTagRequest) -> Result<i64> {
self.tag_repo.create_tag(request).await
}
async fn create_tag_with_tx(&self, request: CreateTagRequest, tx: &Db) -> Result<i64> {
self.tag_repo.create_tag_with_tx(request, tx).await
}
async fn update_tag(&self, id: i64, request: UpdateTagRequest) -> Result<()> {
self.tag_repo.update_tag(id, request).await
}
async fn update_tag_with_tx(&self, id: i64, request: UpdateTagRequest, tx: &Db) -> Result<()> {
self.tag_repo.update_tag_with_tx(id, request, tx).await
}
async fn delete_tag(&self, id: i64) -> Result<()> {
self.tag_repo.delete_tag(id).await
}
async fn update_tags_for_transaction(
&self,
transaction_id: i64,
tag_ids: Vec<i64>,
) -> Result<()> {
self.tag_repo
.update_tags_for_transaction(transaction_id, tag_ids)
.await
}
async fn update_tags_for_transaction_with_tx(
&self,
transaction_id: i64,
new_tag_ids: Vec<i64>,
tx: &Db,
) -> Result<()> {
self.tag_repo
.update_tags_for_transaction_with_tx(transaction_id, new_tag_ids, tx)
.await
}
}

View File

@@ -13,8 +13,8 @@
"windows": [
{
"title": "otter",
"width": 800,
"height": 600
"width": 390,
"height": 760
}
],
"security": {