9 Commits

Author SHA1 Message Date
4e4a656c88 Merge pull request 'feat: implement account management commands and services' (#9) from feature/accounts-service into master
All checks were successful
Lint / lint-frontend (push) Successful in 20s
Test / test-crates (push) Successful in 1m46s
Lint / lint-crates (push) Successful in 2m17s
Reviewed-on: http://gitea.gwmc.dev/finwise/finwise/pulls/9
2026-02-25 18:04:48 +08:00
3ff421c200 feat: extend setting type handling to include Language, View, DisplayMode, Theme, and DateOfWeek
All checks were successful
Lint / lint-frontend (pull_request) Successful in 38s
Test / test-crates (pull_request) Successful in 1m48s
Lint / lint-crates (pull_request) Successful in 2m16s
2026-02-25 09:59:26 +00:00
75efe5768a fix: update database connection options to use ConnectOptions for improved logging 2026-02-25 09:58:59 +00:00
620df5780b refactor: improve HEX_COLOR_PATTERN initialization with Clippy expectation
All checks were successful
Lint / lint-crates (pull_request) Successful in 1m5s
Lint / lint-frontend (pull_request) Successful in 16s
Test / test-crates (pull_request) Successful in 1m25s
2026-02-23 08:51:51 +00:00
6b987181a8 refactor: improve formatting of apply_transaction_to_balance function and add TODO for transaction service integration
Some checks failed
Lint / lint-crates (pull_request) Failing after 3m2s
Lint / lint-frontend (pull_request) Successful in 15s
Test / test-crates (pull_request) Successful in 1m47s
2026-02-23 08:48:54 +00:00
bf04d8d2da feat: add recalculate_account_balance command and enhance account validation 2026-02-23 08:48:43 +00:00
7ffc3bac00 feat: enhance account management with filtering and validation improvements
All checks were successful
Lint / lint-frontend (pull_request) Successful in 21s
Lint / lint-crates (pull_request) Successful in 3m6s
Test / test-crates (pull_request) Successful in 4m6s
2026-02-23 04:53:37 +00:00
7448bbd5e0 feat: implement account management commands and services
All checks were successful
Lint / lint-frontend (pull_request) Successful in 19s
Test / test-crates (pull_request) Successful in 1m33s
Lint / lint-crates (pull_request) Successful in 2m1s
- Added account commands for creating, retrieving, updating, archiving, deleting accounts, and fetching account balances.
- Created account service with async methods for account operations.
- Defined input and output types for account operations.
- Integrated account service into the application state and service factory.
- Added tests for account service methods to ensure functionality and correctness.
2026-02-21 03:14:02 +00:00
8013f2ad61 Merge pull request 'feature/exchange-rate-service' (#8) from feature/exchange-rate-service into master
All checks were successful
Lint / lint-frontend (push) Successful in 19s
Test / test-crates (push) Successful in 1m30s
Lint / lint-crates (push) Successful in 1m58s
Reviewed-on: http://gitea.gwmc.dev/finwise/finwise/pulls/8
2026-02-21 10:26:35 +08:00
14 changed files with 1855 additions and 8 deletions

2
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"] }

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

View File

@@ -1,2 +1,3 @@
pub mod accounts;
pub mod exchange_rate;
pub mod settings;

View File

@@ -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(

View File

@@ -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,

View File

@@ -0,0 +1,2 @@
pub mod service;
pub mod types;

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1,2 @@
pub mod inputs;
pub mod outputs;

View File

@@ -0,0 +1,7 @@
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct AccountBalance {
pub amount: String,
pub currency: String,
}

View File

@@ -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,
}
}
}

View File

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

View File

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