Compare commits
9 Commits
feature/ex
...
4e4a656c88
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e4a656c88 | |||
| 3ff421c200 | |||
| 75efe5768a | |||
| 620df5780b | |||
| 6b987181a8 | |||
| bf04d8d2da | |||
| 7ffc3bac00 | |||
| 7448bbd5e0 | |||
| 8013f2ad61 |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1345,8 +1345,10 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"migration",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"rust_decimal",
|
||||
"sea-orm",
|
||||
|
||||
@@ -35,6 +35,8 @@ sha2 = "0.10"
|
||||
tauri-plugin-log = "2.8.0"
|
||||
log = "0.4.29"
|
||||
struct_iterable = "0.1.1"
|
||||
regex = "1"
|
||||
lazy_static = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
sea-orm = { workspace = true, features = ["mock"] }
|
||||
|
||||
99
src-tauri/src/commands/accounts.rs
Normal file
99
src-tauri/src/commands/accounts.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use crate::{
|
||||
errors::CommandResult,
|
||||
services::accounts::{
|
||||
service::AccountModel,
|
||||
types::{
|
||||
inputs::{AccountFilter, AccountType, CreateAccountInput, UpdateAccountInput},
|
||||
outputs::AccountBalance,
|
||||
},
|
||||
},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_account(
|
||||
state: tauri::State<'_, AppState>,
|
||||
input: CreateAccountInput,
|
||||
) -> CommandResult<AccountModel> {
|
||||
state.account_service().create_account(input, None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_accounts(
|
||||
state: tauri::State<'_, AppState>,
|
||||
include_archived: Option<bool>,
|
||||
account_type: Option<String>,
|
||||
limit: Option<u64>,
|
||||
offset: Option<u64>,
|
||||
) -> CommandResult<Vec<AccountModel>> {
|
||||
let filter = AccountFilter {
|
||||
account_type: account_type.and_then(|s| AccountType::from_str(&s).ok()), // Convert string to AccountType enum
|
||||
include_archived,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
state.account_service().get_accounts(filter, None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_account(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> CommandResult<AccountModel> {
|
||||
state.account_service().get_account(id, None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_account(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
updates: UpdateAccountInput,
|
||||
) -> CommandResult<AccountModel> {
|
||||
state
|
||||
.account_service()
|
||||
.update_account(id, updates, None)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn archive_account(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
archived: bool,
|
||||
) -> CommandResult<()> {
|
||||
state
|
||||
.account_service()
|
||||
.archive_account(id, archived, None)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_account(state: tauri::State<'_, AppState>, id: String) -> CommandResult<()> {
|
||||
state.account_service().delete_account(id, None).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_account_balance(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
as_of_date: Option<String>,
|
||||
) -> CommandResult<AccountBalance> {
|
||||
state
|
||||
.account_service()
|
||||
.get_balance(id, as_of_date, None)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn recalculate_account_balance(
|
||||
state: tauri::State<'_, AppState>,
|
||||
id: String,
|
||||
) -> CommandResult<String> {
|
||||
let balance = state
|
||||
.account_service()
|
||||
.recalculate_balance(&id, None)
|
||||
.await?;
|
||||
Ok(balance.to_string())
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
pub mod accounts;
|
||||
pub mod exchange_rate;
|
||||
pub mod settings;
|
||||
|
||||
@@ -2,8 +2,8 @@ use std::path::PathBuf;
|
||||
|
||||
use log::error;
|
||||
use sea_orm::{
|
||||
ConnectionTrait, Database, DatabaseConnection, DatabaseTransaction, DbBackend, DbErr,
|
||||
ExecResult, QueryResult, Statement,
|
||||
ConnectOptions, ConnectionTrait, Database, DatabaseConnection, DatabaseTransaction, DbBackend,
|
||||
DbErr, ExecResult, QueryResult, Statement,
|
||||
};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
@@ -27,10 +27,18 @@ pub(super) async fn establish_connection(
|
||||
|
||||
let db_path = app_dir.join(DATABASE_PATH);
|
||||
let url = format!("sqlite://{}?mode=rwc", db_path.display());
|
||||
let mut opt = ConnectOptions::new(url);
|
||||
opt.sqlx_logging_level(log::LevelFilter::Debug);
|
||||
opt.min_connections(0);
|
||||
opt.max_connections(10);
|
||||
opt.sqlx_slow_statements_logging_settings(
|
||||
log::LevelFilter::Warn,
|
||||
std::time::Duration::from_secs(1),
|
||||
);
|
||||
|
||||
println!("Connecting to database at: {}", db_path.display());
|
||||
|
||||
let db = Database::connect(&url).await?;
|
||||
let db = Database::connect(opt).await?;
|
||||
|
||||
// Enable foreign keys and set pragmas
|
||||
sea_orm::ConnectionTrait::execute_unprepared(
|
||||
|
||||
@@ -65,6 +65,15 @@ pub fn run() {
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Account commands
|
||||
commands::accounts::create_account,
|
||||
commands::accounts::get_accounts,
|
||||
commands::accounts::get_account,
|
||||
commands::accounts::update_account,
|
||||
commands::accounts::archive_account,
|
||||
commands::accounts::delete_account,
|
||||
commands::accounts::get_account_balance,
|
||||
commands::accounts::recalculate_account_balance,
|
||||
// Exchange rate commands
|
||||
commands::exchange_rate::get_exchange_rate,
|
||||
commands::exchange_rate::get_supported_currencies,
|
||||
|
||||
2
src-tauri/src/services/accounts/mod.rs
Normal file
2
src-tauri/src/services/accounts/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod service;
|
||||
pub mod types;
|
||||
1494
src-tauri/src/services/accounts/service.rs
Normal file
1494
src-tauri/src/services/accounts/service.rs
Normal file
File diff suppressed because it is too large
Load Diff
185
src-tauri/src/services/accounts/types/inputs.rs
Normal file
185
src-tauri/src/services/accounts/types/inputs.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
use lazy_static::lazy_static;
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
lazy_static! {
|
||||
static ref HEX_COLOR_PATTERN: regex::Regex =
|
||||
#[expect(clippy::expect_used)]
|
||||
regex::Regex::new(r"^#([0-9A-Fa-f]{3,4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$")
|
||||
.expect("Invalid hex color regex pattern");
|
||||
}
|
||||
|
||||
const CHECKING: &str = "checking";
|
||||
const SAVINGS: &str = "savings";
|
||||
const CREDIT_CARD: &str = "credit_card";
|
||||
const INVESTMENT: &str = "investment";
|
||||
const LOAN: &str = "loan";
|
||||
const CASH: &str = "cash";
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub enum AccountType {
|
||||
#[serde(rename = "checking")]
|
||||
Checking,
|
||||
#[serde(rename = "savings")]
|
||||
Savings,
|
||||
#[serde(rename = "credit_card")]
|
||||
CreditCard,
|
||||
#[serde(rename = "investment")]
|
||||
Investment,
|
||||
#[serde(rename = "loan")]
|
||||
Loan,
|
||||
#[serde(rename = "cash")]
|
||||
Cash,
|
||||
// the provided string will be used as the account type, allowing for custom types without needing to update the enum
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl std::str::FromStr for AccountType {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
CHECKING => Ok(AccountType::Checking),
|
||||
SAVINGS => Ok(AccountType::Savings),
|
||||
CREDIT_CARD => Ok(AccountType::CreditCard),
|
||||
INVESTMENT => Ok(AccountType::Investment),
|
||||
LOAN => Ok(AccountType::Loan),
|
||||
CASH => Ok(AccountType::Cash),
|
||||
other => Ok(AccountType::Other(other.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AccountType> for String {
|
||||
fn from(account_type: AccountType) -> Self {
|
||||
match account_type {
|
||||
AccountType::Other(s) => s,
|
||||
_ => serde_json::to_string(&account_type)
|
||||
.unwrap_or_else(|err| {
|
||||
log::error!("Failed to serialize AccountType: {}", err);
|
||||
"\"unknown\"".to_string()
|
||||
})
|
||||
.replace('"', ""),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateAccountInput {
|
||||
pub name: String,
|
||||
pub account_type: AccountType,
|
||||
pub currency: String,
|
||||
pub initial_balance: String,
|
||||
// hex color code, e.g. #FF0000
|
||||
pub color: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
|
||||
impl CreateAccountInput {
|
||||
/// Validates create account input
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.name.trim().is_empty() {
|
||||
return Err("Account name is required".to_string());
|
||||
}
|
||||
if self.name.len() > 100 {
|
||||
return Err("Account name cannot exceed 100 characters".to_string());
|
||||
}
|
||||
let currency_upper = self.currency.to_uppercase();
|
||||
if currency_upper.len() != 3 {
|
||||
return Err("Currency code must be 3 characters (ISO 4217)".to_string());
|
||||
}
|
||||
if !currency_upper.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
return Err("Currency code must contain only letters".to_string());
|
||||
}
|
||||
// Validate account type
|
||||
let _account_type: AccountType = self.account_type.clone();
|
||||
if matches!(self.account_type, AccountType::Other(ref s) if s.trim().is_empty()) {
|
||||
return Err("Account type cannot be empty".to_string());
|
||||
}
|
||||
if Decimal::from_str_exact(&self.initial_balance).is_err() {
|
||||
return Err("Initial balance must be a valid decimal number".to_string());
|
||||
}
|
||||
if let Some(ref color) = self.color {
|
||||
if !color.trim().is_empty() && !HEX_COLOR_PATTERN.is_match(color) {
|
||||
return Err(
|
||||
"Color must be a valid hex code (e.g., #FF0000 or #FF0000FF)".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct UpdateAccountInput {
|
||||
pub name: Option<String>,
|
||||
pub account_type: Option<AccountType>,
|
||||
pub currency: Option<String>,
|
||||
pub initial_balance: Option<String>,
|
||||
// hex color code, e.g. #FF0000
|
||||
pub color: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub sort_order: Option<i32>,
|
||||
pub is_active: Option<bool>,
|
||||
pub is_archived: Option<bool>,
|
||||
pub include_in_net_worth: Option<bool>,
|
||||
pub show_in_combined_view: Option<bool>,
|
||||
}
|
||||
|
||||
impl UpdateAccountInput {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
// Validate name if provided
|
||||
if let Some(name) = &self.name {
|
||||
if name.trim().is_empty() {
|
||||
return Err("Account name cannot be empty".to_string());
|
||||
}
|
||||
if name.len() > 100 {
|
||||
return Err("Account name cannot exceed 100 characters".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Validate currency code if provided
|
||||
if let Some(currency) = &self.currency {
|
||||
let currency_upper = currency.to_uppercase();
|
||||
if currency_upper.len() != 3 {
|
||||
return Err("Currency code must be 3 characters (ISO 4217)".to_string());
|
||||
}
|
||||
if !currency_upper.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
return Err("Currency code must contain only letters".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Validate account type if provided
|
||||
if let Some(account_type) = &self.account_type {
|
||||
if matches!(account_type, AccountType::Other(s) if s.trim().is_empty()) {
|
||||
return Err("Account type cannot be empty".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Validate color format if provided
|
||||
if let Some(color) = &self.color {
|
||||
if !color.trim().is_empty() && !HEX_COLOR_PATTERN.is_match(color) {
|
||||
return Err(
|
||||
"Color must be a valid hex code (e.g., #FF0000 or #FF0000FF)".to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate initial_balance if provided
|
||||
if let Some(balance) = &self.initial_balance {
|
||||
if Decimal::from_str_exact(balance).is_err() {
|
||||
return Err("Initial balance must be a valid decimal number".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
pub struct AccountFilter {
|
||||
pub account_type: Option<AccountType>,
|
||||
pub include_archived: Option<bool>,
|
||||
pub limit: Option<u64>,
|
||||
pub offset: Option<u64>,
|
||||
}
|
||||
2
src-tauri/src/services/accounts/types/mod.rs
Normal file
2
src-tauri/src/services/accounts/types/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod inputs;
|
||||
pub mod outputs;
|
||||
7
src-tauri/src/services/accounts/types/outputs.rs
Normal file
7
src-tauri/src/services/accounts/types/outputs.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AccountBalance {
|
||||
pub amount: String,
|
||||
pub currency: String,
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use sea_orm::DatabaseConnection;
|
||||
|
||||
use crate::errors::CommandResult;
|
||||
|
||||
pub mod accounts;
|
||||
pub mod exchange_rate;
|
||||
pub mod settings;
|
||||
|
||||
@@ -18,20 +19,26 @@ pub trait ServiceTrait: Send + Sync {
|
||||
pub struct ServiceFactory;
|
||||
|
||||
pub struct ServiceFactoryResult {
|
||||
pub account_service: Arc<dyn accounts::service::AccountService>,
|
||||
pub settings_service: Arc<dyn settings::service::SettingsService>,
|
||||
pub exchange_rate_service: Arc<dyn exchange_rate::service::ExchangeRateService>,
|
||||
}
|
||||
|
||||
impl ServiceFactory {
|
||||
pub async fn create_services(db: DatabaseConnection) -> ServiceFactoryResult {
|
||||
let account_service = Arc::new(accounts::service::AccountServiceImpl::new(db.clone()));
|
||||
let settings_service = Arc::new(settings::service::SettingsServiceImpl::new(db.clone()));
|
||||
let exchange_rate_service = Arc::new(
|
||||
exchange_rate::service::ExchangeRateServiceImpl::new(db, settings_service.clone())
|
||||
.await,
|
||||
exchange_rate::service::ExchangeRateServiceImpl::new(
|
||||
db.clone(),
|
||||
settings_service.clone(),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
ServiceFactoryResult {
|
||||
settings_service,
|
||||
account_service,
|
||||
exchange_rate_service,
|
||||
settings_service,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,26 @@ impl SettingsService for SettingsServiceImpl {
|
||||
s.clone()
|
||||
} else if let Some(i) = v.downcast_ref::<i32>() {
|
||||
i.to_string()
|
||||
} else if let Some(e) =
|
||||
v.downcast_ref::<crate::services::settings::types::language::Language>()
|
||||
{
|
||||
e.to_string()
|
||||
} else if let Some(e) =
|
||||
v.downcast_ref::<crate::services::settings::types::view::View>()
|
||||
{
|
||||
e.to_string()
|
||||
} else if let Some(e) =
|
||||
v.downcast_ref::<crate::services::settings::types::display_mode::DisplayMode>()
|
||||
{
|
||||
e.to_string()
|
||||
} else if let Some(e) =
|
||||
v.downcast_ref::<crate::services::settings::types::theme::Theme>()
|
||||
{
|
||||
e.to_string()
|
||||
} else if let Some(e) =
|
||||
v.downcast_ref::<crate::services::settings::types::date_of_week::DateOfWeek>()
|
||||
{
|
||||
e.to_string()
|
||||
} else {
|
||||
warn!("Unsupported setting type for key '{k}'");
|
||||
return None;
|
||||
|
||||
@@ -3,12 +3,13 @@ use std::sync::Arc;
|
||||
use crate::{
|
||||
db::service::DbService,
|
||||
services::{
|
||||
ServiceFactoryResult, ServiceTrait, exchange_rate::service::ExchangeRateService,
|
||||
settings::service::SettingsService,
|
||||
ServiceFactoryResult, ServiceTrait, accounts::service::AccountService,
|
||||
exchange_rate::service::ExchangeRateService, settings::service::SettingsService,
|
||||
},
|
||||
};
|
||||
pub struct AppState {
|
||||
db: DbService,
|
||||
account_service: Arc<dyn AccountService>,
|
||||
settings_service: Arc<dyn SettingsService>,
|
||||
exchange_rate_service: Arc<dyn ExchangeRateService>,
|
||||
}
|
||||
@@ -18,6 +19,7 @@ impl AppState {
|
||||
pub async fn new(db: DbService, services: ServiceFactoryResult) -> Self {
|
||||
Self {
|
||||
db,
|
||||
account_service: services.account_service,
|
||||
settings_service: services.settings_service,
|
||||
exchange_rate_service: services.exchange_rate_service,
|
||||
}
|
||||
@@ -26,6 +28,12 @@ impl AppState {
|
||||
pub fn db(&self) -> &DbService {
|
||||
&self.db
|
||||
}
|
||||
|
||||
/// Get the account service
|
||||
pub fn account_service(&self) -> &Arc<dyn AccountService> {
|
||||
&self.account_service
|
||||
}
|
||||
|
||||
/// Get the settings service
|
||||
pub fn settings_service(&self) -> &Arc<dyn SettingsService> {
|
||||
&self.settings_service
|
||||
@@ -40,6 +48,7 @@ impl AppState {
|
||||
// Call on_app_start for all services
|
||||
self.settings_service.on_app_start().await?;
|
||||
self.exchange_rate_service.on_app_start().await?;
|
||||
self.account_service.on_app_start().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user