1 Commits

23 changed files with 2320 additions and 0 deletions

View 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,
})
}

View 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(())
}

View 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::*;

View 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())
}

View 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(())
}

View 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(())
}

View 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
View 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")
}

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

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

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

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

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

View 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::*;

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

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

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

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

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

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

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

View 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
View 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>;