diff --git a/Cargo.lock b/Cargo.lock index 981fcdc..b48e740 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/migration/src/lib.rs b/crates/migration/src/lib.rs index 0dbd873..dfbb7a0 100644 --- a/crates/migration/src/lib.rs +++ b/crates/migration/src/lib.rs @@ -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> { vec![ - // Box::new(m20260526_062833_create_account_table::Migration), + Box::new(m20260526_110957_create_transcations_table::Migration), ] } } diff --git a/crates/migration/src/m20260526_110957_create_transcations_table.rs b/crates/migration/src/m20260526_110957_create_transcations_table.rs new file mode 100644 index 0000000..aa2a86e --- /dev/null +++ b/crates/migration/src/m20260526_110957_create_transcations_table.rs @@ -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(()) + } +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c9b6780..6b7f9dd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs new file mode 100644 index 0000000..2b733d6 --- /dev/null +++ b/src-tauri/src/commands/account.rs @@ -0,0 +1,34 @@ +#[tauri::command] +pub async fn get_account( + id: i64, + services: tauri::State<'_, crate::services::AppState>, +) -> Result, 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, 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 { + let accounts_service = &services.accounts; + accounts_service + .create_account(request) + .await + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..b1a2bf2 --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -0,0 +1,5 @@ +mod account; +mod transactions; + +pub use account::*; +pub use transactions::*; diff --git a/src-tauri/src/commands/transactions.rs b/src-tauri/src/commands/transactions.rs new file mode 100644 index 0000000..6241d19 --- /dev/null +++ b/src-tauri/src/commands/transactions.rs @@ -0,0 +1,36 @@ +#[tauri::command] +pub async fn get_transactions_for_account( + id: i64, + services: tauri::State<'_, crate::services::AppState>, +) -> Result, 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 { + 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()) +} diff --git a/src-tauri/src/db/entities/category.rs b/src-tauri/src/db/entities/category.rs new file mode 100644 index 0000000..5a636cf --- /dev/null +++ b/src-tauri/src/db/entities/category.rs @@ -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, + 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 for Entity { + fn to() -> RelationDef { + Relation::Transaction.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/mod.rs b/src-tauri/src/db/entities/mod.rs index d72fe24..dd10269 100644 --- a/src-tauri/src/db/entities/mod.rs +++ b/src-tauri/src/db/entities/mod.rs @@ -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; diff --git a/src-tauri/src/db/entities/prelude.rs b/src-tauri/src/db/entities/prelude.rs index 453e3eb..9045208 100644 --- a/src-tauri/src/db/entities/prelude.rs +++ b/src-tauri/src/db/entities/prelude.rs @@ -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; diff --git a/src-tauri/src/db/entities/tag.rs b/src-tauri/src/db/entities/tag.rs new file mode 100644 index 0000000..c66f575 --- /dev/null +++ b/src-tauri/src/db/entities/tag.rs @@ -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 for Entity { + fn to() -> RelationDef { + Relation::TransactionTag.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::transaction_tag::Relation::Transaction.def() + } + fn via() -> Option { + Some(super::transaction_tag::Relation::Tag.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/transaction.rs b/src-tauri/src/db/entities/transaction.rs new file mode 100644 index 0000000..8a3a660 --- /dev/null +++ b/src-tauri/src/db/entities/transaction.rs @@ -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, + pub transaction_date: String, + pub metadata: Option, + pub created_at: String, + pub updated_at: String, + pub account_id: i64, + pub from_account_id: Option, + pub category_id: Option, +} + +#[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 for Entity { + fn to() -> RelationDef { + Relation::Category.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::TransactionTag.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::transaction_tag::Relation::Tag.def() + } + fn via() -> Option { + Some(super::transaction_tag::Relation::Transaction.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/transaction_tag.rs b/src-tauri/src/db/entities/transaction_tag.rs new file mode 100644 index 0000000..96f815a --- /dev/null +++ b/src-tauri/src/db/entities/transaction_tag.rs @@ -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 for Entity { + fn to() -> RelationDef { + Relation::Tag.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Transaction.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index b338ed2..3b05485 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -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 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 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), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7b42cf3..d41d0a1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 7b00bcb..32b7087 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -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, - pub accounts: Arc, +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 +where + AC: AccountCategoryService + ?Sized, + A: AccountsService + ?Sized, + T: TransactionService + ?Sized, + TC: CategoryService + ?Sized, + TT: TagService + ?Sized, +{ + pub account_category: Arc, + pub accounts: Arc, + pub transaction: Arc, + pub transaction_category: Arc, + pub transaction_tag: Arc, } -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 = + Arc::new(AccountCategoryServiceImpl::new(db.clone())); + let account_service: Arc = Arc::new(AccountsServiceImpl::new( db.clone(), + Arc::new(AccountCategoryServiceImpl::new(db.clone())), )); + let transaction_category_service: Arc = + Arc::new(CategoryServiceImpl::new(db.clone())); + let transaction_tag_service: Arc = 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, + transaction_category: transaction_category_service, + transaction_tag: transaction_tag_service, } } diff --git a/src-tauri/src/services/transaction/category/dto.rs b/src-tauri/src/services/transaction/category/dto.rs new file mode 100644 index 0000000..de17f9f --- /dev/null +++ b/src-tauri/src/services/transaction/category/dto.rs @@ -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, + pub created_at: String, + pub updated_at: String, +} + +impl From 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, + pub parent: Option>, + pub created_at: String, + pub updated_at: String, +} + +impl From<(CategoryModel, Option)> for CategoryWithParent { + fn from((model, parent): (CategoryModel, Option)) -> 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, +} + +impl CreateCategoryRequest { + pub async fn validate( + &self, + category_repo: Arc, + ) -> 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 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, + pub color_code: Option, + pub parent_id: Option>, +} + +impl UpdateCategoryRequest { + pub async fn validate( + &self, + category_repo: Arc, + ) -> 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); + } + } +} diff --git a/src-tauri/src/services/transaction/category/mod.rs b/src-tauri/src/services/transaction/category/mod.rs new file mode 100644 index 0000000..df05e03 --- /dev/null +++ b/src-tauri/src/services/transaction/category/mod.rs @@ -0,0 +1,6 @@ +mod dto; +pub(super) mod repo; +mod service; + +pub use dto::*; +pub use service::*; diff --git a/src-tauri/src/services/transaction/category/repo.rs b/src-tauri/src/services/transaction/category/repo.rs new file mode 100644 index 0000000..3f8cdcd --- /dev/null +++ b/src-tauri/src/services/transaction/category/repo.rs @@ -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 = CategoryServiceResult; + +#[async_trait::async_trait] +pub trait CategoryRepoService: Send + Sync + 'static { + async fn get_categories(&self) -> Result>; + async fn get_category(&self, id: i64) -> Result>; + async fn create_category(&self, request: CreateCategoryRequest) -> Result; + async fn create_category_with_tx(&self, request: CreateCategoryRequest, tx: &Db) + -> Result; + 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 { + 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> { + 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> { + 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 { + self.create_category_with_tx(request, &(&self.db).into()) + .await + } + + async fn create_category_with_tx( + &self, + request: CreateCategoryRequest, + tx: &Db, + ) -> Result { + 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(()) + } +} diff --git a/src-tauri/src/services/transaction/category/service.rs b/src-tauri/src/services/transaction/category/service.rs new file mode 100644 index 0000000..1081894 --- /dev/null +++ b/src-tauri/src/services/transaction/category/service.rs @@ -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 for CategoryServiceError { + fn from(err: sea_orm::DbErr) -> Self { + CategoryServiceError::DbErr(DbServiceError::from(err)) + } +} + +pub type CategoryServiceResult = std::result::Result; + +type Result = CategoryServiceResult; + +#[async_trait::async_trait] +pub trait CategoryService: Send + Sync + 'static { + async fn get_categories(&self) -> Result>; + async fn get_category(&self, id: i64) -> Result>; + async fn create_category(&self, request: CreateCategoryRequest) -> Result; + async fn create_category_with_tx(&self, request: CreateCategoryRequest, tx: &Db) + -> Result; + 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 +where + T: CategoryRepoService + ?Sized, +{ + db: DatabaseConnection, + category_repo: Arc, +} + +impl CategoryServiceImpl { + pub fn new(db: DatabaseConnection) -> Self { + Self { + db: db.clone(), + category_repo: Arc::new(CategoryRepoServiceImpl { db }), + } + } +} + +#[async_trait::async_trait] +impl CategoryService for CategoryServiceImpl +where + T: CategoryRepoService + ?Sized, +{ + async fn get_categories(&self) -> Result> { + self.category_repo.get_categories().await + } + + async fn get_category(&self, id: i64) -> Result> { + self.category_repo.get_category(id).await + } + + async fn create_category(&self, request: CreateCategoryRequest) -> Result { + self.category_repo.create_category(request).await + } + + async fn create_category_with_tx( + &self, + request: CreateCategoryRequest, + tx: &Db, + ) -> Result { + 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 + } +} diff --git a/src-tauri/src/services/transaction/dto.rs b/src-tauri/src/services/transaction/dto.rs new file mode 100644 index 0000000..cb6f1ff --- /dev/null +++ b/src-tauri/src/services/transaction/dto.rs @@ -0,0 +1,381 @@ +// TODO: validation for all dto + +use std::sync::Arc; + +use crate::{ + db::{ + DbServiceError, + entities::{account::Column::Id, transaction::Model as TransactionModel}, + }, + services::transaction::{category::CategoryServiceError, tag::TagServiceError}, +}; + +use crate::db::{ + Db, + entities::transaction::{ActiveModel as TransactionActiveModel, Column::AccountId}, +}; +use sea_orm::{ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum TransactionServiceError { + #[error("database error: {0}")] + DbErr(#[from] DbServiceError), + #[error("Target not found: {0}")] + TargetNotFound(String), + #[error("Validation error: {0}")] + Validation(String), + #[error("unknown error: {0}")] + Unknown(String), +} + +impl From for TransactionServiceError { + fn from(err: sea_orm::DbErr) -> Self { + TransactionServiceError::DbErr(DbServiceError::from(err)) + } +} + +impl From for TransactionServiceError { + fn from(err: TagServiceError) -> Self { + match err { + TagServiceError::DbErr(db_err) => TransactionServiceError::DbErr(db_err), + TagServiceError::TargetNotFound(msg) => TransactionServiceError::TargetNotFound(msg), + TagServiceError::Validation(msg) => TransactionServiceError::Validation(msg), + TagServiceError::Unknown(msg) => TransactionServiceError::Unknown(msg), + } + } +} + +impl From for TransactionServiceError { + fn from(err: CategoryServiceError) -> Self { + match err { + CategoryServiceError::DbErr(db_err) => TransactionServiceError::DbErr(db_err), + CategoryServiceError::TargetNotFound(msg) => { + TransactionServiceError::TargetNotFound(msg) + } + CategoryServiceError::Validation(msg) => TransactionServiceError::Validation(msg), + CategoryServiceError::Unknown(msg) => TransactionServiceError::Unknown(msg), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TransactionType { + #[serde(rename = "STANDARD")] + Standard, + #[serde(rename = "RECONCILIATION")] + Reconciliation, + #[serde(rename = "OPENING_BALANCE")] + OpeningBalance, + #[serde(other, rename = "UNKNOWN")] + Unknown, +} + +impl From for TransactionType { + fn from(s: String) -> Self { + match serde_json::from_str::(&format!("\"{}\"", s)) { + Ok(t) => t, + Err(_) => TransactionType::Unknown, + } + } +} + +impl std::fmt::Display for TransactionType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + TransactionType::Standard => "STANDARD", + TransactionType::Reconciliation => "RECONCILIATION", + TransactionType::OpeningBalance => "OPENING_BALANCE", + TransactionType::Unknown => "UNKNOWN", + }; + write!(f, "{}", s) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Transaction { + pub id: i64, + pub account_id: i64, + pub amount: String, + pub transaction_type: TransactionType, + pub currency_code: String, + pub exchange_rate: Option, + pub transaction_date: String, + pub metadata: TransactionMetadata, + pub created_at: String, + pub updated_at: String, + pub from_account_id: Option, + pub category_id: Option, +} + +impl From for Transaction { + fn from(model: TransactionModel) -> Self { + Self { + id: model.id, + account_id: model.account_id, + amount: model.amount, + transaction_type: model.transaction_type.into(), + currency_code: model.currency_code, + exchange_rate: model.exchange_rate, + transaction_date: model.transaction_date, + metadata: TransactionMetadata::from(model.metadata), + created_at: model.created_at.to_string(), + updated_at: model.updated_at.to_string(), + from_account_id: model.from_account_id, + category_id: model.category_id, + } + } +} + +#[derive(Default, Debug, Clone, Deserialize, Serialize)] +pub struct TransactionMetadata {} + +impl From for TransactionMetadata { + fn from(s: String) -> Self { + serde_json::from_str(&s).unwrap_or_default() + } +} + +impl From> for TransactionMetadata { + fn from(s: Option) -> Self { + s.map(|s| serde_json::from_str(&s).unwrap_or_default()) + .unwrap_or_default() + } +} + +impl TransactionMetadata { + pub fn validate(&self) -> Result<(), TransactionServiceError> { + Ok(()) + } +} + +async fn validate_account_exists( + account_id: i64, + account_service: Arc, +) -> Result<(), TransactionServiceError> +where + T: crate::services::accounts::AccountsService + ?Sized, +{ + let account_exists = account_service + .get_account(&account_id) + .await + // TODO: add error from account service to error chain + .map_err(|err| TransactionServiceError::Unknown(err))? + .is_some(); + if !account_exists { + return Err(TransactionServiceError::Validation(format!( + "Account with id {} does not exist", + account_id + ))); + } + Ok(()) +} + +async fn validate_category_exists( + category_id: i64, + category_service: Arc, +) -> Result<(), TransactionServiceError> +where + T: crate::services::CategoryService + ?Sized, +{ + let category_exists = category_service.get_category(category_id).await?.is_some(); + if !category_exists { + return Err(TransactionServiceError::Validation(format!( + "Category with id {} does not exist", + category_id + ))); + } + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateTransactionRequest { + pub account_id: i64, + pub amount: String, + pub transaction_type: TransactionType, + pub currency_code: String, + pub exchange_rate: Option, + pub transaction_date: String, + pub metadata: Option, + pub from_account_id: Option, + pub category_id: Option, +} + +impl CreateTransactionRequest { + pub async fn validate( + &self, + account_service: Arc, + category_service: Arc, + ) -> Result<(), TransactionServiceError> + where + T: crate::services::accounts::AccountsService + ?Sized, + U: crate::services::CategoryService + ?Sized, + { + if self.amount.trim().is_empty() { + return Err(TransactionServiceError::Validation( + "Amount cannot be empty".to_string(), + )); + } + // check if amount is a valid decimal + if self.amount.parse::().is_err() { + return Err(TransactionServiceError::Validation( + "Amount must be a valid decimal".to_string(), + )); + } + if self.currency_code.trim().is_empty() { + return Err(TransactionServiceError::Validation( + "Currency code cannot be empty".to_string(), + )); + } + if self.transaction_date.trim().is_empty() { + return Err(TransactionServiceError::Validation( + "Transaction date cannot be empty".to_string(), + )); + } + // check if transaction_date is a valid date + if chrono::DateTime::parse_from_rfc3339(&self.transaction_date).is_err() { + return Err(TransactionServiceError::Validation( + "Transaction date must be a valid RFC3339 date".to_string(), + )); + } + + if let Some(metadata) = &self.metadata { + metadata.validate()?; + } + + validate_account_exists(self.account_id, account_service).await?; + if let Some(category_id) = self.category_id { + validate_category_exists(category_id, category_service).await?; + } + Ok(()) + } +} + +impl From for TransactionActiveModel { + fn from(request: CreateTransactionRequest) -> Self { + Self { + account_id: Set(request.account_id), + amount: Set(request.amount), + transaction_type: Set(request.transaction_type.to_string()), + currency_code: Set(request.currency_code), + exchange_rate: Set(request.exchange_rate), + transaction_date: Set(request.transaction_date), + metadata: Set(request + .metadata + .map(|m| serde_json::to_string(&m).unwrap_or_default())), + from_account_id: Set(request.from_account_id), + category_id: Set(request.category_id), + created_at: Set(chrono::Utc::now().to_rfc3339()), + updated_at: Set(chrono::Utc::now().to_rfc3339()), + ..Default::default() + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateTransactionRequest { + pub account_id: Option, + pub amount: Option, + pub transaction_type: Option, + pub currency_code: Option, + pub exchange_rate: Option>, + pub transaction_date: Option, + pub metadata: Option>, + pub from_account_id: Option>, + pub category_id: Option>, +} + +impl UpdateTransactionRequest { + pub fn apply_to(self, transaction: &mut TransactionActiveModel) { + if let Some(account_id) = self.account_id { + transaction.account_id = Set(account_id); + } + if let Some(amount) = self.amount { + transaction.amount = Set(amount); + } + if let Some(transaction_type) = self.transaction_type { + transaction.transaction_type = Set(transaction_type.to_string()); + } + if let Some(currency_code) = self.currency_code { + transaction.currency_code = Set(currency_code); + } + if let Some(exchange_rate) = self.exchange_rate { + transaction.exchange_rate = Set(exchange_rate); + } + if let Some(transaction_date) = self.transaction_date { + transaction.transaction_date = Set(transaction_date); + } + if let Some(metadata) = self.metadata { + transaction.metadata = + Set(metadata.map(|m| serde_json::to_string(&m).unwrap_or_default())); + } + if let Some(from_account_id) = self.from_account_id { + transaction.from_account_id = Set(from_account_id); + } + if let Some(category_id) = self.category_id { + transaction.category_id = Set(category_id); + } + } + + pub async fn validate( + &self, + account_service: Arc, + category_service: Arc, + ) -> Result<(), TransactionServiceError> + where + T: crate::services::accounts::AccountsService + ?Sized, + U: crate::services::CategoryService + ?Sized, + { + if let Some(amount) = self.amount.as_ref() + && amount.trim().is_empty() + { + return Err(TransactionServiceError::Validation( + "Amount cannot be empty".to_string(), + )); + } + // check if amount is a valid decimal + if let Some(amount) = self.amount.as_ref() + && amount.parse::().is_err() + { + return Err(TransactionServiceError::Validation( + "Amount must be a valid decimal".to_string(), + )); + } + if let Some(currency_code) = self.currency_code.as_ref() + && currency_code.trim().is_empty() + { + return Err(TransactionServiceError::Validation( + "Currency code cannot be empty".to_string(), + )); + } + if let Some(transaction_date) = self.transaction_date.as_ref() + && transaction_date.trim().is_empty() + { + return Err(TransactionServiceError::Validation( + "Transaction date cannot be empty".to_string(), + )); + } + // check if transaction_date is a valid date + if let Some(transaction_date) = self.transaction_date.as_ref() + && chrono::DateTime::parse_from_rfc3339(transaction_date).is_err() + { + return Err(TransactionServiceError::Validation( + "Transaction date must be a valid RFC3339 date".to_string(), + )); + } + if let Some(Some(metadata)) = self.metadata.as_ref() { + metadata.validate()?; + } + + if let Some(account_id) = self.account_id { + validate_account_exists(account_id, account_service).await?; + } + if let Some(Some(category_id)) = self.category_id { + validate_category_exists(category_id, category_service).await?; + } + + Ok(()) + } +} diff --git a/src-tauri/src/services/transaction/mod.rs b/src-tauri/src/services/transaction/mod.rs new file mode 100644 index 0000000..25c2222 --- /dev/null +++ b/src-tauri/src/services/transaction/mod.rs @@ -0,0 +1,9 @@ +pub mod category; +mod dto; +mod repo; +mod service; +pub mod tag; + +// re-export as a single module +pub use dto::*; +pub use service::*; diff --git a/src-tauri/src/services/transaction/repo.rs b/src-tauri/src/services/transaction/repo.rs new file mode 100644 index 0000000..fb00a41 --- /dev/null +++ b/src-tauri/src/services/transaction/repo.rs @@ -0,0 +1,199 @@ +use std::sync::Arc; + +use crate::{ + db::{ + Db, + entities::transaction::{ + ActiveModel as TransactionActiveModel, Column::AccountId, Entity as TransactionEntity, + }, + }, + services::transaction::{ + CreateTransactionRequest, Transaction, TransactionResult, TransactionServiceError, + UpdateTransactionRequest, category::repo::CategoryRepoServiceImpl, + tag::repo::TagRepoServiceImpl, + }, +}; +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, + TransactionTrait, +}; + +type Result = TransactionResult; + +#[async_trait::async_trait] +pub trait TransactionRepoService: Send + Sync + 'static { + async fn get_transaction(&self, id: i64) -> Result>; + // TODO: pagination + // TODO: filter + // TODO: sort + async fn get_transactions_by_account(&self, account_id: i64) -> Result>; + async fn create_transaction(&self, request: CreateTransactionRequest) -> Result; + async fn create_transaction_with_tx( + &self, + request: CreateTransactionRequest, + tx: &Db, + ) -> Result; + async fn update_transaction(&self, id: i64, request: UpdateTransactionRequest) -> Result<()>; + async fn update_transaction_with_tx( + &self, + id: i64, + request: UpdateTransactionRequest, + tx: &Db, + ) -> Result<()>; + async fn delete_transaction(&self, id: i64) -> Result<()>; +} + +pub struct TransactionRepoServiceImpl +where + T: super::super::accounts::AccountsService + ?Sized, + U: super::category::CategoryService + ?Sized, + K: super::tag::TagService + ?Sized, +{ + db: DatabaseConnection, + account_service: Arc, + category_service: Arc, + tag_service: Arc, +} + +impl TransactionRepoServiceImpl +where + T: super::super::accounts::AccountsService + ?Sized, + U: super::category::CategoryService + ?Sized, + K: super::tag::TagService + ?Sized, +{ + pub fn new( + db: DatabaseConnection, + account_service: Arc, + category_service: Arc, + tag_service: Arc, + ) -> Self { + Self { + db: db.clone(), + account_service, + tag_service, + category_service, + } + } +} + +#[async_trait::async_trait] +impl TransactionRepoService for TransactionRepoServiceImpl +where + T: super::super::accounts::AccountsService + ?Sized, + U: super::category::CategoryService + ?Sized, + K: super::tag::TagService + ?Sized, +{ + async fn get_transaction(&self, id: i64) -> Result> { + let transaction = TransactionEntity::find_by_id(id) + .one(&self.db) + .await? + .map(|model| model.into()); + Ok(transaction) + } + + async fn get_transactions_by_account(&self, account_id: i64) -> Result> { + let transactions = TransactionEntity::find() + .filter(AccountId.eq(account_id)) + .all(&self.db) + .await? + .into_iter() + .map(|model| model.into()) + .collect::>(); + Ok(transactions) + } + + async fn create_transaction(&self, request: CreateTransactionRequest) -> Result { + self.create_transaction_with_tx(request, &(&self.db).into()) + .await + } + + async fn create_transaction_with_tx( + &self, + request: CreateTransactionRequest, + outer_tx: &Db, + ) -> Result { + let inner_tx = match outer_tx { + Db::Connection(conn) => Some(conn.begin().await?), + Db::Transaction(_) => None, + }; + let tx = inner_tx.as_ref().map_or_else( + || match outer_tx { + Db::Connection(conn) => Db::Connection(conn), + Db::Transaction(tx) => Db::Transaction(tx), + }, + Db::Transaction, + ); + + request + .validate(self.account_service.clone(), self.category_service.clone()) + .await?; + + let new_transaction: TransactionActiveModel = request.into(); + let res = new_transaction.insert(&tx).await?; + + if let Some(inner_tx) = inner_tx { + inner_tx.commit().await?; + } + Ok(res.id) + } + + async fn update_transaction(&self, id: i64, request: UpdateTransactionRequest) -> Result<()> { + self.update_transaction_with_tx(id, request, &(&self.db).into()) + .await + } + + async fn update_transaction_with_tx( + &self, + id: i64, + request: UpdateTransactionRequest, + outer_tx: &Db, + ) -> Result<()> { + let inner_tx = match outer_tx { + Db::Connection(conn) => Some(conn.begin().await?), + Db::Transaction(_) => None, + }; + let tx = inner_tx.as_ref().map_or_else( + || match outer_tx { + Db::Connection(conn) => Db::Connection(conn), + Db::Transaction(tx) => Db::Transaction(tx), + }, + Db::Transaction, + ); + // + request + .validate(self.account_service.clone(), self.category_service.clone()) + .await?; + let transaction = TransactionEntity::find_by_id(id) + .one(&tx) + .await? + .ok_or_else(|| { + TransactionServiceError::TargetNotFound(format!( + "Transaction with id {} not found", + id + )) + })?; + let mut transaction: TransactionActiveModel = transaction.into(); + request.apply_to(&mut transaction); + transaction.updated_at = Set(chrono::Utc::now().to_rfc3339()); + + transaction.update(&tx).await?; + // + if let Some(inner_tx) = inner_tx { + inner_tx.commit().await?; + } + Ok(()) + } + + async fn delete_transaction(&self, id: i64) -> Result<()> { + // delete the tag-transaction relations + let tx = self.db.begin().await?; + self.tag_service + .update_tags_for_transaction_with_tx(id, vec![], &(&tx).into()) + .await?; + // delete the transaction itself + TransactionEntity::delete_by_id(id).exec(&tx).await?; + tx.commit().await?; + + Ok(()) + } +} diff --git a/src-tauri/src/services/transaction/service.rs b/src-tauri/src/services/transaction/service.rs new file mode 100644 index 0000000..9f1a469 --- /dev/null +++ b/src-tauri/src/services/transaction/service.rs @@ -0,0 +1,115 @@ +use std::sync::Arc; + +use sea_orm::DatabaseConnection; + +use crate::{ + db::Db, + services::transaction::{ + CreateTransactionRequest, Transaction, TransactionServiceError, UpdateTransactionRequest, + repo::TransactionRepoService, + }, +}; + +pub type TransactionResult = std::result::Result; +type Result = TransactionResult; + +#[async_trait::async_trait] +pub trait TransactionService: Send + Sync + 'static { + async fn get_transaction(&self, id: i64) -> Result>; + // TODO: pagination + // TODO: filter + // TODO: sort + async fn get_transactions_by_account(&self, account_id: i64) -> Result>; + async fn create_transaction(&self, request: CreateTransactionRequest) -> Result; + async fn create_transaction_with_tx( + &self, + request: CreateTransactionRequest, + tx: &Db, + ) -> Result; + async fn update_transaction(&self, id: i64, request: UpdateTransactionRequest) -> Result<()>; + async fn update_transaction_with_tx( + &self, + id: i64, + request: UpdateTransactionRequest, + tx: &Db, + ) -> Result<()>; + async fn delete_transaction(&self, id: i64) -> Result<()>; +} + +pub struct TransactionServiceImpl +where + T: super::repo::TransactionRepoService + ?Sized, +{ + db: DatabaseConnection, + repo: Arc, +} + +impl TransactionServiceImpl> +where + T: super::super::accounts::AccountsService + ?Sized, + U: super::category::CategoryService + ?Sized, + K: super::tag::TagService + ?Sized, +{ + pub fn new( + db: DatabaseConnection, + account_service: Arc, + category_service: Arc, + tag_service: Arc, + ) -> Self { + Self { + db: db.clone(), + repo: Arc::new(super::repo::TransactionRepoServiceImpl::new( + db, + account_service, + category_service, + tag_service, + )), + } + } +} + +#[async_trait::async_trait] +impl TransactionService + for TransactionServiceImpl> +where + T: super::super::accounts::AccountsService + ?Sized, + U: super::category::CategoryService + ?Sized, + K: super::tag::TagService + ?Sized, +{ + async fn get_transaction(&self, id: i64) -> Result> { + self.repo.get_transaction(id).await + } + + async fn get_transactions_by_account(&self, account_id: i64) -> Result> { + self.repo.get_transactions_by_account(account_id).await + } + + async fn create_transaction(&self, request: CreateTransactionRequest) -> Result { + self.repo.create_transaction(request).await + } + + async fn create_transaction_with_tx( + &self, + request: CreateTransactionRequest, + tx: &Db, + ) -> Result { + self.repo.create_transaction_with_tx(request, tx).await + } + + async fn update_transaction(&self, id: i64, request: UpdateTransactionRequest) -> Result<()> { + self.repo.update_transaction(id, request).await + } + + async fn update_transaction_with_tx( + &self, + id: i64, + request: UpdateTransactionRequest, + tx: &Db, + ) -> Result<()> { + self.repo.update_transaction_with_tx(id, request, tx).await + } + + async fn delete_transaction(&self, id: i64) -> Result<()> { + self.repo.delete_transaction(id).await + } +} diff --git a/src-tauri/src/services/transaction/tag/dto.rs b/src-tauri/src/services/transaction/tag/dto.rs new file mode 100644 index 0000000..0fa8587 --- /dev/null +++ b/src-tauri/src/services/transaction/tag/dto.rs @@ -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 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 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, + pub color_code: Option, +} + +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()); + } + } +} diff --git a/src-tauri/src/services/transaction/tag/mod.rs b/src-tauri/src/services/transaction/tag/mod.rs new file mode 100644 index 0000000..df05e03 --- /dev/null +++ b/src-tauri/src/services/transaction/tag/mod.rs @@ -0,0 +1,6 @@ +mod dto; +pub(super) mod repo; +mod service; + +pub use dto::*; +pub use service::*; diff --git a/src-tauri/src/services/transaction/tag/repo.rs b/src-tauri/src/services/transaction/tag/repo.rs new file mode 100644 index 0000000..178aa2b --- /dev/null +++ b/src-tauri/src/services/transaction/tag/repo.rs @@ -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 = TagServiceResult; + +#[async_trait::async_trait] +pub trait TagRepoService: Send + Sync + 'static { + async fn get_tags(&self) -> Result>; + async fn get_tag(&self, id: i64) -> Result>; + async fn create_tag(&self, request: CreateTagRequest) -> Result; + async fn create_tag_with_tx(&self, request: CreateTagRequest, tx: &Db) -> Result; + 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, + ) -> Result<()>; + async fn update_tags_for_transaction_with_tx( + &self, + transaction_id: i64, + new_tag_ids: Vec, + 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> { + 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> { + 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 { + self.create_tag_with_tx(request, &(&self.db).into()).await + } + + async fn create_tag_with_tx(&self, request: CreateTagRequest, tx: &Db) -> Result { + 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, + ) -> 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, + 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::>(); + TransactionTagEntity::insert_many(models).exec(&tx).await?; + + if let Some(inner_tx) = inner_tx { + inner_tx.commit().await?; + } + Ok(()) + } +} diff --git a/src-tauri/src/services/transaction/tag/service.rs b/src-tauri/src/services/transaction/tag/service.rs new file mode 100644 index 0000000..b00c743 --- /dev/null +++ b/src-tauri/src/services/transaction/tag/service.rs @@ -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 for TagServiceError { + fn from(err: sea_orm::DbErr) -> Self { + TagServiceError::DbErr(DbServiceError::from(err)) + } +} + +pub type TagServiceResult = std::result::Result; +type Result = TagServiceResult; + +#[async_trait::async_trait] +pub trait TagService: Send + Sync + 'static { + async fn get_tags(&self) -> Result>; + async fn get_tag(&self, id: i64) -> Result>; + async fn create_tag(&self, request: CreateTagRequest) -> Result; + async fn create_tag_with_tx(&self, request: CreateTagRequest, tx: &Db) -> Result; + 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, + ) -> Result<()>; + async fn update_tags_for_transaction_with_tx( + &self, + transaction_id: i64, + new_tag_ids: Vec, + tx: &Db, + ) -> Result<()>; +} + +pub struct TagServiceImpl +where + T: TagRepoService + ?Sized, +{ + db: DatabaseConnection, + tag_repo: Arc, +} + +impl TagServiceImpl { + pub fn new(db: DatabaseConnection) -> Self { + Self { + db: db.clone(), + tag_repo: Arc::new(TagRepoServiceImpl { db }), + } + } +} + +#[async_trait::async_trait] +impl TagService for TagServiceImpl +where + T: TagRepoService + ?Sized, +{ + async fn get_tags(&self) -> Result> { + self.tag_repo.get_tags().await + } + + async fn get_tag(&self, id: i64) -> Result> { + self.tag_repo.get_tag(id).await + } + + async fn create_tag(&self, request: CreateTagRequest) -> Result { + self.tag_repo.create_tag(request).await + } + + async fn create_tag_with_tx(&self, request: CreateTagRequest, tx: &Db) -> Result { + 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, + ) -> 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, + tx: &Db, + ) -> Result<()> { + self.tag_repo + .update_tags_for_transaction_with_tx(transaction_id, new_tag_ids, tx) + .await + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ac260dd..2e61767 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -13,8 +13,8 @@ "windows": [ { "title": "otter", - "width": 800, - "height": 600 + "width": 390, + "height": 760 } ], "security": { @@ -32,4 +32,4 @@ "icons/icon.ico" ] } -} +} \ No newline at end of file