Compare commits
1 Commits
feature/ex
...
backend
| Author | SHA1 | Date | |
|---|---|---|---|
| 17add847a0 |
200
src-tauri/src/commands/accounts.rs
Normal file
200
src-tauri/src/commands/accounts.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
use crate::entities::{account, prelude::*};
|
||||
use crate::error::{AppError, CommandResult};
|
||||
use chrono::Utc;
|
||||
use sea_orm::{entity::*, query::*, DatabaseConnection, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateAccountInput {
|
||||
pub name: String,
|
||||
pub account_type: String,
|
||||
pub currency: String,
|
||||
pub initial_balance: String,
|
||||
pub color: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateAccountInput {
|
||||
pub name: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub sort_order: Option<i32>,
|
||||
pub is_active: Option<bool>,
|
||||
pub include_in_net_worth: Option<bool>,
|
||||
pub show_in_combined_view: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AccountBalance {
|
||||
pub amount: String,
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_account(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
input: CreateAccountInput,
|
||||
) -> CommandResult<account::Model> {
|
||||
let now = Utc::now();
|
||||
|
||||
let account = account::ActiveModel {
|
||||
id: Set(Uuid::new_v4().to_string()),
|
||||
name: Set(input.name),
|
||||
account_type: Set(input.account_type),
|
||||
currency: Set(input.currency),
|
||||
initial_balance: Set(input.initial_balance.clone()),
|
||||
current_balance: Set(input.initial_balance),
|
||||
color: Set(input.color.or(Some("#3B82F6".to_string()))),
|
||||
icon: Set(input.icon.or(Some("wallet".to_string()))),
|
||||
sort_order: Set(0),
|
||||
is_active: Set(true),
|
||||
is_archived: Set(false),
|
||||
include_in_net_worth: Set(true),
|
||||
show_in_combined_view: Set(true),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
version: Set(1),
|
||||
device_id: Set(None),
|
||||
is_deleted: Set(false),
|
||||
};
|
||||
|
||||
let result = account.insert(db.inner()).await.map_err(|e| e.to_string())?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_accounts(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
include_archived: Option<bool>,
|
||||
) -> CommandResult<Vec<account::Model>> {
|
||||
let mut query = Account::find()
|
||||
.filter(account::Column::IsDeleted.eq(false));
|
||||
|
||||
if !include_archived.unwrap_or(false) {
|
||||
query = query.filter(account::Column::IsArchived.eq(false));
|
||||
}
|
||||
|
||||
let accounts = query
|
||||
.order_by_asc(account::Column::SortOrder)
|
||||
.all(db.inner())
|
||||
.await.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(accounts)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_account(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
id: String,
|
||||
) -> CommandResult<account::Model> {
|
||||
let account = Account::find_by_id(id.clone())
|
||||
.one(db.inner())
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.ok_or(format!("Account with id {} not found", id))?;
|
||||
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_account(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
id: String,
|
||||
updates: UpdateAccountInput,
|
||||
) -> CommandResult<account::Model> {
|
||||
let account = Account::find_by_id(id.clone())
|
||||
.one(db.inner())
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.ok_or(format!("Account with id {} not found", id))?;
|
||||
|
||||
let mut active_model: account::ActiveModel = account.into();
|
||||
|
||||
if let Some(name) = updates.name {
|
||||
active_model.name = Set(name);
|
||||
}
|
||||
if let Some(color) = updates.color {
|
||||
active_model.color = Set(Some(color));
|
||||
}
|
||||
if let Some(icon) = updates.icon {
|
||||
active_model.icon = Set(Some(icon));
|
||||
}
|
||||
if let Some(sort_order) = updates.sort_order {
|
||||
active_model.sort_order = Set(sort_order);
|
||||
}
|
||||
if let Some(is_active) = updates.is_active {
|
||||
active_model.is_active = Set(is_active);
|
||||
}
|
||||
if let Some(include_in_net_worth) = updates.include_in_net_worth {
|
||||
active_model.include_in_net_worth = Set(include_in_net_worth);
|
||||
}
|
||||
if let Some(show_in_combined_view) = updates.show_in_combined_view {
|
||||
active_model.show_in_combined_view = Set(show_in_combined_view);
|
||||
}
|
||||
|
||||
active_model.updated_at = Set(Utc::now());
|
||||
active_model.version = Set(active_model.version.unwrap() + 1);
|
||||
|
||||
let updated = active_model.update(db.inner()).await.map_err(|e| e.to_string())?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn archive_account(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
id: String,
|
||||
archived: bool,
|
||||
) -> CommandResult<()> {
|
||||
let account = Account::find_by_id(id.clone())
|
||||
.one(db.inner())
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.ok_or(format!("Account with id {} not found", id))?;
|
||||
|
||||
let mut active_model: account::ActiveModel = account.into();
|
||||
active_model.is_archived = Set(archived);
|
||||
active_model.updated_at = Set(Utc::now());
|
||||
active_model.version = Set(active_model.version.unwrap() + 1);
|
||||
|
||||
active_model.update(db.inner()).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_account(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
id: String,
|
||||
) -> CommandResult<()> {
|
||||
let account = Account::find_by_id(id.clone())
|
||||
.one(db.inner())
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.ok_or(format!("Account with id {} not found", id))?;
|
||||
|
||||
let mut active_model: account::ActiveModel = account.into();
|
||||
active_model.is_deleted = Set(true);
|
||||
active_model.updated_at = Set(Utc::now());
|
||||
active_model.version = Set(active_model.version.unwrap() + 1);
|
||||
|
||||
active_model.update(db.inner()).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_account_balance(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
id: String,
|
||||
_as_of_date: Option<String>,
|
||||
) -> CommandResult<AccountBalance> {
|
||||
let account = Account::find_by_id(id.clone())
|
||||
.one(db.inner())
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.ok_or(format!("Account with id {} not found", id))?;
|
||||
|
||||
// If as_of_date is provided, we would need to calculate balance up to that date
|
||||
// For now, return current balance
|
||||
// TODO: Implement historical balance calculation
|
||||
|
||||
Ok(AccountBalance {
|
||||
amount: account.current_balance,
|
||||
currency: account.currency,
|
||||
})
|
||||
}
|
||||
233
src-tauri/src/commands/goals.rs
Normal file
233
src-tauri/src/commands/goals.rs
Normal file
@@ -0,0 +1,233 @@
|
||||
use crate::entities::{goal, goal_progress, goal_rule, prelude::*};
|
||||
use crate::error::CommandResult;
|
||||
use chrono::Utc;
|
||||
use rust_decimal::Decimal;
|
||||
use sea_orm::{entity::*, query::*, DatabaseConnection, Set};
|
||||
use serde::Deserialize;
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateGoalInput {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub target_amount: String,
|
||||
pub currency: String,
|
||||
pub goal_type: String,
|
||||
pub target_date: Option<String>,
|
||||
pub is_recurring: Option<bool>,
|
||||
pub linked_account_id: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateGoalInput {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub target_amount: Option<String>,
|
||||
pub target_date: Option<String>,
|
||||
pub is_active: Option<bool>,
|
||||
pub color: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateGoalRuleInput {
|
||||
pub goal_id: String,
|
||||
pub tag_ids: Vec<String>,
|
||||
pub contribution_type: String,
|
||||
pub percentage: Option<String>,
|
||||
pub fixed_amount: Option<String>,
|
||||
pub max_contribution_per_transaction: Option<String>,
|
||||
pub monthly_cap: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ManualContributionInput {
|
||||
pub goal_id: String,
|
||||
pub amount: String,
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_goal(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
input: CreateGoalInput,
|
||||
) -> CommandResult<goal::Model> {
|
||||
let now = Utc::now();
|
||||
|
||||
let goal = goal::ActiveModel {
|
||||
id: Set(Uuid::new_v4().to_string()),
|
||||
name: Set(input.name),
|
||||
description: Set(input.description),
|
||||
target_amount: Set(input.target_amount),
|
||||
current_amount: Set("0.00000000".to_string()),
|
||||
currency: Set(input.currency),
|
||||
goal_type: Set(input.goal_type),
|
||||
target_date: Set(input.target_date),
|
||||
is_recurring: Set(input.is_recurring.unwrap_or(false)),
|
||||
recurrence_period: Set(None),
|
||||
linked_account_id: Set(input.linked_account_id),
|
||||
color: Set(input.color),
|
||||
icon: Set(input.icon),
|
||||
is_active: Set(true),
|
||||
is_achieved: Set(false),
|
||||
achieved_at: Set(None),
|
||||
last_reset_date: Set(None),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
version: Set(1),
|
||||
device_id: Set(None),
|
||||
is_deleted: Set(false),
|
||||
};
|
||||
|
||||
let result = goal.insert(db.inner()).await.map_err(|e| e.to_string())?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_goals(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
include_achieved: Option<bool>,
|
||||
) -> CommandResult<Vec<goal::Model>> {
|
||||
let mut query = Goal::find()
|
||||
.filter(goal::Column::IsDeleted.eq(false));
|
||||
|
||||
if !include_achieved.unwrap_or(false) {
|
||||
query = query.filter(goal::Column::IsAchieved.eq(false));
|
||||
}
|
||||
|
||||
let goals = query
|
||||
.order_by_desc(goal::Column::CreatedAt)
|
||||
.all(db.inner())
|
||||
.await.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(goals)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_goal(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
id: String,
|
||||
updates: UpdateGoalInput,
|
||||
) -> CommandResult<goal::Model> {
|
||||
let goal = Goal::find_by_id(id.clone())
|
||||
.one(db.inner())
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.ok_or(format!("Goal with id {} not found", id))?;
|
||||
|
||||
let mut active_model: goal::ActiveModel = goal.into();
|
||||
|
||||
if let Some(name) = updates.name {
|
||||
active_model.name = Set(name);
|
||||
}
|
||||
if let Some(description) = updates.description {
|
||||
active_model.description = Set(Some(description));
|
||||
}
|
||||
if let Some(target_amount) = updates.target_amount {
|
||||
active_model.target_amount = Set(target_amount);
|
||||
}
|
||||
if let Some(target_date) = updates.target_date {
|
||||
active_model.target_date = Set(Some(target_date));
|
||||
}
|
||||
if let Some(is_active) = updates.is_active {
|
||||
active_model.is_active = Set(is_active);
|
||||
}
|
||||
if let Some(color) = updates.color {
|
||||
active_model.color = Set(Some(color));
|
||||
}
|
||||
if let Some(icon) = updates.icon {
|
||||
active_model.icon = Set(Some(icon));
|
||||
}
|
||||
|
||||
active_model.updated_at = Set(Utc::now());
|
||||
active_model.version = Set(active_model.version.unwrap() + 1);
|
||||
|
||||
let updated = active_model.update(db.inner()).await.map_err(|e| e.to_string())?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_goal(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
id: String,
|
||||
) -> CommandResult<()> {
|
||||
let goal = Goal::find_by_id(id.clone())
|
||||
.one(db.inner())
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.ok_or(format!("Goal with id {} not found", id))?;
|
||||
|
||||
let mut active_model: goal::ActiveModel = goal.into();
|
||||
active_model.is_deleted = Set(true);
|
||||
active_model.updated_at = Set(Utc::now());
|
||||
active_model.version = Set(active_model.version.unwrap() + 1);
|
||||
|
||||
active_model.update(db.inner()).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_goal_rule(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
input: CreateGoalRuleInput,
|
||||
) -> CommandResult<goal_rule::Model> {
|
||||
let now = Utc::now();
|
||||
|
||||
let rule = goal_rule::ActiveModel {
|
||||
id: Set(Uuid::new_v4().to_string()),
|
||||
goal_id: Set(input.goal_id),
|
||||
tag_ids: Set(serde_json::to_string(&input.tag_ids).map_err(|e| e.to_string())?),
|
||||
contribution_type: Set(input.contribution_type),
|
||||
percentage: Set(input.percentage),
|
||||
fixed_amount: Set(input.fixed_amount),
|
||||
max_contribution_per_transaction: Set(input.max_contribution_per_transaction),
|
||||
monthly_cap: Set(input.monthly_cap),
|
||||
is_active: Set(true),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
version: Set(1),
|
||||
device_id: Set(None),
|
||||
is_deleted: Set(false),
|
||||
};
|
||||
|
||||
let result = rule.insert(db.inner()).await.map_err(|e| e.to_string())?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn manual_contribute_to_goal(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
input: ManualContributionInput,
|
||||
) -> CommandResult<()> {
|
||||
let txn = db.inner().begin().await.map_err(|e| e.to_string())?;
|
||||
|
||||
let goal = Goal::find_by_id(&input.goal_id)
|
||||
.one(&txn)
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.ok_or(format!("Goal with id {} not found", input.goal_id))?;
|
||||
|
||||
let current = Decimal::from_str(&goal.current_amount).map_err(|e| e.to_string())?;
|
||||
let contribution = Decimal::from_str(&input.amount).map_err(|e| e.to_string())?;
|
||||
let new_amount = current + contribution;
|
||||
|
||||
let mut active_goal: goal::ActiveModel = goal.into();
|
||||
active_goal.current_amount = Set(new_amount.to_string());
|
||||
active_goal.updated_at = Set(Utc::now());
|
||||
active_goal.update(&txn).await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Create progress record
|
||||
let progress = goal_progress::ActiveModel {
|
||||
id: Set(Uuid::new_v4().to_string()),
|
||||
goal_id: Set(input.goal_id),
|
||||
amount: Set(input.amount),
|
||||
transaction_id: Set(None),
|
||||
notes: Set(input.notes),
|
||||
recorded_at: Set(Utc::now()),
|
||||
};
|
||||
progress.insert(&txn).await.map_err(|e| e.to_string())?;
|
||||
|
||||
txn.commit().await.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
11
src-tauri/src/commands/mod.rs
Normal file
11
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
pub mod accounts;
|
||||
pub mod transactions;
|
||||
pub mod tags;
|
||||
pub mod goals;
|
||||
pub mod settings;
|
||||
|
||||
pub use accounts::*;
|
||||
pub use transactions::*;
|
||||
pub use tags::*;
|
||||
pub use goals::*;
|
||||
pub use settings::*;
|
||||
100
src-tauri/src/commands/settings.rs
Normal file
100
src-tauri/src/commands/settings.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use crate::entities::{prelude::*, setting};
|
||||
use crate::error::CommandResult;
|
||||
use chrono::Utc;
|
||||
use sea_orm::{entity::*, query::*, DatabaseConnection, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Settings {
|
||||
pub language: String,
|
||||
pub default_currency: String,
|
||||
pub base_currency: String,
|
||||
pub timezone: String,
|
||||
pub default_view: String,
|
||||
pub display_mode: String,
|
||||
pub decimal_places: i32,
|
||||
pub date_format: String,
|
||||
pub time_format: String,
|
||||
pub theme: String,
|
||||
pub week_starts_on: i32,
|
||||
pub scheduled_check_interval: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateSettingsInput {
|
||||
pub settings: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_settings(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
) -> CommandResult<Settings> {
|
||||
let settings_list = Setting::find().all(db.inner()).await.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
for setting in settings_list {
|
||||
map.insert(setting.key, setting.value.unwrap_or_default());
|
||||
}
|
||||
|
||||
Ok(Settings {
|
||||
language: map.get("language").cloned().unwrap_or("en".to_string()),
|
||||
default_currency: map.get("default_currency").cloned().unwrap_or("HKD".to_string()),
|
||||
base_currency: map.get("base_currency").cloned().unwrap_or("HKD".to_string()),
|
||||
timezone: map.get("timezone").cloned().unwrap_or("auto".to_string()),
|
||||
default_view: map.get("default_view").cloned().unwrap_or("combined".to_string()),
|
||||
display_mode: map.get("display_mode").cloned().unwrap_or("net".to_string()),
|
||||
decimal_places: map.get("decimal_places").cloned().unwrap_or("2".to_string()).parse().unwrap_or(2),
|
||||
date_format: map.get("date_format").cloned().unwrap_or("YYYY-MM-DD".to_string()),
|
||||
time_format: map.get("time_format").cloned().unwrap_or("24h".to_string()),
|
||||
theme: map.get("theme").cloned().unwrap_or("system".to_string()),
|
||||
week_starts_on: map.get("week_starts_on").cloned().unwrap_or("1".to_string()).parse().unwrap_or(1),
|
||||
scheduled_check_interval: map.get("scheduled_check_interval").cloned().unwrap_or("1".to_string()).parse().unwrap_or(1),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_setting(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
key: String,
|
||||
value: String,
|
||||
) -> CommandResult<()> {
|
||||
let now = Utc::now();
|
||||
|
||||
let setting = Setting::find_by_id(key.clone())
|
||||
.one(db.inner())
|
||||
.await.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(existing) = setting {
|
||||
let mut active_model: setting::ActiveModel = existing.into();
|
||||
active_model.value = Set(Some(value));
|
||||
active_model.updated_at = Set(now);
|
||||
active_model.update(db.inner()).await.map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
let new_setting = setting::ActiveModel {
|
||||
key: Set(key),
|
||||
value: Set(Some(value)),
|
||||
updated_at: Set(now),
|
||||
};
|
||||
new_setting.insert(db.inner()).await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_settings(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
input: UpdateSettingsInput,
|
||||
) -> CommandResult<()> {
|
||||
for (key, value) in input.settings {
|
||||
update_setting(db.clone(), key, value).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_database_path(app: tauri::AppHandle) -> CommandResult<String> {
|
||||
let path = crate::db::get_database_path(&app);
|
||||
Ok(path.to_string_lossy().to_string())
|
||||
}
|
||||
126
src-tauri/src/commands/tags.rs
Normal file
126
src-tauri/src/commands/tags.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use crate::entities::{prelude::*, tag};
|
||||
use crate::error::CommandResult;
|
||||
use chrono::Utc;
|
||||
use sea_orm::{entity::*, query::*, DatabaseConnection, Set};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateTagInput {
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
pub icon: Option<String>,
|
||||
pub budget_amount: Option<String>,
|
||||
pub budget_period: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateTagInput {
|
||||
pub name: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub budget_amount: Option<String>,
|
||||
pub budget_period: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_tag(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
input: CreateTagInput,
|
||||
) -> CommandResult<tag::Model> {
|
||||
let now = Utc::now();
|
||||
|
||||
let tag = tag::ActiveModel {
|
||||
id: Set(Uuid::new_v4().to_string()),
|
||||
name: Set(input.name),
|
||||
color: Set(input.color),
|
||||
icon: Set(input.icon),
|
||||
budget_amount: Set(input.budget_amount),
|
||||
budget_period: Set(input.budget_period),
|
||||
is_system: Set(false),
|
||||
sort_order: Set(0),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
version: Set(1),
|
||||
device_id: Set(None),
|
||||
is_deleted: Set(false),
|
||||
};
|
||||
|
||||
let result = tag.insert(db.inner()).await.map_err(|e| e.to_string())?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_tags(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
include_system: Option<bool>,
|
||||
) -> CommandResult<Vec<tag::Model>> {
|
||||
let mut query = Tag::find()
|
||||
.filter(tag::Column::IsDeleted.eq(false));
|
||||
|
||||
if !include_system.unwrap_or(true) {
|
||||
query = query.filter(tag::Column::IsSystem.eq(false));
|
||||
}
|
||||
|
||||
let tags = query
|
||||
.order_by_asc(tag::Column::SortOrder)
|
||||
.all(db.inner())
|
||||
.await.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_tag(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
id: String,
|
||||
updates: UpdateTagInput,
|
||||
) -> CommandResult<tag::Model> {
|
||||
let tag = Tag::find_by_id(id.clone())
|
||||
.one(db.inner())
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.ok_or(format!("Tag with id {} not found", id))?;
|
||||
|
||||
let mut active_model: tag::ActiveModel = tag.into();
|
||||
|
||||
if let Some(name) = updates.name {
|
||||
active_model.name = Set(name);
|
||||
}
|
||||
if let Some(color) = updates.color {
|
||||
active_model.color = Set(color);
|
||||
}
|
||||
if let Some(icon) = updates.icon {
|
||||
active_model.icon = Set(Some(icon));
|
||||
}
|
||||
if let Some(budget_amount) = updates.budget_amount {
|
||||
active_model.budget_amount = Set(Some(budget_amount));
|
||||
}
|
||||
if let Some(budget_period) = updates.budget_period {
|
||||
active_model.budget_period = Set(Some(budget_period));
|
||||
}
|
||||
|
||||
active_model.updated_at = Set(Utc::now());
|
||||
active_model.version = Set(active_model.version.unwrap() + 1);
|
||||
|
||||
let updated = active_model.update(db.inner()).await.map_err(|e| e.to_string())?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_tag(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
id: String,
|
||||
) -> CommandResult<()> {
|
||||
let tag = Tag::find_by_id(id.clone())
|
||||
.one(db.inner())
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.ok_or(format!("Tag with id {} not found", id))?;
|
||||
|
||||
let mut active_model: tag::ActiveModel = tag.into();
|
||||
active_model.is_deleted = Set(true);
|
||||
active_model.updated_at = Set(Utc::now());
|
||||
active_model.version = Set(active_model.version.unwrap() + 1);
|
||||
|
||||
active_model.update(db.inner()).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
531
src-tauri/src/commands/transactions.rs
Normal file
531
src-tauri/src/commands/transactions.rs
Normal file
@@ -0,0 +1,531 @@
|
||||
use crate::entities::{account, goal_progress, goal_rule, prelude::*, transaction, transaction_tag};
|
||||
use crate::error::CommandResult;
|
||||
use chrono::Utc;
|
||||
use rust_decimal::Decimal;
|
||||
use sea_orm::{entity::*, query::*, DatabaseConnection, Set, TransactionTrait};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::str::FromStr;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateTransactionInput {
|
||||
pub account_id: String,
|
||||
pub transaction_type: String,
|
||||
pub gross_amount: String,
|
||||
pub tax_amount: Option<String>,
|
||||
pub net_amount: String,
|
||||
pub currency: String,
|
||||
pub description: String,
|
||||
pub merchant: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub tag_ids: Vec<String>,
|
||||
pub transaction_date: String,
|
||||
pub receipt_paths: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateTransactionInput {
|
||||
pub gross_amount: Option<String>,
|
||||
pub tax_amount: Option<String>,
|
||||
pub net_amount: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub merchant: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub tag_ids: Option<Vec<String>>,
|
||||
pub transaction_date: Option<String>,
|
||||
pub receipt_paths: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TransactionFilter {
|
||||
pub account_id: Option<String>,
|
||||
pub transaction_type: Option<String>,
|
||||
pub tag_ids: Option<Vec<String>>,
|
||||
pub start_date: Option<String>,
|
||||
pub end_date: Option<String>,
|
||||
pub search_query: Option<String>,
|
||||
pub needs_review: Option<bool>,
|
||||
pub is_auto_inserted: Option<bool>,
|
||||
pub limit: Option<u64>,
|
||||
pub offset: Option<u64>,
|
||||
pub sort_by: Option<String>,
|
||||
pub sort_order: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TransactionWithTags {
|
||||
#[serde(flatten)]
|
||||
pub transaction: transaction::Model,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_transaction(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
input: CreateTransactionInput,
|
||||
) -> CommandResult<TransactionWithTags> {
|
||||
let now = Utc::now();
|
||||
let transaction_id = Uuid::new_v4().to_string();
|
||||
|
||||
// Start a database transaction
|
||||
let txn = db.inner().begin().await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Create transaction
|
||||
let transaction = transaction::ActiveModel {
|
||||
id: Set(transaction_id.clone()),
|
||||
account_id: Set(input.account_id.clone()),
|
||||
transaction_type: Set(input.transaction_type.clone()),
|
||||
gross_amount: Set(input.gross_amount),
|
||||
tax_amount: Set(input.tax_amount.unwrap_or("0.00000000".to_string())),
|
||||
net_amount: Set(input.net_amount.clone()),
|
||||
tax_rate: Set(None),
|
||||
currency: Set(input.currency),
|
||||
description: Set(input.description),
|
||||
merchant: Set(input.merchant),
|
||||
notes: Set(input.notes),
|
||||
receipt_paths: Set(input.receipt_paths.map(|paths| serde_json::to_string(&paths).ok()).flatten()),
|
||||
receipt_ocr_data: Set(None),
|
||||
transfer_id: Set(None),
|
||||
related_transaction_id: Set(None),
|
||||
schedule_id: Set(None),
|
||||
is_scheduled_instance: Set(false),
|
||||
is_auto_inserted: Set(false),
|
||||
needs_review: Set(false),
|
||||
transaction_date: Set(input.transaction_date),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
version: Set(1),
|
||||
device_id: Set(None),
|
||||
is_deleted: Set(false),
|
||||
sync_status: Set("synced".to_string()),
|
||||
};
|
||||
|
||||
let result = transaction.insert(&txn) .await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Insert transaction tags
|
||||
for tag_id in &input.tag_ids {
|
||||
let transaction_tag = transaction_tag::ActiveModel {
|
||||
transaction_id: Set(transaction_id.clone()),
|
||||
tag_id: Set(tag_id.clone()),
|
||||
};
|
||||
transaction_tag.insert(&txn).await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// Update account balance
|
||||
update_account_balance_for_transaction(&txn, &result).await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Process goal contributions (only for expenses)
|
||||
if result.transaction_type == "expense" {
|
||||
process_goal_contributions(&txn, &result, &input.tag_ids).await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
txn.commit().await.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(TransactionWithTags {
|
||||
transaction: result,
|
||||
tags: input.tag_ids,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_transactions(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
filter: TransactionFilter,
|
||||
) -> CommandResult<Vec<TransactionWithTags>> {
|
||||
let mut query = Transaction::find()
|
||||
.filter(transaction::Column::IsDeleted.eq(false));
|
||||
|
||||
if let Some(account_id) = filter.account_id {
|
||||
query = query.filter(transaction::Column::AccountId.eq(account_id));
|
||||
}
|
||||
|
||||
if let Some(transaction_type) = filter.transaction_type {
|
||||
query = query.filter(transaction::Column::TransactionType.eq(transaction_type));
|
||||
}
|
||||
|
||||
if let Some(start_date) = filter.start_date {
|
||||
query = query.filter(transaction::Column::TransactionDate.gte(start_date));
|
||||
}
|
||||
|
||||
if let Some(end_date) = filter.end_date {
|
||||
query = query.filter(transaction::Column::TransactionDate.lte(end_date));
|
||||
}
|
||||
|
||||
if let Some(needs_review) = filter.needs_review {
|
||||
query = query.filter(transaction::Column::NeedsReview.eq(needs_review));
|
||||
}
|
||||
|
||||
if let Some(is_auto_inserted) = filter.is_auto_inserted {
|
||||
query = query.filter(transaction::Column::IsAutoInserted.eq(is_auto_inserted));
|
||||
}
|
||||
|
||||
if let Some(search_query) = filter.search_query {
|
||||
let pattern = format!("%{}%", search_query);
|
||||
query = query.filter(
|
||||
transaction::Column::Description.like(&pattern)
|
||||
.or(transaction::Column::Merchant.like(&pattern))
|
||||
);
|
||||
}
|
||||
|
||||
// Sort
|
||||
match filter.sort_by.as_deref() {
|
||||
Some("date") => {
|
||||
if filter.sort_order.as_deref() == Some("asc") {
|
||||
query = query.order_by_asc(transaction::Column::TransactionDate);
|
||||
} else {
|
||||
query = query.order_by_desc(transaction::Column::TransactionDate);
|
||||
}
|
||||
}
|
||||
Some("amount") => {
|
||||
if filter.sort_order.as_deref() == Some("asc") {
|
||||
query = query.order_by_asc(transaction::Column::NetAmount);
|
||||
} else {
|
||||
query = query.order_by_desc(transaction::Column::NetAmount);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
query = query.order_by_desc(transaction::Column::CreatedAt);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(limit) = filter.limit {
|
||||
query = query.limit(limit);
|
||||
}
|
||||
|
||||
if let Some(offset) = filter.offset {
|
||||
query = query.offset(offset);
|
||||
}
|
||||
|
||||
let transactions = query.all(db.inner()).await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Fetch tags for each transaction
|
||||
let mut result = Vec::new();
|
||||
for txn in transactions {
|
||||
let tags = TransactionTag::find()
|
||||
.filter(transaction_tag::Column::TransactionId.eq(&txn.id))
|
||||
.all(db.inner())
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|tt| tt.tag_id)
|
||||
.collect();
|
||||
|
||||
result.push(TransactionWithTags {
|
||||
transaction: txn,
|
||||
tags,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_transaction(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
id: String,
|
||||
) -> CommandResult<TransactionWithTags> {
|
||||
let transaction = Transaction::find_by_id(id.clone())
|
||||
.one(db.inner())
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.ok_or(format!("Transaction with id {} not found", id))?;
|
||||
|
||||
let tags = TransactionTag::find()
|
||||
.filter(transaction_tag::Column::TransactionId.eq(&id))
|
||||
.all(db.inner())
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|tt| tt.tag_id)
|
||||
.collect();
|
||||
|
||||
Ok(TransactionWithTags {
|
||||
transaction,
|
||||
tags,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_transaction(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
id: String,
|
||||
updates: UpdateTransactionInput,
|
||||
) -> CommandResult<TransactionWithTags> {
|
||||
let txn = db.inner().begin().await.map_err(|e| e.to_string())?;
|
||||
|
||||
let transaction = Transaction::find_by_id(id.clone())
|
||||
.one(&txn)
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.ok_or(format!("Transaction with id {} not found", id))?;
|
||||
|
||||
let mut active_model: transaction::ActiveModel = transaction.clone().into();
|
||||
|
||||
if let Some(gross_amount) = updates.gross_amount {
|
||||
active_model.gross_amount = Set(gross_amount);
|
||||
}
|
||||
if let Some(tax_amount) = updates.tax_amount {
|
||||
active_model.tax_amount = Set(tax_amount);
|
||||
}
|
||||
if let Some(net_amount) = updates.net_amount {
|
||||
active_model.net_amount = Set(net_amount);
|
||||
}
|
||||
if let Some(description) = updates.description {
|
||||
active_model.description = Set(description);
|
||||
}
|
||||
if let Some(merchant) = updates.merchant {
|
||||
active_model.merchant = Set(Some(merchant));
|
||||
}
|
||||
if let Some(notes) = updates.notes {
|
||||
active_model.notes = Set(Some(notes));
|
||||
}
|
||||
if let Some(transaction_date) = updates.transaction_date {
|
||||
active_model.transaction_date = Set(transaction_date);
|
||||
}
|
||||
if let Some(receipt_paths) = updates.receipt_paths {
|
||||
active_model.receipt_paths = Set(Some(serde_json::to_string(&receipt_paths).map_err(|e| e.to_string())?));
|
||||
}
|
||||
|
||||
active_model.updated_at = Set(Utc::now());
|
||||
active_model.version = Set(active_model.version.unwrap() + 1);
|
||||
|
||||
let updated = active_model.update(&txn).await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Update tags if provided
|
||||
let tag_ids = if let Some(new_tags) = updates.tag_ids {
|
||||
// Delete existing tags
|
||||
TransactionTag::delete_many()
|
||||
.filter(transaction_tag::Column::TransactionId.eq(&id))
|
||||
.exec(&txn)
|
||||
.await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Insert new tags
|
||||
for tag_id in &new_tags {
|
||||
let transaction_tag = transaction_tag::ActiveModel {
|
||||
transaction_id: Set(id.clone()),
|
||||
tag_id: Set(tag_id.clone()),
|
||||
};
|
||||
transaction_tag.insert(&txn).await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
new_tags
|
||||
} else {
|
||||
TransactionTag::find()
|
||||
.filter(transaction_tag::Column::TransactionId.eq(&id))
|
||||
.all(&txn)
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|tt| tt.tag_id)
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Recalculate account balance
|
||||
recalculate_account_balance(&txn, &updated.account_id).await.map_err(|e| e.to_string())?;
|
||||
|
||||
txn.commit().await.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(TransactionWithTags {
|
||||
transaction: updated,
|
||||
tags: tag_ids,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_transaction(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
id: String,
|
||||
) -> CommandResult<()> {
|
||||
let txn = db.inner().begin().await.map_err(|e| e.to_string())?;
|
||||
|
||||
let transaction = Transaction::find_by_id(id.clone())
|
||||
.one(&txn)
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.ok_or(format!("Transaction with id {} not found", id))?;
|
||||
|
||||
let mut active_model: transaction::ActiveModel = transaction.clone().into();
|
||||
active_model.is_deleted = Set(true);
|
||||
active_model.updated_at = Set(Utc::now());
|
||||
active_model.version = Set(active_model.version.unwrap() + 1);
|
||||
|
||||
active_model.update(&txn).await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Recalculate account balance
|
||||
recalculate_account_balance(&txn, &transaction.account_id).await.map_err(|e| e.to_string())?;
|
||||
|
||||
txn.commit().await.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_transactions_needing_review(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
) -> CommandResult<Vec<TransactionWithTags>> {
|
||||
let transactions = Transaction::find()
|
||||
.filter(transaction::Column::NeedsReview.eq(true))
|
||||
.filter(transaction::Column::IsDeleted.eq(false))
|
||||
.order_by_desc(transaction::Column::CreatedAt)
|
||||
.all(db.inner())
|
||||
.await.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for txn in transactions {
|
||||
let tags = TransactionTag::find()
|
||||
.filter(transaction_tag::Column::TransactionId.eq(&txn.id))
|
||||
.all(db.inner())
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|tt| tt.tag_id)
|
||||
.collect();
|
||||
|
||||
result.push(TransactionWithTags {
|
||||
transaction: txn,
|
||||
tags,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn confirm_transaction(
|
||||
db: tauri::State<'_, DatabaseConnection>,
|
||||
id: String,
|
||||
) -> CommandResult<()> {
|
||||
let transaction = Transaction::find_by_id(id.clone())
|
||||
.one(db.inner())
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.ok_or(format!("Transaction with id {} not found", id))?;
|
||||
|
||||
let mut active_model: transaction::ActiveModel = transaction.into();
|
||||
active_model.needs_review = Set(false);
|
||||
active_model.updated_at = Set(Utc::now());
|
||||
|
||||
active_model.update(db.inner()).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
async fn update_account_balance_for_transaction<C: ConnectionTrait>(
|
||||
db: &C,
|
||||
transaction: &transaction::Model,
|
||||
) -> CommandResult<()> {
|
||||
let account = Account::find_by_id(&transaction.account_id)
|
||||
.one(db)
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.ok_or(format!("Account {} not found", transaction.account_id))?;
|
||||
|
||||
let current_balance = Decimal::from_str(&account.current_balance).map_err(|e| e.to_string())?;
|
||||
let net_amount = Decimal::from_str(&transaction.net_amount).map_err(|e| e.to_string())?;
|
||||
|
||||
let new_balance = match transaction.transaction_type.as_str() {
|
||||
"expense" | "transfer_out" => current_balance - net_amount,
|
||||
"income" | "transfer_in" => current_balance + net_amount,
|
||||
_ => current_balance,
|
||||
};
|
||||
|
||||
let mut active_account: account::ActiveModel = account.into();
|
||||
active_account.current_balance = Set(new_balance.to_string());
|
||||
active_account.updated_at = Set(Utc::now());
|
||||
active_account.version = Set(active_account.version.unwrap() + 1);
|
||||
|
||||
active_account.update(db).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recalculate_account_balance<C: ConnectionTrait>(
|
||||
db: &C,
|
||||
account_id: &str,
|
||||
) -> CommandResult<()> {
|
||||
let account = Account::find_by_id(account_id)
|
||||
.one(db)
|
||||
.await.map_err(|e| e.to_string())?
|
||||
.ok_or(format!("Account {} not found", account_id))?;
|
||||
|
||||
let mut balance = Decimal::from_str(&account.initial_balance).map_err(|e| e.to_string())?;
|
||||
|
||||
let transactions = Transaction::find()
|
||||
.filter(transaction::Column::AccountId.eq(account_id))
|
||||
.filter(transaction::Column::IsDeleted.eq(false))
|
||||
.order_by_asc(transaction::Column::TransactionDate)
|
||||
.all(db)
|
||||
.await.map_err(|e| e.to_string())?;
|
||||
|
||||
for txn in transactions {
|
||||
let amount = Decimal::from_str(&txn.net_amount).map_err(|e| e.to_string())?;
|
||||
match txn.transaction_type.as_str() {
|
||||
"expense" | "transfer_out" => balance -= amount,
|
||||
"income" | "transfer_in" => balance += amount,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut active_account: account::ActiveModel = account.into();
|
||||
active_account.current_balance = Set(balance.to_string());
|
||||
active_account.updated_at = Set(Utc::now());
|
||||
active_account.version = Set(active_account.version.unwrap() + 1);
|
||||
|
||||
active_account.update(db).await.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process_goal_contributions<C: ConnectionTrait>(
|
||||
db: &C,
|
||||
transaction: &transaction::Model,
|
||||
tag_ids: &[String],
|
||||
) -> CommandResult<()> {
|
||||
// Find matching goal rules
|
||||
let all_rules = GoalRule::find()
|
||||
.filter(goal_rule::Column::IsActive.eq(true))
|
||||
.filter(goal_rule::Column::IsDeleted.eq(false))
|
||||
.all(db)
|
||||
.await.map_err(|e| e.to_string())?;
|
||||
|
||||
for rule in all_rules {
|
||||
// Parse tag_ids from JSON
|
||||
let rule_tags: Vec<String> = serde_json::from_str(&rule.tag_ids).unwrap_or_default();
|
||||
|
||||
// Check if transaction has ALL required tags (AND logic)
|
||||
let has_all_tags = rule_tags.iter().all(|tag| tag_ids.contains(tag));
|
||||
|
||||
if has_all_tags {
|
||||
// Calculate contribution
|
||||
let transaction_amount = Decimal::from_str(&transaction.net_amount).map_err(|e| e.to_string())?;
|
||||
let contribution = match rule.contribution_type.as_str() {
|
||||
"percentage" => {
|
||||
let percentage = Decimal::from_str(rule.percentage.as_ref().unwrap()).map_err(|e| e.to_string())?;
|
||||
transaction_amount * percentage / Decimal::from(100)
|
||||
}
|
||||
"fixed" => {
|
||||
Decimal::from_str(rule.fixed_amount.as_ref().unwrap()).map_err(|e| e.to_string())?
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
// Update goal current_amount
|
||||
let goal = Goal::find_by_id(&rule.goal_id)
|
||||
.one(db)
|
||||
.await.map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(goal) = goal {
|
||||
let current = Decimal::from_str(&goal.current_amount).map_err(|e| e.to_string())?;
|
||||
let new_amount = current + contribution;
|
||||
|
||||
let mut active_goal: crate::entities::goal::ActiveModel = goal.into();
|
||||
active_goal.current_amount = Set(new_amount.to_string());
|
||||
active_goal.updated_at = Set(Utc::now());
|
||||
active_goal.update(db).await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Create goal progress record
|
||||
let progress = goal_progress::ActiveModel {
|
||||
id: Set(Uuid::new_v4().to_string()),
|
||||
goal_id: Set(rule.goal_id.clone()),
|
||||
amount: Set(contribution.to_string()),
|
||||
transaction_id: Set(Some(transaction.id.clone())),
|
||||
notes: Set(Some(format!("Auto-contribution from transaction"))),
|
||||
recorded_at: Set(Utc::now()),
|
||||
};
|
||||
progress.insert(db).await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
449
src-tauri/src/db/migrations.rs
Normal file
449
src-tauri/src/db/migrations.rs
Normal file
@@ -0,0 +1,449 @@
|
||||
use sea_orm::{ConnectionTrait, DatabaseConnection, DbErr, Statement};
|
||||
use sea_orm::DatabaseBackend;
|
||||
|
||||
pub async fn run_migrations(db: &DatabaseConnection) -> Result<(), DbErr> {
|
||||
// Create all tables
|
||||
create_accounts_table(db).await?;
|
||||
create_tags_table(db).await?;
|
||||
create_transactions_table(db).await?;
|
||||
create_transaction_tags_table(db).await?;
|
||||
create_scheduled_transactions_table(db).await?;
|
||||
create_scheduled_instances_table(db).await?;
|
||||
create_goals_table(db).await?;
|
||||
create_goal_rules_table(db).await?;
|
||||
create_goal_progress_table(db).await?;
|
||||
create_transfers_table(db).await?;
|
||||
create_exchange_rates_table(db).await?;
|
||||
create_reconciliations_table(db).await?;
|
||||
create_settings_table(db).await?;
|
||||
|
||||
// Insert default data
|
||||
insert_default_settings(db).await?;
|
||||
insert_system_tags(db).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_accounts_table(db: &DatabaseConnection) -> Result<(), DbErr> {
|
||||
let sql = r#"
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
account_type TEXT NOT NULL CHECK(account_type IN (
|
||||
'checking', 'savings', 'credit_card', 'cash', 'digital_wallet', 'loan', 'other'
|
||||
)),
|
||||
currency TEXT NOT NULL,
|
||||
initial_balance TEXT NOT NULL DEFAULT '0.00000000',
|
||||
current_balance TEXT NOT NULL DEFAULT '0.00000000',
|
||||
color TEXT DEFAULT '#3B82F6',
|
||||
icon TEXT DEFAULT 'wallet',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
is_archived INTEGER DEFAULT 0,
|
||||
include_in_net_worth INTEGER DEFAULT 1,
|
||||
show_in_combined_view INTEGER DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
version INTEGER DEFAULT 1,
|
||||
device_id TEXT,
|
||||
is_deleted INTEGER DEFAULT 0
|
||||
)
|
||||
"#;
|
||||
|
||||
db.execute(Statement::from_string(DatabaseBackend::Sqlite, sql)).await?;
|
||||
|
||||
// Create indexes
|
||||
db.execute(Statement::from_string(
|
||||
DatabaseBackend::Sqlite,
|
||||
"CREATE INDEX IF NOT EXISTS idx_accounts_active ON accounts(is_active, is_archived)"
|
||||
)).await?;
|
||||
|
||||
db.execute(Statement::from_string(
|
||||
DatabaseBackend::Sqlite,
|
||||
"CREATE INDEX IF NOT EXISTS idx_accounts_type ON accounts(account_type)"
|
||||
)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_tags_table(db: &DatabaseConnection) -> Result<(), DbErr> {
|
||||
let sql = r#"
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
color TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
budget_amount TEXT,
|
||||
budget_period TEXT CHECK(budget_period IN ('daily', 'weekly', 'monthly', 'yearly')),
|
||||
is_system INTEGER DEFAULT 0,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
version INTEGER DEFAULT 1,
|
||||
device_id TEXT,
|
||||
is_deleted INTEGER DEFAULT 0
|
||||
)
|
||||
"#;
|
||||
|
||||
db.execute(Statement::from_string(DatabaseBackend::Sqlite, sql)).await?;
|
||||
|
||||
db.execute(Statement::from_string(
|
||||
DatabaseBackend::Sqlite,
|
||||
"CREATE INDEX IF NOT EXISTS idx_tags_active ON tags(is_deleted)"
|
||||
)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_transactions_table(db: &DatabaseConnection) -> Result<(), DbErr> {
|
||||
let sql = r#"
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
transaction_type TEXT NOT NULL CHECK(transaction_type IN (
|
||||
'expense', 'income', 'transfer_out', 'transfer_in'
|
||||
)),
|
||||
gross_amount TEXT NOT NULL,
|
||||
tax_amount TEXT DEFAULT '0.00000000',
|
||||
net_amount TEXT NOT NULL,
|
||||
tax_rate TEXT,
|
||||
currency TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
merchant TEXT,
|
||||
notes TEXT,
|
||||
receipt_paths TEXT,
|
||||
receipt_ocr_data TEXT,
|
||||
transfer_id TEXT,
|
||||
related_transaction_id TEXT REFERENCES transactions(id),
|
||||
schedule_id TEXT REFERENCES scheduled_transactions(id),
|
||||
is_scheduled_instance INTEGER DEFAULT 0,
|
||||
is_auto_inserted INTEGER DEFAULT 0,
|
||||
needs_review INTEGER DEFAULT 0,
|
||||
transaction_date TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
version INTEGER DEFAULT 1,
|
||||
device_id TEXT,
|
||||
is_deleted INTEGER DEFAULT 0,
|
||||
sync_status TEXT DEFAULT 'synced' CHECK(sync_status IN ('synced', 'pending', 'conflict', 'error'))
|
||||
)
|
||||
"#;
|
||||
|
||||
db.execute(Statement::from_string(DatabaseBackend::Sqlite, sql)).await?;
|
||||
|
||||
// Create indexes
|
||||
db.execute(Statement::from_string(
|
||||
DatabaseBackend::Sqlite,
|
||||
"CREATE INDEX IF NOT EXISTS idx_transactions_account ON transactions(account_id, is_deleted)"
|
||||
)).await?;
|
||||
|
||||
db.execute(Statement::from_string(
|
||||
DatabaseBackend::Sqlite,
|
||||
"CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(transaction_date DESC)"
|
||||
)).await?;
|
||||
|
||||
db.execute(Statement::from_string(
|
||||
DatabaseBackend::Sqlite,
|
||||
"CREATE INDEX IF NOT EXISTS idx_transactions_type ON transactions(transaction_type)"
|
||||
)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_transaction_tags_table(db: &DatabaseConnection) -> Result<(), DbErr> {
|
||||
let sql = r#"
|
||||
CREATE TABLE IF NOT EXISTS transaction_tags (
|
||||
transaction_id TEXT NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
|
||||
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (transaction_id, tag_id)
|
||||
)
|
||||
"#;
|
||||
|
||||
db.execute(Statement::from_string(DatabaseBackend::Sqlite, sql)).await?;
|
||||
|
||||
db.execute(Statement::from_string(
|
||||
DatabaseBackend::Sqlite,
|
||||
"CREATE INDEX IF NOT EXISTS idx_transaction_tags_tag ON transaction_tags(tag_id)"
|
||||
)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_scheduled_transactions_table(db: &DatabaseConnection) -> Result<(), DbErr> {
|
||||
let sql = r#"
|
||||
CREATE TABLE IF NOT EXISTS scheduled_transactions (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES accounts(id),
|
||||
schedule_type TEXT NOT NULL CHECK(schedule_type IN ('daily', 'weekly', 'monthly', 'yearly', 'custom')),
|
||||
frequency INTEGER DEFAULT 1,
|
||||
days_of_week TEXT,
|
||||
day_of_month INTEGER,
|
||||
month_of_year INTEGER,
|
||||
execution_time TEXT DEFAULT '00:00',
|
||||
timezone TEXT,
|
||||
start_date TEXT NOT NULL,
|
||||
end_date TEXT,
|
||||
occurrence_count INTEGER,
|
||||
current_occurrence INTEGER DEFAULT 0,
|
||||
transaction_type TEXT NOT NULL,
|
||||
gross_amount TEXT NOT NULL,
|
||||
tax_amount TEXT DEFAULT '0.00000000',
|
||||
net_amount TEXT NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
description TEXT,
|
||||
merchant TEXT,
|
||||
notes TEXT,
|
||||
tag_ids TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
last_generated_date TEXT,
|
||||
next_execution_datetime TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
version INTEGER DEFAULT 1,
|
||||
device_id TEXT,
|
||||
is_deleted INTEGER DEFAULT 0
|
||||
)
|
||||
"#;
|
||||
|
||||
db.execute(Statement::from_string(DatabaseBackend::Sqlite, sql)).await?;
|
||||
|
||||
db.execute(Statement::from_string(
|
||||
DatabaseBackend::Sqlite,
|
||||
"CREATE INDEX IF NOT EXISTS idx_scheduled_next_exec ON scheduled_transactions(next_execution_datetime) WHERE is_active = 1 AND is_deleted = 0"
|
||||
)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_scheduled_instances_table(db: &DatabaseConnection) -> Result<(), DbErr> {
|
||||
let sql = r#"
|
||||
CREATE TABLE IF NOT EXISTS scheduled_instances (
|
||||
id TEXT PRIMARY KEY,
|
||||
schedule_id TEXT NOT NULL REFERENCES scheduled_transactions(id) ON DELETE CASCADE,
|
||||
transaction_id TEXT REFERENCES transactions(id),
|
||||
due_date TEXT NOT NULL,
|
||||
is_generated INTEGER DEFAULT 0,
|
||||
is_skipped INTEGER DEFAULT 0,
|
||||
generated_at TEXT,
|
||||
notified INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
"#;
|
||||
|
||||
db.execute(Statement::from_string(DatabaseBackend::Sqlite, sql)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_goals_table(db: &DatabaseConnection) -> Result<(), DbErr> {
|
||||
let sql = r#"
|
||||
CREATE TABLE IF NOT EXISTS goals (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
target_amount TEXT NOT NULL,
|
||||
current_amount TEXT NOT NULL DEFAULT '0.00000000',
|
||||
currency TEXT NOT NULL,
|
||||
goal_type TEXT NOT NULL CHECK(goal_type IN ('savings', 'debt_payoff', 'spending_limit', 'custom')),
|
||||
target_date TEXT,
|
||||
is_recurring INTEGER DEFAULT 0,
|
||||
recurrence_period TEXT CHECK(recurrence_period IN ('monthly', 'quarterly', 'yearly')),
|
||||
linked_account_id TEXT REFERENCES accounts(id),
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
is_achieved INTEGER DEFAULT 0,
|
||||
achieved_at TEXT,
|
||||
last_reset_date TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
version INTEGER DEFAULT 1,
|
||||
device_id TEXT,
|
||||
is_deleted INTEGER DEFAULT 0
|
||||
)
|
||||
"#;
|
||||
|
||||
db.execute(Statement::from_string(DatabaseBackend::Sqlite, sql)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_goal_rules_table(db: &DatabaseConnection) -> Result<(), DbErr> {
|
||||
let sql = r#"
|
||||
CREATE TABLE IF NOT EXISTS goal_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
goal_id TEXT NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
|
||||
tag_ids TEXT NOT NULL,
|
||||
contribution_type TEXT NOT NULL CHECK(contribution_type IN ('percentage', 'fixed')),
|
||||
percentage TEXT,
|
||||
fixed_amount TEXT,
|
||||
max_contribution_per_transaction TEXT,
|
||||
monthly_cap TEXT,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
version INTEGER DEFAULT 1,
|
||||
device_id TEXT,
|
||||
is_deleted INTEGER DEFAULT 0
|
||||
)
|
||||
"#;
|
||||
|
||||
db.execute(Statement::from_string(DatabaseBackend::Sqlite, sql)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_goal_progress_table(db: &DatabaseConnection) -> Result<(), DbErr> {
|
||||
let sql = r#"
|
||||
CREATE TABLE IF NOT EXISTS goal_progress (
|
||||
id TEXT PRIMARY KEY,
|
||||
goal_id TEXT NOT NULL REFERENCES goals(id) ON DELETE CASCADE,
|
||||
amount TEXT NOT NULL,
|
||||
transaction_id TEXT REFERENCES transactions(id),
|
||||
notes TEXT,
|
||||
recorded_at TEXT NOT NULL
|
||||
)
|
||||
"#;
|
||||
|
||||
db.execute(Statement::from_string(DatabaseBackend::Sqlite, sql)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_transfers_table(db: &DatabaseConnection) -> Result<(), DbErr> {
|
||||
let sql = r#"
|
||||
CREATE TABLE IF NOT EXISTS transfers (
|
||||
id TEXT PRIMARY KEY,
|
||||
from_account_id TEXT NOT NULL REFERENCES accounts(id),
|
||||
to_account_id TEXT NOT NULL REFERENCES accounts(id),
|
||||
from_transaction_id TEXT REFERENCES transactions(id),
|
||||
to_transaction_id TEXT REFERENCES transactions(id),
|
||||
from_amount TEXT NOT NULL,
|
||||
to_amount TEXT NOT NULL,
|
||||
exchange_rate TEXT,
|
||||
exchange_rate_source TEXT,
|
||||
fees TEXT DEFAULT '0.00000000',
|
||||
description TEXT,
|
||||
transfer_date TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
version INTEGER DEFAULT 1,
|
||||
device_id TEXT,
|
||||
is_deleted INTEGER DEFAULT 0
|
||||
)
|
||||
"#;
|
||||
|
||||
db.execute(Statement::from_string(DatabaseBackend::Sqlite, sql)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_exchange_rates_table(db: &DatabaseConnection) -> Result<(), DbErr> {
|
||||
let sql = r#"
|
||||
CREATE TABLE IF NOT EXISTS exchange_rates (
|
||||
from_currency TEXT NOT NULL,
|
||||
to_currency TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
rate TEXT NOT NULL,
|
||||
source TEXT,
|
||||
fetched_at TEXT,
|
||||
PRIMARY KEY (from_currency, to_currency, date)
|
||||
)
|
||||
"#;
|
||||
|
||||
db.execute(Statement::from_string(DatabaseBackend::Sqlite, sql)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_reconciliations_table(db: &DatabaseConnection) -> Result<(), DbErr> {
|
||||
let sql = r#"
|
||||
CREATE TABLE IF NOT EXISTS reconciliations (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES accounts(id),
|
||||
statement_date TEXT NOT NULL,
|
||||
statement_balance TEXT NOT NULL,
|
||||
app_balance TEXT NOT NULL,
|
||||
difference TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'balanced', 'adjusted')),
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT,
|
||||
version INTEGER DEFAULT 1,
|
||||
device_id TEXT
|
||||
)
|
||||
"#;
|
||||
|
||||
db.execute(Statement::from_string(DatabaseBackend::Sqlite, sql)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_settings_table(db: &DatabaseConnection) -> Result<(), DbErr> {
|
||||
let sql = r#"
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"#;
|
||||
|
||||
db.execute(Statement::from_string(DatabaseBackend::Sqlite, sql)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_default_settings(db: &DatabaseConnection) -> Result<(), DbErr> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let settings = vec![
|
||||
("language", "en"),
|
||||
("default_currency", "HKD"),
|
||||
("base_currency", "HKD"),
|
||||
("timezone", "auto"),
|
||||
("default_view", "combined"),
|
||||
("display_mode", "net"),
|
||||
("decimal_places", "2"),
|
||||
("date_format", "YYYY-MM-DD"),
|
||||
("time_format", "24h"),
|
||||
("theme", "system"),
|
||||
("week_starts_on", "1"),
|
||||
("scheduled_check_interval", "1"),
|
||||
];
|
||||
|
||||
for (key, value) in settings {
|
||||
let sql = format!(
|
||||
"INSERT OR IGNORE INTO settings (key, value, updated_at) VALUES ('{}', '{}', '{}')",
|
||||
key, value, now
|
||||
);
|
||||
db.execute(Statement::from_string(DatabaseBackend::Sqlite, sql)).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_system_tags(db: &DatabaseConnection) -> Result<(), DbErr> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let tags = vec![
|
||||
("tag-food", "Food & Dining", "#EF4444", "utensils"),
|
||||
("tag-transport", "Transportation", "#3B82F6", "car"),
|
||||
("tag-shopping", "Shopping", "#8B5CF6", "shopping-bag"),
|
||||
("tag-entertainment", "Entertainment", "#F59E0B", "film"),
|
||||
("tag-utilities", "Utilities", "#10B981", "zap"),
|
||||
("tag-health", "Health & Medical", "#EC4899", "heart-pulse"),
|
||||
("tag-education", "Education", "#6366F1", "graduation-cap"),
|
||||
("tag-salary", "Salary", "#10B981", "banknote"),
|
||||
("tag-investment", "Investment", "#14B8A6", "trending-up"),
|
||||
("tag-transfer", "Transfer", "#6B7280", "arrow-right-left"),
|
||||
];
|
||||
|
||||
for (i, (id, name, color, icon)) in tags.iter().enumerate() {
|
||||
let sql = format!(
|
||||
"INSERT OR IGNORE INTO tags (id, name, color, icon, is_system, sort_order, created_at, updated_at, version, is_deleted) VALUES ('{}', '{}', '{}', '{}', 1, {}, '{}', '{}', 1, 0)",
|
||||
id, name, color, icon, i + 1, now, now
|
||||
);
|
||||
db.execute(Statement::from_string(DatabaseBackend::Sqlite, sql)).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
41
src-tauri/src/db/mod.rs
Normal file
41
src-tauri/src/db/mod.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
pub mod migrations;
|
||||
|
||||
use sea_orm::{Database, DatabaseConnection, DbErr};
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
pub async fn establish_connection(app_handle: &AppHandle) -> Result<DatabaseConnection, DbErr> {
|
||||
let app_dir = app_handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.expect("Failed to get app data directory");
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
std::fs::create_dir_all(&app_dir).expect("Failed to create app data directory");
|
||||
|
||||
let db_path = app_dir.join("finance.db");
|
||||
let url = format!("sqlite://{}?mode=rwc", db_path.display());
|
||||
|
||||
println!("Connecting to database at: {}", db_path.display());
|
||||
|
||||
let db = Database::connect(&url).await?;
|
||||
|
||||
// Enable foreign keys and set pragmas
|
||||
sea_orm::ConnectionTrait::execute_unprepared(
|
||||
&db,
|
||||
"PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;"
|
||||
).await?;
|
||||
|
||||
// Run migrations
|
||||
migrations::run_migrations(&db).await?;
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
pub fn get_database_path(app_handle: &AppHandle) -> PathBuf {
|
||||
app_handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.expect("Failed to get app data directory")
|
||||
.join("finance.db")
|
||||
}
|
||||
56
src-tauri/src/entities/account.rs
Normal file
56
src-tauri/src/entities/account.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "accounts")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub account_type: String,
|
||||
pub currency: String,
|
||||
pub initial_balance: String,
|
||||
pub current_balance: String,
|
||||
pub color: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub sort_order: i32,
|
||||
pub is_active: bool,
|
||||
pub is_archived: bool,
|
||||
pub include_in_net_worth: bool,
|
||||
pub show_in_combined_view: bool,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub version: i32,
|
||||
pub device_id: Option<String>,
|
||||
pub is_deleted: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::transaction::Entity")]
|
||||
Transactions,
|
||||
#[sea_orm(has_many = "super::scheduled_transaction::Entity")]
|
||||
ScheduledTransactions,
|
||||
#[sea_orm(has_many = "super::goal::Entity")]
|
||||
Goals,
|
||||
}
|
||||
|
||||
impl Related<super::transaction::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Transactions.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::scheduled_transaction::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::ScheduledTransactions.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::goal::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Goals.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
21
src-tauri/src/entities/exchange_rate.rs
Normal file
21
src-tauri/src/entities/exchange_rate.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "exchange_rates")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub from_currency: String,
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub to_currency: String,
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub date: String,
|
||||
pub rate: String,
|
||||
pub source: Option<String>,
|
||||
pub fetched_at: Option<DateTimeUtc>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
64
src-tauri/src/entities/goal.rs
Normal file
64
src-tauri/src/entities/goal.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "goals")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub target_amount: String,
|
||||
pub current_amount: String,
|
||||
pub currency: String,
|
||||
pub goal_type: String,
|
||||
pub target_date: Option<String>,
|
||||
pub is_recurring: bool,
|
||||
pub recurrence_period: Option<String>,
|
||||
pub linked_account_id: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub is_active: bool,
|
||||
pub is_achieved: bool,
|
||||
pub achieved_at: Option<DateTimeUtc>,
|
||||
pub last_reset_date: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub version: i32,
|
||||
pub device_id: Option<String>,
|
||||
pub is_deleted: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::account::Entity",
|
||||
from = "Column::LinkedAccountId",
|
||||
to = "super::account::Column::Id"
|
||||
)]
|
||||
Account,
|
||||
#[sea_orm(has_many = "super::goal_rule::Entity")]
|
||||
GoalRules,
|
||||
#[sea_orm(has_many = "super::goal_progress::Entity")]
|
||||
GoalProgress,
|
||||
}
|
||||
|
||||
impl Related<super::account::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Account.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::goal_rule::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::GoalRules.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::goal_progress::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::GoalProgress.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
44
src-tauri/src/entities/goal_progress.rs
Normal file
44
src-tauri/src/entities/goal_progress.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "goal_progress")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: String,
|
||||
pub goal_id: String,
|
||||
pub amount: String,
|
||||
pub transaction_id: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub recorded_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::goal::Entity",
|
||||
from = "Column::GoalId",
|
||||
to = "super::goal::Column::Id"
|
||||
)]
|
||||
Goal,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::transaction::Entity",
|
||||
from = "Column::TransactionId",
|
||||
to = "super::transaction::Column::Id"
|
||||
)]
|
||||
Transaction,
|
||||
}
|
||||
|
||||
impl Related<super::goal::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Goal.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::transaction::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Transaction.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
40
src-tauri/src/entities/goal_rule.rs
Normal file
40
src-tauri/src/entities/goal_rule.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "goal_rules")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: String,
|
||||
pub goal_id: String,
|
||||
pub tag_ids: String, // JSON
|
||||
pub contribution_type: String,
|
||||
pub percentage: Option<String>,
|
||||
pub fixed_amount: Option<String>,
|
||||
pub max_contribution_per_transaction: Option<String>,
|
||||
pub monthly_cap: Option<String>,
|
||||
pub is_active: bool,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub version: i32,
|
||||
pub device_id: Option<String>,
|
||||
pub is_deleted: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::goal::Entity",
|
||||
from = "Column::GoalId",
|
||||
to = "super::goal::Column::Id"
|
||||
)]
|
||||
Goal,
|
||||
}
|
||||
|
||||
impl Related<super::goal::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Goal.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
32
src-tauri/src/entities/mod.rs
Normal file
32
src-tauri/src/entities/mod.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
pub mod account;
|
||||
pub mod tag;
|
||||
pub mod transaction;
|
||||
pub mod transaction_tag;
|
||||
pub mod scheduled_transaction;
|
||||
pub mod scheduled_instance;
|
||||
pub mod goal;
|
||||
pub mod goal_rule;
|
||||
pub mod goal_progress;
|
||||
pub mod transfer;
|
||||
pub mod exchange_rate;
|
||||
pub mod reconciliation;
|
||||
pub mod setting;
|
||||
|
||||
pub mod prelude {
|
||||
pub use super::account::Entity as Account;
|
||||
pub use super::tag::Entity as Tag;
|
||||
pub use super::transaction::Entity as Transaction;
|
||||
pub use super::transaction_tag::Entity as TransactionTag;
|
||||
pub use super::scheduled_transaction::Entity as ScheduledTransaction;
|
||||
pub use super::scheduled_instance::Entity as ScheduledInstance;
|
||||
pub use super::goal::Entity as Goal;
|
||||
pub use super::goal_rule::Entity as GoalRule;
|
||||
pub use super::goal_progress::Entity as GoalProgress;
|
||||
pub use super::transfer::Entity as Transfer;
|
||||
pub use super::exchange_rate::Entity as ExchangeRate;
|
||||
pub use super::reconciliation::Entity as Reconciliation;
|
||||
pub use super::setting::Entity as Setting;
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
pub use prelude::*;
|
||||
38
src-tauri/src/entities/reconciliation.rs
Normal file
38
src-tauri/src/entities/reconciliation.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "reconciliations")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: String,
|
||||
pub account_id: String,
|
||||
pub statement_date: String,
|
||||
pub statement_balance: String,
|
||||
pub app_balance: String,
|
||||
pub difference: String,
|
||||
pub status: String,
|
||||
pub notes: Option<String>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub resolved_at: Option<DateTimeUtc>,
|
||||
pub version: i32,
|
||||
pub device_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::account::Entity",
|
||||
from = "Column::AccountId",
|
||||
to = "super::account::Column::Id"
|
||||
)]
|
||||
Account,
|
||||
}
|
||||
|
||||
impl Related<super::account::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Account.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
35
src-tauri/src/entities/scheduled_instance.rs
Normal file
35
src-tauri/src/entities/scheduled_instance.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "scheduled_instances")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: String,
|
||||
pub schedule_id: String,
|
||||
pub transaction_id: Option<String>,
|
||||
pub due_date: String,
|
||||
pub is_generated: bool,
|
||||
pub is_skipped: bool,
|
||||
pub generated_at: Option<DateTimeUtc>,
|
||||
pub notified: bool,
|
||||
pub created_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::scheduled_transaction::Entity",
|
||||
from = "Column::ScheduleId",
|
||||
to = "super::scheduled_transaction::Column::Id"
|
||||
)]
|
||||
ScheduledTransaction,
|
||||
}
|
||||
|
||||
impl Related<super::scheduled_transaction::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::ScheduledTransaction.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
64
src-tauri/src/entities/scheduled_transaction.rs
Normal file
64
src-tauri/src/entities/scheduled_transaction.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "scheduled_transactions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: String,
|
||||
pub account_id: String,
|
||||
pub schedule_type: String,
|
||||
pub frequency: i32,
|
||||
pub days_of_week: Option<String>, // JSON
|
||||
pub day_of_month: Option<i32>,
|
||||
pub month_of_year: Option<i32>,
|
||||
pub execution_time: String,
|
||||
pub timezone: Option<String>,
|
||||
pub start_date: String,
|
||||
pub end_date: Option<String>,
|
||||
pub occurrence_count: Option<i32>,
|
||||
pub current_occurrence: i32,
|
||||
pub transaction_type: String,
|
||||
pub gross_amount: String,
|
||||
pub tax_amount: String,
|
||||
pub net_amount: String,
|
||||
pub currency: String,
|
||||
pub description: Option<String>,
|
||||
pub merchant: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub tag_ids: Option<String>, // JSON
|
||||
pub is_active: bool,
|
||||
pub last_generated_date: Option<String>,
|
||||
pub next_execution_datetime: Option<DateTimeUtc>,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub version: i32,
|
||||
pub device_id: Option<String>,
|
||||
pub is_deleted: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::account::Entity",
|
||||
from = "Column::AccountId",
|
||||
to = "super::account::Column::Id"
|
||||
)]
|
||||
Account,
|
||||
#[sea_orm(has_many = "super::scheduled_instance::Entity")]
|
||||
ScheduledInstances,
|
||||
}
|
||||
|
||||
impl Related<super::account::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Account.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::scheduled_instance::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::ScheduledInstances.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
16
src-tauri/src/entities/setting.rs
Normal file
16
src-tauri/src/entities/setting.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "settings")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub key: String,
|
||||
pub value: Option<String>,
|
||||
pub updated_at: DateTimeUtc,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
35
src-tauri/src/entities/tag.rs
Normal file
35
src-tauri/src/entities/tag.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "tags")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
pub icon: Option<String>,
|
||||
pub budget_amount: Option<String>,
|
||||
pub budget_period: Option<String>,
|
||||
pub is_system: bool,
|
||||
pub sort_order: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub version: i32,
|
||||
pub device_id: Option<String>,
|
||||
pub is_deleted: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::transaction_tag::Entity")]
|
||||
TransactionTags,
|
||||
}
|
||||
|
||||
impl Related<super::transaction_tag::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::TransactionTags.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
68
src-tauri/src/entities/transaction.rs
Normal file
68
src-tauri/src/entities/transaction.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "transactions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: String,
|
||||
pub account_id: String,
|
||||
pub transaction_type: String,
|
||||
pub gross_amount: String,
|
||||
pub tax_amount: String,
|
||||
pub net_amount: String,
|
||||
pub tax_rate: Option<String>,
|
||||
pub currency: String,
|
||||
pub description: String,
|
||||
pub merchant: Option<String>,
|
||||
pub notes: Option<String>,
|
||||
pub receipt_paths: Option<String>, // JSON
|
||||
pub receipt_ocr_data: Option<String>, // JSON
|
||||
pub transfer_id: Option<String>,
|
||||
pub related_transaction_id: Option<String>,
|
||||
pub schedule_id: Option<String>,
|
||||
pub is_scheduled_instance: bool,
|
||||
pub is_auto_inserted: bool,
|
||||
pub needs_review: bool,
|
||||
pub transaction_date: String,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
pub version: i32,
|
||||
pub device_id: Option<String>,
|
||||
pub is_deleted: bool,
|
||||
pub sync_status: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::account::Entity",
|
||||
from = "Column::AccountId",
|
||||
to = "super::account::Column::Id"
|
||||
)]
|
||||
Account,
|
||||
#[sea_orm(has_many = "super::transaction_tag::Entity")]
|
||||
TransactionTags,
|
||||
#[sea_orm(has_many = "super::goal_progress::Entity")]
|
||||
GoalProgress,
|
||||
}
|
||||
|
||||
impl Related<super::account::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Account.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::transaction_tag::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::TransactionTags.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::goal_progress::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::GoalProgress.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
41
src-tauri/src/entities/transaction_tag.rs
Normal file
41
src-tauri/src/entities/transaction_tag.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "transaction_tags")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub transaction_id: String,
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub tag_id: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::transaction::Entity",
|
||||
from = "Column::TransactionId",
|
||||
to = "super::transaction::Column::Id"
|
||||
)]
|
||||
Transaction,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::tag::Entity",
|
||||
from = "Column::TagId",
|
||||
to = "super::tag::Column::Id"
|
||||
)]
|
||||
Tag,
|
||||
}
|
||||
|
||||
impl Related<super::transaction::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Transaction.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::tag::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Tag.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
29
src-tauri/src/entities/transfer.rs
Normal file
29
src-tauri/src/entities/transfer.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "transfers")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: String,
|
||||
pub from_account_id: String,
|
||||
pub to_account_id: String,
|
||||
pub from_transaction_id: Option<String>,
|
||||
pub to_transaction_id: Option<String>,
|
||||
pub from_amount: String,
|
||||
pub to_amount: String,
|
||||
pub exchange_rate: Option<String>,
|
||||
pub exchange_rate_source: Option<String>,
|
||||
pub fees: String,
|
||||
pub description: Option<String>,
|
||||
pub transfer_date: String,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub version: i32,
|
||||
pub device_id: Option<String>,
|
||||
pub is_deleted: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
46
src-tauri/src/error.rs
Normal file
46
src-tauri/src/error.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum AppError {
|
||||
#[error("Database error: {0}")]
|
||||
Database(#[from] sea_orm::DbErr),
|
||||
|
||||
#[error("Validation error: {0}")]
|
||||
Validation(String),
|
||||
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("Invalid amount: {0}")]
|
||||
InvalidAmount(String),
|
||||
|
||||
#[error("Currency mismatch: expected {expected}, got {actual}")]
|
||||
CurrencyMismatch { expected: String, actual: String },
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Serialization error: {0}")]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl From<AppError> for String {
|
||||
fn from(error: AppError) -> Self {
|
||||
error.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rust_decimal::Error> for AppError {
|
||||
fn from(error: rust_decimal::Error) -> Self {
|
||||
AppError::InvalidAmount(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, AppError>;
|
||||
pub type CommandResult<T> = std::result::Result<T, String>;
|
||||
Reference in New Issue
Block a user