From ea16cc2d55f0e47b30f0194d9914fcd36aa11b9d Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Thu, 28 May 2026 04:00:01 +0000 Subject: [PATCH] feat: add migration for transaction, category, and tag tables with foreign key constraints --- crates/migration/src/lib.rs | 3 +- ...260526_110957_create_transcations_table.rs | 253 ++++++++++++++++++ src-tauri/src/db/entities/category.rs | 39 +++ src-tauri/src/db/entities/mod.rs | 4 + src-tauri/src/db/entities/prelude.rs | 4 + src-tauri/src/db/entities/tag.rs | 40 +++ src-tauri/src/db/entities/transaction.rs | 76 ++++++ src-tauri/src/db/entities/transaction_tag.rs | 48 ++++ src-tauri/src/db/mod.rs | 52 +++- 9 files changed, 517 insertions(+), 2 deletions(-) create mode 100644 crates/migration/src/m20260526_110957_create_transcations_table.rs create mode 100644 src-tauri/src/db/entities/category.rs create mode 100644 src-tauri/src/db/entities/tag.rs create mode 100644 src-tauri/src/db/entities/transaction.rs create mode 100644 src-tauri/src/db/entities/transaction_tag.rs 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/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),