Merge branch 'feature/transaction'
This commit is contained in:
29
Cargo.lock
generated
29
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
34
src-tauri/src/commands/account.rs
Normal file
34
src-tauri/src/commands/account.rs
Normal 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())
|
||||
}
|
||||
5
src-tauri/src/commands/mod.rs
Normal file
5
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod account;
|
||||
mod transactions;
|
||||
|
||||
pub use account::*;
|
||||
pub use transactions::*;
|
||||
36
src-tauri/src/commands/transactions.rs
Normal file
36
src-tauri/src/commands/transactions.rs
Normal 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())
|
||||
}
|
||||
39
src-tauri/src/db/entities/category.rs
Normal file
39
src-tauri/src/db/entities/category.rs
Normal 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 {}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
40
src-tauri/src/db/entities/tag.rs
Normal file
40
src-tauri/src/db/entities/tag.rs
Normal 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 {}
|
||||
76
src-tauri/src/db/entities/transaction.rs
Normal file
76
src-tauri/src/db/entities/transaction.rs
Normal 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 {}
|
||||
48
src-tauri/src/db/entities/transaction_tag.rs
Normal file
48
src-tauri/src/db/entities/transaction_tag.rs
Normal 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 {}
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
165
src-tauri/src/services/transaction/category/dto.rs
Normal file
165
src-tauri/src/services/transaction/category/dto.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
6
src-tauri/src/services/transaction/category/mod.rs
Normal file
6
src-tauri/src/services/transaction/category/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod dto;
|
||||
pub(super) mod repo;
|
||||
mod service;
|
||||
|
||||
pub use dto::*;
|
||||
pub use service::*;
|
||||
124
src-tauri/src/services/transaction/category/repo.rs
Normal file
124
src-tauri/src/services/transaction/category/repo.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
115
src-tauri/src/services/transaction/category/service.rs
Normal file
115
src-tauri/src/services/transaction/category/service.rs
Normal 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
|
||||
}
|
||||
}
|
||||
381
src-tauri/src/services/transaction/dto.rs
Normal file
381
src-tauri/src/services/transaction/dto.rs
Normal file
@@ -0,0 +1,381 @@
|
||||
// TODO: validation for all dto
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
db::{
|
||||
DbServiceError,
|
||||
entities::{account::Column::Id, transaction::Model as TransactionModel},
|
||||
},
|
||||
services::transaction::{category::CategoryServiceError, tag::TagServiceError},
|
||||
};
|
||||
|
||||
use crate::db::{
|
||||
Db,
|
||||
entities::transaction::{ActiveModel as TransactionActiveModel, Column::AccountId},
|
||||
};
|
||||
use sea_orm::{ActiveValue::Set, ColumnTrait, EntityTrait, QueryFilter};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum TransactionServiceError {
|
||||
#[error("database error: {0}")]
|
||||
DbErr(#[from] DbServiceError),
|
||||
#[error("Target not found: {0}")]
|
||||
TargetNotFound(String),
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
#[error("unknown error: {0}")]
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<sea_orm::DbErr> for TransactionServiceError {
|
||||
fn from(err: sea_orm::DbErr) -> Self {
|
||||
TransactionServiceError::DbErr(DbServiceError::from(err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TagServiceError> for TransactionServiceError {
|
||||
fn from(err: TagServiceError) -> Self {
|
||||
match err {
|
||||
TagServiceError::DbErr(db_err) => TransactionServiceError::DbErr(db_err),
|
||||
TagServiceError::TargetNotFound(msg) => TransactionServiceError::TargetNotFound(msg),
|
||||
TagServiceError::Validation(msg) => TransactionServiceError::Validation(msg),
|
||||
TagServiceError::Unknown(msg) => TransactionServiceError::Unknown(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CategoryServiceError> for TransactionServiceError {
|
||||
fn from(err: CategoryServiceError) -> Self {
|
||||
match err {
|
||||
CategoryServiceError::DbErr(db_err) => TransactionServiceError::DbErr(db_err),
|
||||
CategoryServiceError::TargetNotFound(msg) => {
|
||||
TransactionServiceError::TargetNotFound(msg)
|
||||
}
|
||||
CategoryServiceError::Validation(msg) => TransactionServiceError::Validation(msg),
|
||||
CategoryServiceError::Unknown(msg) => TransactionServiceError::Unknown(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum TransactionType {
|
||||
#[serde(rename = "STANDARD")]
|
||||
Standard,
|
||||
#[serde(rename = "RECONCILIATION")]
|
||||
Reconciliation,
|
||||
#[serde(rename = "OPENING_BALANCE")]
|
||||
OpeningBalance,
|
||||
#[serde(other, rename = "UNKNOWN")]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl From<String> for TransactionType {
|
||||
fn from(s: String) -> Self {
|
||||
match serde_json::from_str::<TransactionType>(&format!("\"{}\"", s)) {
|
||||
Ok(t) => t,
|
||||
Err(_) => TransactionType::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TransactionType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
TransactionType::Standard => "STANDARD",
|
||||
TransactionType::Reconciliation => "RECONCILIATION",
|
||||
TransactionType::OpeningBalance => "OPENING_BALANCE",
|
||||
TransactionType::Unknown => "UNKNOWN",
|
||||
};
|
||||
write!(f, "{}", s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Transaction {
|
||||
pub id: i64,
|
||||
pub account_id: i64,
|
||||
pub amount: String,
|
||||
pub transaction_type: TransactionType,
|
||||
pub currency_code: String,
|
||||
pub exchange_rate: Option<String>,
|
||||
pub transaction_date: String,
|
||||
pub metadata: TransactionMetadata,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub from_account_id: Option<i64>,
|
||||
pub category_id: Option<i64>,
|
||||
}
|
||||
|
||||
impl From<TransactionModel> for Transaction {
|
||||
fn from(model: TransactionModel) -> Self {
|
||||
Self {
|
||||
id: model.id,
|
||||
account_id: model.account_id,
|
||||
amount: model.amount,
|
||||
transaction_type: model.transaction_type.into(),
|
||||
currency_code: model.currency_code,
|
||||
exchange_rate: model.exchange_rate,
|
||||
transaction_date: model.transaction_date,
|
||||
metadata: TransactionMetadata::from(model.metadata),
|
||||
created_at: model.created_at.to_string(),
|
||||
updated_at: model.updated_at.to_string(),
|
||||
from_account_id: model.from_account_id,
|
||||
category_id: model.category_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct TransactionMetadata {}
|
||||
|
||||
impl From<String> for TransactionMetadata {
|
||||
fn from(s: String) -> Self {
|
||||
serde_json::from_str(&s).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Option<String>> for TransactionMetadata {
|
||||
fn from(s: Option<String>) -> Self {
|
||||
s.map(|s| serde_json::from_str(&s).unwrap_or_default())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
impl TransactionMetadata {
|
||||
pub fn validate(&self) -> Result<(), TransactionServiceError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn validate_account_exists<T>(
|
||||
account_id: i64,
|
||||
account_service: Arc<T>,
|
||||
) -> Result<(), TransactionServiceError>
|
||||
where
|
||||
T: crate::services::accounts::AccountsService + ?Sized,
|
||||
{
|
||||
let account_exists = account_service
|
||||
.get_account(&account_id)
|
||||
.await
|
||||
// TODO: add error from account service to error chain
|
||||
.map_err(|err| TransactionServiceError::Unknown(err))?
|
||||
.is_some();
|
||||
if !account_exists {
|
||||
return Err(TransactionServiceError::Validation(format!(
|
||||
"Account with id {} does not exist",
|
||||
account_id
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn validate_category_exists<T>(
|
||||
category_id: i64,
|
||||
category_service: Arc<T>,
|
||||
) -> Result<(), TransactionServiceError>
|
||||
where
|
||||
T: crate::services::CategoryService + ?Sized,
|
||||
{
|
||||
let category_exists = category_service.get_category(category_id).await?.is_some();
|
||||
if !category_exists {
|
||||
return Err(TransactionServiceError::Validation(format!(
|
||||
"Category with id {} does not exist",
|
||||
category_id
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateTransactionRequest {
|
||||
pub account_id: i64,
|
||||
pub amount: String,
|
||||
pub transaction_type: TransactionType,
|
||||
pub currency_code: String,
|
||||
pub exchange_rate: Option<String>,
|
||||
pub transaction_date: String,
|
||||
pub metadata: Option<TransactionMetadata>,
|
||||
pub from_account_id: Option<i64>,
|
||||
pub category_id: Option<i64>,
|
||||
}
|
||||
|
||||
impl CreateTransactionRequest {
|
||||
pub async fn validate<T, U>(
|
||||
&self,
|
||||
account_service: Arc<T>,
|
||||
category_service: Arc<U>,
|
||||
) -> Result<(), TransactionServiceError>
|
||||
where
|
||||
T: crate::services::accounts::AccountsService + ?Sized,
|
||||
U: crate::services::CategoryService + ?Sized,
|
||||
{
|
||||
if self.amount.trim().is_empty() {
|
||||
return Err(TransactionServiceError::Validation(
|
||||
"Amount cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
// check if amount is a valid decimal
|
||||
if self.amount.parse::<rust_decimal::Decimal>().is_err() {
|
||||
return Err(TransactionServiceError::Validation(
|
||||
"Amount must be a valid decimal".to_string(),
|
||||
));
|
||||
}
|
||||
if self.currency_code.trim().is_empty() {
|
||||
return Err(TransactionServiceError::Validation(
|
||||
"Currency code cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if self.transaction_date.trim().is_empty() {
|
||||
return Err(TransactionServiceError::Validation(
|
||||
"Transaction date cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
// check if transaction_date is a valid date
|
||||
if chrono::DateTime::parse_from_rfc3339(&self.transaction_date).is_err() {
|
||||
return Err(TransactionServiceError::Validation(
|
||||
"Transaction date must be a valid RFC3339 date".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(metadata) = &self.metadata {
|
||||
metadata.validate()?;
|
||||
}
|
||||
|
||||
validate_account_exists(self.account_id, account_service).await?;
|
||||
if let Some(category_id) = self.category_id {
|
||||
validate_category_exists(category_id, category_service).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CreateTransactionRequest> for TransactionActiveModel {
|
||||
fn from(request: CreateTransactionRequest) -> Self {
|
||||
Self {
|
||||
account_id: Set(request.account_id),
|
||||
amount: Set(request.amount),
|
||||
transaction_type: Set(request.transaction_type.to_string()),
|
||||
currency_code: Set(request.currency_code),
|
||||
exchange_rate: Set(request.exchange_rate),
|
||||
transaction_date: Set(request.transaction_date),
|
||||
metadata: Set(request
|
||||
.metadata
|
||||
.map(|m| serde_json::to_string(&m).unwrap_or_default())),
|
||||
from_account_id: Set(request.from_account_id),
|
||||
category_id: Set(request.category_id),
|
||||
created_at: Set(chrono::Utc::now().to_rfc3339()),
|
||||
updated_at: Set(chrono::Utc::now().to_rfc3339()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateTransactionRequest {
|
||||
pub account_id: Option<i64>,
|
||||
pub amount: Option<String>,
|
||||
pub transaction_type: Option<TransactionType>,
|
||||
pub currency_code: Option<String>,
|
||||
pub exchange_rate: Option<Option<String>>,
|
||||
pub transaction_date: Option<String>,
|
||||
pub metadata: Option<Option<TransactionMetadata>>,
|
||||
pub from_account_id: Option<Option<i64>>,
|
||||
pub category_id: Option<Option<i64>>,
|
||||
}
|
||||
|
||||
impl UpdateTransactionRequest {
|
||||
pub fn apply_to(self, transaction: &mut TransactionActiveModel) {
|
||||
if let Some(account_id) = self.account_id {
|
||||
transaction.account_id = Set(account_id);
|
||||
}
|
||||
if let Some(amount) = self.amount {
|
||||
transaction.amount = Set(amount);
|
||||
}
|
||||
if let Some(transaction_type) = self.transaction_type {
|
||||
transaction.transaction_type = Set(transaction_type.to_string());
|
||||
}
|
||||
if let Some(currency_code) = self.currency_code {
|
||||
transaction.currency_code = Set(currency_code);
|
||||
}
|
||||
if let Some(exchange_rate) = self.exchange_rate {
|
||||
transaction.exchange_rate = Set(exchange_rate);
|
||||
}
|
||||
if let Some(transaction_date) = self.transaction_date {
|
||||
transaction.transaction_date = Set(transaction_date);
|
||||
}
|
||||
if let Some(metadata) = self.metadata {
|
||||
transaction.metadata =
|
||||
Set(metadata.map(|m| serde_json::to_string(&m).unwrap_or_default()));
|
||||
}
|
||||
if let Some(from_account_id) = self.from_account_id {
|
||||
transaction.from_account_id = Set(from_account_id);
|
||||
}
|
||||
if let Some(category_id) = self.category_id {
|
||||
transaction.category_id = Set(category_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn validate<T, U>(
|
||||
&self,
|
||||
account_service: Arc<T>,
|
||||
category_service: Arc<U>,
|
||||
) -> Result<(), TransactionServiceError>
|
||||
where
|
||||
T: crate::services::accounts::AccountsService + ?Sized,
|
||||
U: crate::services::CategoryService + ?Sized,
|
||||
{
|
||||
if let Some(amount) = self.amount.as_ref()
|
||||
&& amount.trim().is_empty()
|
||||
{
|
||||
return Err(TransactionServiceError::Validation(
|
||||
"Amount cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
// check if amount is a valid decimal
|
||||
if let Some(amount) = self.amount.as_ref()
|
||||
&& amount.parse::<rust_decimal::Decimal>().is_err()
|
||||
{
|
||||
return Err(TransactionServiceError::Validation(
|
||||
"Amount must be a valid decimal".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(currency_code) = self.currency_code.as_ref()
|
||||
&& currency_code.trim().is_empty()
|
||||
{
|
||||
return Err(TransactionServiceError::Validation(
|
||||
"Currency code cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(transaction_date) = self.transaction_date.as_ref()
|
||||
&& transaction_date.trim().is_empty()
|
||||
{
|
||||
return Err(TransactionServiceError::Validation(
|
||||
"Transaction date cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
// check if transaction_date is a valid date
|
||||
if let Some(transaction_date) = self.transaction_date.as_ref()
|
||||
&& chrono::DateTime::parse_from_rfc3339(transaction_date).is_err()
|
||||
{
|
||||
return Err(TransactionServiceError::Validation(
|
||||
"Transaction date must be a valid RFC3339 date".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(Some(metadata)) = self.metadata.as_ref() {
|
||||
metadata.validate()?;
|
||||
}
|
||||
|
||||
if let Some(account_id) = self.account_id {
|
||||
validate_account_exists(account_id, account_service).await?;
|
||||
}
|
||||
if let Some(Some(category_id)) = self.category_id {
|
||||
validate_category_exists(category_id, category_service).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
9
src-tauri/src/services/transaction/mod.rs
Normal file
9
src-tauri/src/services/transaction/mod.rs
Normal 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::*;
|
||||
199
src-tauri/src/services/transaction/repo.rs
Normal file
199
src-tauri/src/services/transaction/repo.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
115
src-tauri/src/services/transaction/service.rs
Normal file
115
src-tauri/src/services/transaction/service.rs
Normal 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
|
||||
}
|
||||
}
|
||||
84
src-tauri/src/services/transaction/tag/dto.rs
Normal file
84
src-tauri/src/services/transaction/tag/dto.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
6
src-tauri/src/services/transaction/tag/mod.rs
Normal file
6
src-tauri/src/services/transaction/tag/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
mod dto;
|
||||
pub(super) mod repo;
|
||||
mod service;
|
||||
|
||||
pub use dto::*;
|
||||
pub use service::*;
|
||||
150
src-tauri/src/services/transaction/tag/repo.rs
Normal file
150
src-tauri/src/services/transaction/tag/repo.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
128
src-tauri/src/services/transaction/tag/service.rs
Normal file
128
src-tauri/src/services/transaction/tag/service.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,8 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "otter",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
"width": 390,
|
||||
"height": 760
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
Reference in New Issue
Block a user