diff --git a/package.json b/package.json index 02f1c21..fcadb1c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wealthfolio-app", "private": true, - "version": "1.0.13", + "version": "1.0.14", "type": "module", "scripts": { "dev": "vite", diff --git a/src-core/Cargo.lock b/src-core/Cargo.lock index dedf621..54e799b 100644 --- a/src-core/Cargo.lock +++ b/src-core/Cargo.lock @@ -293,6 +293,20 @@ dependencies = [ "syn 2.0.76", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "deranged" version = "0.3.11" @@ -315,6 +329,7 @@ dependencies = [ "num-bigint", "num-integer", "num-traits", + "r2d2", "time", ] @@ -1228,6 +1243,17 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + [[package]] name = "rand" version = "0.8.5" @@ -1476,6 +1502,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1794,9 +1829,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.39.3" +version = "1.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babc99b9923bfa4804bd74722ff02c0381021eafa4db9949217e3be8e84fff5" +checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" dependencies = [ "backtrace", "bytes", @@ -2080,9 +2115,11 @@ version = "1.0.11" dependencies = [ "chrono", "csv", + "dashmap", "diesel", "diesel_migrations", "lazy_static", + "r2d2", "rayon", "regex", "reqwest", diff --git a/src-core/Cargo.toml b/src-core/Cargo.toml index b70a6a6..76af3ef 100644 --- a/src-core/Cargo.toml +++ b/src-core/Cargo.toml @@ -3,7 +3,7 @@ name = "wealthfolio_core" version = "1.0.11" description = "Portfolio tracker" authors = ["Aziz Fadil"] -license = "MIT" +license = "LGPL-3.0" repository = "" edition = "2021" @@ -12,7 +12,7 @@ edition = "2021" [dependencies] serde = { version = "1.0.210", features = ["derive"] } serde_json = "1.0.128" -diesel = { version = "2.2.4", features = ["sqlite", "chrono", "numeric", "returning_clauses_for_sqlite_3_35"] } +diesel = { version = "2.2.4", features = ["sqlite", "chrono", "r2d2", "numeric", "returning_clauses_for_sqlite_3_35"] } chrono = { version = "0.4.38", features = ["serde"] } uuid = { version = "1.10.0", features = ["v4"] } rusqlite = { version = "0.32.1", features = ["bundled"] } @@ -24,3 +24,5 @@ thiserror = "1.0.63" lazy_static = "1.5.0" diesel_migrations = { version = "2.2.0", features = ["sqlite" ] } rayon = "1.10.0" +r2d2 = "0.8.10" +dashmap = "6.1.0" \ No newline at end of file diff --git a/src-core/migrations/2023-11-08-162221_init_db/down.sql b/src-core/migrations/2023-11-08-162221_init_db/down.sql index d9a93fe..404faac 100644 --- a/src-core/migrations/2023-11-08-162221_init_db/down.sql +++ b/src-core/migrations/2023-11-08-162221_init_db/down.sql @@ -1 +1,13 @@ --- This file should undo anything in `up.sql` + +DROP TABLE IF EXISTS goals_allocation; +DROP TABLE IF EXISTS goals; +DROP TABLE IF EXISTS settings; +DROP TABLE IF EXISTS quotes; +DROP TABLE IF EXISTS activities; +DROP TABLE IF EXISTS assets; +DROP TABLE IF EXISTS accounts; +DROP TABLE IF EXISTS platforms; + +DROP INDEX IF EXISTS market_data_data_source_date_symbol_key; +DROP INDEX IF EXISTS market_data_symbol_idx; +DROP INDEX IF EXISTS assets_data_source_symbol_key; diff --git a/src-core/migrations/2024-09-16-023604_portfolio_history/down.sql b/src-core/migrations/2024-09-16-023604_portfolio_history/down.sql new file mode 100644 index 0000000..26d8d59 --- /dev/null +++ b/src-core/migrations/2024-09-16-023604_portfolio_history/down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS portfolio_history; +DROP INDEX IF EXISTS idx_portfolio_history_account_date; + +DROP TABLE IF EXISTS exchange_rates; +DROP INDEX IF EXISTS idx_exchange_rates_currencies; diff --git a/src-core/migrations/2024-09-16-023604_portfolio_history/up.sql b/src-core/migrations/2024-09-16-023604_portfolio_history/up.sql new file mode 100644 index 0000000..0c4d329 --- /dev/null +++ b/src-core/migrations/2024-09-16-023604_portfolio_history/up.sql @@ -0,0 +1,44 @@ +CREATE TABLE portfolio_history ( + id TEXT NOT NULL PRIMARY KEY, + account_id TEXT NOT NULL, + date DATE NOT NULL, + total_value NUMERIC NOT NULL DEFAULT 0, + market_value NUMERIC NOT NULL DEFAULT 0, + book_cost NUMERIC NOT NULL DEFAULT 0, + available_cash NUMERIC NOT NULL DEFAULT 0, + net_deposit NUMERIC NOT NULL DEFAULT 0, + currency TEXT NOT NULL, + base_currency TEXT NOT NULL, + total_gain_value NUMERIC NOT NULL DEFAULT 0, + total_gain_percentage NUMERIC NOT NULL DEFAULT 0, + day_gain_percentage NUMERIC NOT NULL DEFAULT 0, + day_gain_value NUMERIC NOT NULL DEFAULT 0, + allocation_percentage NUMERIC NOT NULL DEFAULT 0, + exchange_rate NUMERIC NOT NULL DEFAULT 0, + holdings TEXT, + UNIQUE(account_id, date) +); +CREATE INDEX idx_portfolio_history_account_date ON portfolio_history(account_id, date); + +-- change goals table column types +ALTER TABLE "goals" ADD COLUMN "target_amount_new" NUMERIC NOT NULL DEFAULT 0; +UPDATE "goals" SET "target_amount_new" = "target_amount"; +ALTER TABLE "goals" DROP COLUMN "target_amount"; +ALTER TABLE "goals" RENAME COLUMN "target_amount_new" TO "target_amount"; +ALTER TABLE "goals" ADD COLUMN "is_achieved_new" BOOLEAN NOT NULL DEFAULT false; +UPDATE "goals" SET "is_achieved_new" = COALESCE("is_achieved", false); +ALTER TABLE "goals" DROP COLUMN "is_achieved"; +ALTER TABLE "goals" RENAME COLUMN "is_achieved_new" TO "is_achieved"; + +CREATE TABLE exchange_rates ( + id TEXT NOT NULL PRIMARY KEY, + from_currency TEXT NOT NULL, + to_currency TEXT NOT NULL, + rate NUMERIC NOT NULL, + source TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(from_currency, to_currency) +); + +CREATE INDEX idx_exchange_rates_currencies ON exchange_rates(from_currency, to_currency); diff --git a/src-core/migrations/2024-09-21-023605_settings_to_kv/down.sql b/src-core/migrations/2024-09-21-023605_settings_to_kv/down.sql new file mode 100644 index 0000000..75419f7 --- /dev/null +++ b/src-core/migrations/2024-09-21-023605_settings_to_kv/down.sql @@ -0,0 +1,17 @@ +-- Create a temporary table with the original structure +CREATE TABLE "settings" ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + theme TEXT NOT NULL DEFAULT 'light', + font TEXT NOT NULL, + base_currency TEXT NOT NULL +); + +-- Migrate data back from app_settings to settings +INSERT INTO settings (theme, font, base_currency) +SELECT + (SELECT setting_value FROM app_settings WHERE setting_key = 'theme'), + (SELECT setting_value FROM app_settings WHERE setting_key = 'font'), + (SELECT setting_value FROM app_settings WHERE setting_key = 'base_currency'); + +-- Drop the new app_settings table +DROP TABLE "app_settings"; \ No newline at end of file diff --git a/src-core/migrations/2024-09-21-023605_settings_to_kv/up.sql b/src-core/migrations/2024-09-21-023605_settings_to_kv/up.sql new file mode 100644 index 0000000..63f567b --- /dev/null +++ b/src-core/migrations/2024-09-21-023605_settings_to_kv/up.sql @@ -0,0 +1,16 @@ +-- Create the new app_settings table with key-value structure +CREATE TABLE "app_settings" ( + "setting_key" TEXT NOT NULL PRIMARY KEY, + "setting_value" TEXT NOT NULL +); + +-- Migrate existing settings to the new table +INSERT INTO "app_settings" ("setting_key", "setting_value") +SELECT 'theme', theme FROM settings +UNION ALL +SELECT 'font', font FROM settings +UNION ALL +SELECT 'base_currency', base_currency FROM settings; + +-- Drop the old settings table +DROP TABLE "settings"; \ No newline at end of file diff --git a/src-core/migrations/2024-09-22-012202_init_exchange_rates/down.sql b/src-core/migrations/2024-09-22-012202_init_exchange_rates/down.sql new file mode 100644 index 0000000..e69de29 diff --git a/src-core/migrations/2024-09-22-012202_init_exchange_rates/up.sql b/src-core/migrations/2024-09-22-012202_init_exchange_rates/up.sql new file mode 100644 index 0000000..fee0f1f --- /dev/null +++ b/src-core/migrations/2024-09-22-012202_init_exchange_rates/up.sql @@ -0,0 +1,44 @@ +-- Get the base currency from app_setting +WITH base_currency AS ( + SELECT setting_value AS currency + FROM app_settings + WHERE setting_key = 'base_currency' +) + +-- Insert exchange rates for accounts +INSERT OR IGNORE INTO exchange_rates (id, from_currency, to_currency, rate, source) +SELECT + base_currency.currency || accounts.currency || '=X' AS id, + base_currency.currency, + accounts.currency, + 1.0, -- Default rate, to be updated later + 'MANUAL' +FROM accounts +CROSS JOIN base_currency +WHERE accounts.currency != base_currency.currency + +UNION + +-- Insert exchange rates for activities +SELECT DISTINCT + accounts.currency || activities.currency || '=X' AS id, + accounts.currency, + activities.currency, + 1.0, -- Default rate, to be updated later + 'MANUAL' +FROM activities +JOIN accounts ON activities.account_id = accounts.id +WHERE activities.currency != accounts.currency + +UNION + +-- Insert exchange rates from base currency to activity currency +SELECT DISTINCT + base_currency.currency || activities.currency || '=X' AS id, + base_currency.currency, + activities.currency, + 1.0, -- Default rate, to be updated later + 'MANUAL' +FROM activities +CROSS JOIN base_currency +WHERE activities.currency != base_currency.currency; \ No newline at end of file diff --git a/src-core/src/account/account_repository.rs b/src-core/src/account/account_repository.rs index 6d88244..a3c2f91 100644 --- a/src-core/src/account/account_repository.rs +++ b/src-core/src/account/account_repository.rs @@ -67,4 +67,16 @@ impl AccountRepository { diesel::delete(accounts.filter(id.eq(account_id))).execute(conn) } + + pub fn load_accounts_by_ids( + &self, + conn: &mut SqliteConnection, + account_ids: &[String], + ) -> Result, diesel::result::Error> { + accounts + .filter(id.eq_any(account_ids)) + .filter(is_active.eq(true)) + .order(created_at.desc()) + .load::(conn) + } } diff --git a/src-core/src/account/account_service.rs b/src-core/src/account/account_service.rs index 2326d4b..9165c91 100644 --- a/src-core/src/account/account_service.rs +++ b/src-core/src/account/account_service.rs @@ -1,105 +1,84 @@ use crate::account::AccountRepository; -use crate::asset::asset_service::AssetService; +use crate::fx::fx_service::CurrencyExchangeService; use crate::models::{Account, AccountUpdate, NewAccount}; -use crate::settings::SettingsService; -use diesel::prelude::*; +use diesel::r2d2::{ConnectionManager, Pool}; +use diesel::Connection; use diesel::SqliteConnection; pub struct AccountService { account_repo: AccountRepository, - asset_service: AssetService, + pool: Pool>, + base_currency: String, } impl AccountService { - pub fn new() -> Self { + pub fn new(pool: Pool>, base_currency: String) -> Self { AccountService { account_repo: AccountRepository::new(), - asset_service: AssetService::new(), + pool, + base_currency, } } - pub fn get_accounts( - &self, - conn: &mut SqliteConnection, - ) -> Result, diesel::result::Error> { - self.account_repo.load_accounts(conn) + pub fn get_accounts(&self) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.account_repo.load_accounts(&mut conn) } - //get account by id - pub fn get_account_by_id( - &self, - conn: &mut SqliteConnection, - account_id: &str, - ) -> Result { - self.account_repo.load_account_by_id(conn, account_id) + pub fn get_account_by_id(&self, account_id: &str) -> Result { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.account_repo.load_account_by_id(&mut conn, account_id) } - pub fn create_account( + pub async fn create_account( &self, - conn: &mut SqliteConnection, new_account: NewAccount, - ) -> Result { - //get base currency - let settings_service = SettingsService::new(); - let settings = settings_service.get_settings(conn)?; - let base_currency = settings.base_currency.clone(); + ) -> Result> { + let mut conn = self.pool.get()?; + let base_currency = self.base_currency.clone(); + println!( + "Creating account..., base_currency: {}, new_account.currency: {}", + base_currency, new_account.currency + ); conn.transaction(|conn| { - //if the account currency is not the same as the base currency, then create the exchange rate asset so that we can track the exchange rate if new_account.currency != base_currency { - // Create the $EXCHANGE_RATE asset - let asset_id = format!("{}{}=X", base_currency, new_account.currency); - - //load the asset profile from the database or create it if not found - let _asset_profile = self - .asset_service - .get_asset_by_id(conn, &asset_id) - .unwrap_or_default(); - - if _asset_profile.id.is_empty() { - let _asset_profile = self.asset_service.create_rate_exchange_asset( - conn, - &base_currency, - &new_account.currency, - )?; - } - } - - // Create the $CASH-CURRENCY asset - let asset_id = format!("$CASH-{}", new_account.currency); - - //load the asset profile from the database or create it if not found - let _asset_profile = self - .asset_service - .get_asset_by_id(conn, &asset_id) - .unwrap_or_default(); - - if _asset_profile.id.is_empty() { - let _asset_profile = self - .asset_service - .create_cash_asset(conn, &new_account.currency)?; + let fx_service = CurrencyExchangeService::new(self.pool.clone()); + fx_service + .add_exchange_rate(base_currency.clone(), new_account.currency.clone())?; } - drop(_asset_profile); - let account = self.account_repo.insert_new_account(conn, new_account)?; - - Ok(account) + // Insert new account + self.account_repo + .insert_new_account(conn, new_account) + .map_err(|e| Box::new(e) as Box) }) } pub fn update_account( &self, - conn: &mut SqliteConnection, updated_account_data: AccountUpdate, ) -> Result { - self.account_repo.update_account(conn, updated_account_data) + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.account_repo + .update_account(&mut conn, updated_account_data) } pub fn delete_account( &self, - conn: &mut SqliteConnection, - account_id_to_delete: String, // ID of the account to delete + account_id_to_delete: String, ) -> Result { - self.account_repo.delete_account(conn, account_id_to_delete) + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.account_repo + .delete_account(&mut conn, account_id_to_delete) + } + + pub fn get_accounts_by_ids( + &self, + account_ids: &[String], + ) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.account_repo + .load_accounts_by_ids(&mut conn, account_ids) } } diff --git a/src-core/src/activity/activity_repository.rs b/src-core/src/activity/activity_repository.rs index 5e2e376..347a058 100644 --- a/src-core/src/activity/activity_repository.rs +++ b/src-core/src/activity/activity_repository.rs @@ -28,6 +28,19 @@ impl ActivityRepository { .load::(conn) } + pub fn get_income_activities( + &self, + conn: &mut SqliteConnection, + ) -> Result, diesel::result::Error> { + activities::table + .inner_join(accounts::table.on(accounts::id.eq(activities::account_id))) + .filter(accounts::is_active.eq(true)) + .filter(activities::activity_type.eq_any(vec!["DIVIDEND", "INTEREST"])) + .select(activities::all_columns) + .order(activities::activity_date.asc()) + .load::(conn) + } + pub fn get_activities( &self, conn: &mut SqliteConnection, @@ -176,7 +189,27 @@ impl ActivityRepository { &self, conn: &mut SqliteConnection, activity_id: String, - ) -> Result { - diesel::delete(activities::table.filter(activities::id.eq(activity_id))).execute(conn) + ) -> Result { + let activity = activities::table + .filter(activities::id.eq(&activity_id)) + .first::(conn)?; + + diesel::delete(activities::table.filter(activities::id.eq(activity_id))).execute(conn)?; + + Ok(activity) + } + + pub fn get_activities_by_account_ids( + &self, + conn: &mut SqliteConnection, + account_ids: &[String], + ) -> Result, diesel::result::Error> { + activities::table + .inner_join(accounts::table.on(accounts::id.eq(activities::account_id))) + .filter(accounts::is_active.eq(true)) + .filter(activities::account_id.eq_any(account_ids)) + .select(activities::all_columns) + .order(activities::activity_date.asc()) + .load::(conn) } } diff --git a/src-core/src/activity/activity_service.rs b/src-core/src/activity/activity_service.rs index c02ed45..af3c18f 100644 --- a/src-core/src/activity/activity_service.rs +++ b/src-core/src/activity/activity_service.rs @@ -3,57 +3,62 @@ use std::fs::File; use crate::account::AccountService; use crate::activity::ActivityRepository; use crate::asset::asset_service::AssetService; +use crate::fx::fx_service::CurrencyExchangeService; use crate::models::{ - Activity, ActivityImport, ActivitySearchResponse, ActivityUpdate, NewActivity, Sort, + Activity, ActivityImport, ActivitySearchResponse, ActivityUpdate, IncomeData, NewActivity, Sort, }; use crate::schema::activities; use csv::ReaderBuilder; use diesel::prelude::*; +use diesel::r2d2::{ConnectionManager, Pool}; + use uuid::Uuid; pub struct ActivityService { repo: ActivityRepository, - asset_service: AssetService, - account_service: AccountService, + pool: Pool>, + base_currency: String, } impl ActivityService { - pub fn new() -> Self { + pub fn new(pool: Pool>, base_currency: String) -> Self { ActivityService { repo: ActivityRepository::new(), - asset_service: AssetService::new(), - account_service: AccountService::new(), + pool, + base_currency, } } - // delete an activity - pub fn delete_activity( - &self, - conn: &mut SqliteConnection, - activity_id: String, - ) -> Result { - self.repo.delete_activity(conn, activity_id) + //load all activities + pub fn get_activities(&self) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.repo.get_activities(&mut conn) } - //load all activities - pub fn get_activities( - &self, - conn: &mut SqliteConnection, - ) -> Result, diesel::result::Error> { - self.repo.get_activities(conn) + pub fn get_trading_activities(&self) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.repo.get_trading_activities(&mut conn) } - pub fn get_trading_activities( - &self, - conn: &mut SqliteConnection, - ) -> Result, diesel::result::Error> { - self.repo.get_trading_activities(conn) + pub fn get_income_data(&self) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.repo.get_income_activities(&mut conn).map(|results| { + results + .into_iter() + .map(|activity| IncomeData { + date: activity.activity_date, + income_type: activity.activity_type, + symbol: activity.asset_id, + amount: activity.quantity * activity.unit_price, + currency: activity.currency, + }) + .collect() + }) } pub fn search_activities( &self, - conn: &mut SqliteConnection, page: i64, // Page number, 1-based page_size: i64, // Number of items per page account_id_filter: Option>, // Optional account_id filter @@ -61,8 +66,9 @@ impl ActivityService { asset_id_keyword: Option, // Optional asset_id keyword for search sort: Option, // Optional sort ) -> Result { + let mut conn = self.pool.get().expect("Couldn't get db connection"); self.repo.search_activities( - conn, + &mut conn, page, page_size, account_id_filter, @@ -75,39 +81,93 @@ impl ActivityService { //create a new activity and fetch related the asset profile pub async fn create_activity( &self, - conn: &mut SqliteConnection, mut activity: NewActivity, - ) -> Result { - // Clone asset_id to avoid moving it + ) -> Result> { + let mut conn = self.pool.get()?; let asset_id = activity.asset_id.clone(); + let asset_service = AssetService::new(self.pool.clone()).await; + let account_service = AccountService::new(self.pool.clone(), self.base_currency.clone()); + let asset_profile = asset_service + .get_asset_profile(&asset_id, Some(true)) + .await?; + let account = account_service.get_account_by_id(&activity.account_id)?; + + conn.transaction(|conn| { + // Update activity currency if asset_profile.currency is available + if !asset_profile.currency.is_empty() { + activity.currency = asset_profile.currency.clone(); + } + // Adjust unit price based on activity type + if ["DEPOSIT", "WITHDRAWAL", "INTEREST", "FEE", "DIVIDEND"] + .contains(&activity.activity_type.as_str()) + { + activity.unit_price = 1.0; + } - // fetch the asset profile from the database or create it if not found - let _asset_profile = self - .asset_service - .get_asset_profile(conn, &asset_id) + // Create exchange rate if asset currency is different from account currency + if activity.currency != account.currency { + let fx_service = CurrencyExchangeService::new(self.pool.clone()); + fx_service + .add_exchange_rate(account.currency.clone(), activity.currency.clone())?; + } + + // Insert the new activity into the database + let inserted_activity = self.repo.insert_new_activity(conn, activity)?; + + Ok(inserted_activity) + }) + } + + // update an activity + pub async fn update_activity( + &self, + mut activity: ActivityUpdate, + ) -> Result> { + let mut conn = self.pool.get()?; + let asset_service = AssetService::new(self.pool.clone()).await; + let account_service = AccountService::new(self.pool.clone(), self.base_currency.clone()); + let asset_profile = asset_service + .get_asset_profile(&activity.asset_id, Some(true)) .await?; + let account = account_service.get_account_by_id(&activity.account_id)?; - // Adjust unit price based on activity type - if ["DEPOSIT", "WITHDRAWAL", "INTEREST", "FEE", "DIVIDEND"] - .contains(&activity.activity_type.as_str()) - { - activity.unit_price = 1.0; - } + conn.transaction(|conn| { + // Update activity currency if asset_profile.currency is available + if !asset_profile.currency.is_empty() { + activity.currency = asset_profile.currency.clone(); + } + // Adjust unit price based on activity type + if ["DEPOSIT", "WITHDRAWAL", "INTEREST", "FEE", "DIVIDEND"] + .contains(&activity.activity_type.as_str()) + { + activity.unit_price = 1.0; + } + + // Create exchange rate if asset currency is different from account currency + if activity.currency != account.currency { + let fx_service = CurrencyExchangeService::new(self.pool.clone()); + fx_service + .add_exchange_rate(account.currency.clone(), activity.currency.clone())?; + } + + // Update the activity in the database + let updated_activity = self.repo.update_activity(conn, activity)?; - // Insert the new activity into the database - self.repo.insert_new_activity(conn, activity) + Ok(updated_activity) + }) } // verify the activities import from csv file pub async fn check_activities_import( &self, - conn: &mut SqliteConnection, _account_id: String, file_path: String, ) -> Result, String> { - let account = self - .account_service - .get_account_by_id(conn, &_account_id) + let asset_service = AssetService::new(self.pool.clone()).await; + let account_service = AccountService::new(self.pool.clone(), self.base_currency.clone()); + let fx_service = CurrencyExchangeService::new(self.pool.clone()); + let account = account_service + .get_account_by_id(&_account_id) .map_err(|e| e.to_string())?; let file = File::open(&file_path).map_err(|e| e.to_string())?; @@ -116,21 +176,40 @@ impl ActivityService { .has_headers(true) .from_reader(file); let mut activities_with_status: Vec = Vec::new(); + let mut symbols_to_sync: Vec = Vec::new(); for (line_number, result) in rdr.deserialize().enumerate() { let line_number = line_number + 1; // Adjust for human-readable line number let mut activity_import: ActivityImport = result.map_err(|e| e.to_string())?; // Load the symbol profile here, now awaiting the async call - let symbol_profile_result = self - .asset_service - .get_asset_profile(conn, &activity_import.symbol) + let symbol_profile_result = asset_service + .get_asset_profile(&activity_import.symbol, Some(false)) .await; // Check if symbol profile is valid let (is_valid, error) = match symbol_profile_result { Ok(profile) => { activity_import.symbol_name = profile.name; + symbols_to_sync.push(activity_import.symbol.clone()); + + // Add exchange rate if the activity currency is different from the account currency + let currency = &activity_import.currency; + if currency != &account.currency { + match fx_service + .add_exchange_rate(account.currency.clone(), currency.clone()) + { + Ok(_) => (), + Err(e) => { + let error_msg = format!( + "Failed to add exchange rate for {}/{}. Error: {}. Line: {}", + &account.currency, currency, e, line_number + ); + return Err(error_msg); + } + } + } + (Some("true".to_string()), None) } Err(_) => { @@ -153,15 +232,20 @@ impl ActivityService { activities_with_status.push(activity_import); } + // Sync quotes for all valid symbols + if !symbols_to_sync.is_empty() { + asset_service.sync_symbol_quotes(&symbols_to_sync).await?; + } + Ok(activities_with_status) } // create activities used after the import is verified pub fn create_activities( &self, - conn: &mut SqliteConnection, activities: Vec, ) -> Result { + let mut conn = self.pool.get().expect("Couldn't get db connection"); conn.transaction(|conn| { let mut insert_count = 0; for new_activity in activities { @@ -175,12 +259,18 @@ impl ActivityService { }) } - // update an activity - pub fn update_activity( + // delete an activity + pub fn delete_activity(&self, activity_id: String) -> Result { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.repo.delete_activity(&mut conn, activity_id) + } + + pub fn get_activities_by_account_ids( &self, - conn: &mut SqliteConnection, - activity: ActivityUpdate, - ) -> Result { - self.repo.update_activity(conn, activity) + account_ids: &[String], + ) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + self.repo + .get_activities_by_account_ids(&mut conn, account_ids) } } diff --git a/src-core/src/app_state.rs b/src-core/src/app_state.rs deleted file mode 100644 index d081116..0000000 --- a/src-core/src/app_state.rs +++ /dev/null @@ -1,7 +0,0 @@ -use diesel::sqlite::SqliteConnection; -use std::sync::Mutex; - -pub struct AppState { - pub conn: Mutex, - pub db_path: String, -} diff --git a/src-core/src/asset/asset_service.rs b/src-core/src/asset/asset_service.rs index 2e76191..f4236c0 100644 --- a/src-core/src/asset/asset_service.rs +++ b/src-core/src/asset/asset_service.rs @@ -1,29 +1,27 @@ +use crate::market_data::market_data_service::MarketDataService; use crate::models::{Asset, AssetProfile, NewAsset, Quote, QuoteSummary}; -use crate::providers::yahoo_provider::YahooProvider; -use std::time::SystemTime; - -use crate::schema::{activities, assets, quotes}; -use chrono::{NaiveDateTime, TimeZone, Utc}; +use crate::schema::{assets, quotes}; use diesel::prelude::*; +use diesel::r2d2::{ConnectionManager, Pool}; use diesel::SqliteConnection; -use std::collections::HashMap; - +use std::sync::Arc; pub struct AssetService { - provider: YahooProvider, + market_data_service: Arc, + pool: Pool>, } impl From for Quote { fn from(external_quote: yahoo_finance_api::Quote) -> Self { Quote { - id: uuid::Uuid::new_v4().to_string(), // Generate a new UUID for the id - created_at: chrono::Utc::now().naive_utc(), // Use the current time for created_at - data_source: String::from("Yahoo"), // Replace with actual data source if available - date: chrono::Utc::now().naive_utc(), // Adjust based on your requirements - symbol: String::new(), // Placeholder, needs actual symbol + id: uuid::Uuid::new_v4().to_string(), + created_at: chrono::Utc::now().naive_utc(), + data_source: String::from("Yahoo"), + date: chrono::Utc::now().naive_utc(), + symbol: String::new(), open: external_quote.open, high: external_quote.high, low: external_quote.low, - volume: external_quote.volume as f64, // Convert from u64 to f64 + volume: external_quote.volume as f64, close: external_quote.close, adjclose: external_quote.adjclose, } @@ -31,43 +29,35 @@ impl From for Quote { } impl AssetService { - pub fn new() -> Self { - AssetService { - provider: YahooProvider::new().unwrap(), + pub async fn new(pool: Pool>) -> Self { + let market_data_service = Arc::new(MarketDataService::new(pool.clone()).await); + Self { + pool, + market_data_service, } } - pub fn get_assets( - &self, - conn: &mut SqliteConnection, - ) -> Result, diesel::result::Error> { - assets::table.load::(conn) + pub fn get_assets(&self) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + assets::table.load::(&mut conn) } - // get asset by id - pub fn get_asset_by_id( - &self, - conn: &mut SqliteConnection, - asset_id: &str, - ) -> Result { - assets::table.find(asset_id).first::(conn) + pub fn get_asset_by_id(&self, asset_id: &str) -> Result { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + assets::table.find(asset_id).first::(&mut conn) } - pub fn get_asset_data( - &self, - conn: &mut SqliteConnection, - asset_id: &str, - ) -> Result { - // Load Asset data + pub fn get_asset_data(&self, asset_id: &str) -> Result { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + let asset = assets::table .filter(assets::id.eq(asset_id)) - .first::(conn)?; + .first::(&mut conn)?; - // Load Quote history for the Asset let quote_history = quotes::table .filter(quotes::symbol.eq(&asset.symbol)) .order(quotes::date.desc()) - .load::(conn)?; + .load::(&mut conn)?; Ok(AssetProfile { asset, @@ -77,44 +67,63 @@ impl AssetService { pub fn load_currency_assets( &self, - conn: &mut SqliteConnection, base_currency: &str, ) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); use crate::schema::assets::dsl::*; assets .filter(asset_type.eq("Currency")) .filter(symbol.like(format!("{}%", base_currency))) - .load::(conn) + .load::(&mut conn) } - pub fn load_exchange_rates( + pub fn create_exchange_rate_symbols( &self, conn: &mut SqliteConnection, - base_currency: &str, - ) -> Result, diesel::result::Error> { - use crate::schema::quotes::dsl::{date, quotes, symbol}; - - let mut exchange_rates = HashMap::new(); - - let currency_assets = self.load_currency_assets(conn, base_currency)?; - - for asset in currency_assets { - let latest_quote = quotes - .filter(symbol.eq(&asset.symbol)) - .order(date.desc()) - .first::(conn) - .ok(); - - if let Some(quote) = latest_quote { - exchange_rates.insert(asset.symbol, quote.close); - } + from_currency: &str, + to_currency: &str, + ) -> Result<(), Box> { + let mut symbols = Vec::new(); + if from_currency != to_currency { + symbols.push(format!("{}{}=X", from_currency, to_currency)); + symbols.push(format!("{}{}=X", to_currency, from_currency)); + } + if from_currency != "USD" { + symbols.push(format!("{}USD=X", from_currency)); } - Ok(exchange_rates) + let new_assets: Vec = symbols + .iter() + .filter(|symbol| self.get_asset_by_id(symbol).is_err()) + .map(|symbol| NewAsset { + id: symbol.to_string(), + isin: None, + name: None, + asset_type: Some("Currency".to_string()), + symbol: symbol.to_string(), + symbol_mapping: None, + asset_class: Some("".to_string()), + asset_sub_class: Some("".to_string()), + comment: None, + countries: None, + categories: None, + classes: None, + attributes: None, + currency: to_currency.to_string(), + data_source: "MANUAL".to_string(), + sectors: None, + url: None, + }) + .collect(); + + diesel::replace_into(assets::table) + .values(&new_assets) + .execute(conn)?; + + Ok(()) } - // create CASH asset pub fn create_cash_asset( &self, conn: &mut SqliteConnection, @@ -126,7 +135,7 @@ impl AssetService { id: asset_id.to_string(), isin: None, name: None, - asset_type: None, + asset_type: Some("Cash".to_string()), symbol: asset_id.to_string(), symbol_mapping: None, asset_class: Some("CASH".to_string()), @@ -144,10 +153,9 @@ impl AssetService { diesel::insert_into(assets::table) .values(&new_asset) - .get_result::(conn) // This line changed + .get_result::(conn) } - // create Rate exchange asset pub fn create_rate_exchange_asset( &self, conn: &mut SqliteConnection, @@ -178,210 +186,77 @@ impl AssetService { diesel::insert_into(assets::table) .values(&new_asset) - .get_result::(conn) // This line changed + .get_result::(conn) } - // pub async fn fetch_quote(&self, symbol: &str) -> Result { - // self.provider - // .get_latest_quote(symbol) - // .await - // .map_err(|e| e.to_string()) - // .map(|external_quote| Quote::from(external_quote)) // Converts ExternalQuote to Quote - // } - - pub fn get_latest_quote( - &self, - conn: &mut SqliteConnection, - symbol_query: &str, - ) -> QueryResult { - use crate::schema::quotes::dsl::*; - - quotes - .filter(symbol.eq(symbol_query)) - .order(date.desc()) // Order by date descending to get the latest quote in the table - .first::(conn) + pub fn get_latest_quote(&self, symbol_query: &str) -> QueryResult { + self.market_data_service.get_latest_quote(symbol_query) } - pub fn get_history_quotes( - &self, - conn: &mut SqliteConnection, - ) -> Result, diesel::result::Error> { - quotes::table.load::(conn) + pub fn get_history_quotes(&self) -> Result, diesel::result::Error> { + self.market_data_service.get_history_quotes() } pub async fn search_ticker(&self, query: &str) -> Result, String> { - self.provider - .search_ticker(query) - .await - .map_err(|e| e.to_string()) - } - - pub async fn initialize_crumb_data(&self) -> Result<(), String> { - match self.provider.set_crumb().await { - Ok(_) => { - println!("Crumb data initialized successfully."); - Ok(()) - } - Err(e) => { - let error_message = format!("Failed to initialize crumb data: {}", e); - eprintln!("{}", &error_message); - Err(error_message) - } - } + self.market_data_service.search_symbol(query).await } pub async fn get_asset_profile( &self, - conn: &mut SqliteConnection, asset_id: &str, + sync: Option, ) -> Result { + let mut conn = self.pool.get().expect("Couldn't get db connection"); use crate::schema::assets::dsl::*; - // Try to load the Asset from the database - match assets.find(asset_id).first::(conn) { + + let should_sync = sync.unwrap_or(true); + + match assets.find(asset_id).first::(&mut conn) { Ok(existing_profile) => Ok(existing_profile), Err(diesel::NotFound) => { - // If not found, fetch one and save it to the database + // symbol not found in database. Fetching from market data service. let fetched_profile = self - .provider - .fetch_quote_summary(asset_id) + .market_data_service + .fetch_symbol_summary(asset_id) .await - .map_err(|_e| diesel::result::Error::NotFound)?; - - // Insert the new profile into the database - diesel::insert_into(assets) + .map_err(|e| { + println!( + "Failed to fetch symbol summary for asset_id: {}. Error: {:?}", + asset_id, e + ); + diesel::result::Error::NotFound + })?; + + let inserted_asset = diesel::insert_into(assets) .values(&fetched_profile) .returning(Asset::as_returning()) - .get_result(conn) - } - Err(e) => Err(e), - } - } - - fn get_last_quote_sync_date( - &self, - conn: &mut SqliteConnection, - ticker: &str, - ) -> Result, diesel::result::Error> { - // Try to get the latest quote date for the given ticker - let latest_quote_date = quotes::table - .filter(quotes::symbol.eq(ticker)) - .select(diesel::dsl::max(quotes::date)) - .first::>(conn)?; - - // Check if latest_quote_date is Some and return early - if let Some(date) = latest_quote_date { - return Ok(Some(date)); - } - - // The code reaches here only if latest_quote_date is None - let earliest_activity_date = activities::table - .filter(activities::asset_id.eq(ticker)) - .select(diesel::dsl::min(activities::activity_date)) - .first::>(conn)?; - - Ok(earliest_activity_date) - } - - pub async fn sync_history_quotes_for_all_assets( - &self, - conn: &mut SqliteConnection, - ) -> Result<(), String> { - println!("Syncing history quotes for all assets..."); - - // 1. Query all assets - let asset_list = Self::get_assets(self, conn).map_err(|e| e.to_string())?; - - // 2. Determine your end date for fetching historical quotes (e.g., current time) - let end_date = SystemTime::now(); - - // 3. Create a Vec to store quotes for all assets - let mut all_quotes_to_insert = Vec::new(); - - for asset in asset_list { - let symbol = asset.symbol.as_str(); - // Get the last quote sync date for this asset - let last_sync_date_naive = match self.get_last_quote_sync_date(conn, symbol) { - Ok(date) => date.unwrap_or_else(|| { - chrono::Utc::now().naive_utc() - chrono::Duration::days(3 * 365) - }), - Err(e) => { - eprintln!( - "Error getting last sync date for {}: {}. Skipping.", - symbol, e - ); - continue; + .get_result(&mut conn)?; + + if should_sync { + self.sync_symbol_quotes(&[inserted_asset.symbol.clone()]) + .await + .map_err(|e| { + println!( + "Failed to sync quotes for asset_id: {}. Error: {:?}", + asset_id, e + ); + diesel::result::Error::NotFound + })?; } - }; - - // Convert NaiveDateTime to DateTime - let start_datetime_utc = Utc.from_utc_datetime(&last_sync_date_naive); - // Convert DateTime to SystemTime - let start_date: std::time::SystemTime = start_datetime_utc.into(); - - // Fetch quotes for the asset and append them to the all_quotes_to_insert Vec - match self - .provider - .fetch_stock_history(symbol, start_date, end_date) - .await - { - Ok(quotes_history) => { - for yahoo_quote in quotes_history { - let timestamp = yahoo_quote.timestamp as i64; - match chrono::DateTime::from_timestamp(timestamp, 0) { - Some(datetime) => { - let naive_datetime = datetime.naive_utc(); - let new_quote = Quote { - id: uuid::Uuid::new_v4().to_string(), - created_at: naive_datetime, - data_source: "YAHOO".to_string(), - date: naive_datetime, - symbol: symbol.to_string(), - open: yahoo_quote.open, - high: yahoo_quote.high, - low: yahoo_quote.low, - volume: yahoo_quote.volume as f64, - close: yahoo_quote.close, - adjclose: yahoo_quote.adjclose, - }; - all_quotes_to_insert.push(new_quote); - } - None => eprintln!( - "Invalid timestamp {} for {}. Skipping quote.", - timestamp, symbol - ), - } - } - } - Err(e) => eprintln!("Error fetching history for {}: {}. Skipping.", symbol, e), + Ok(inserted_asset) + } + Err(e) => { + println!( + "Error while getting asset profile for asset_id: {}. Error: {:?}", + asset_id, e + ); + Err(e) } } - - // 4. Use Diesel's batch insert to insert all quotes in a single operation - diesel::replace_into(quotes::table) - .values(&all_quotes_to_insert) - .execute(conn) - .map_err(|e| e.to_string())?; - - Ok(()) } - pub async fn initialize_and_sync_quotes( - &self, - conn: &mut SqliteConnection, - ) -> Result<(), String> { - // Initialize crumb data - if let Err(e) = self.initialize_crumb_data().await { - return Err(format!("Failed to initialize crumb data: {}", e)); - } - - // Synchronize history quotes - if let Err(e) = self.sync_history_quotes_for_all_assets(conn).await { - return Err(format!("Failed to sync history quotes: {}", e)); - } - - Ok(()) + pub async fn sync_symbol_quotes(&self, symbols: &[String]) -> Result<(), String> { + self.market_data_service.sync_quotes(symbols).await } } - -// } diff --git a/src-core/src/error.rs b/src-core/src/error.rs new file mode 100644 index 0000000..fe8f407 --- /dev/null +++ b/src-core/src/error.rs @@ -0,0 +1,26 @@ +use r2d2; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum PortfolioError { + #[error("Database error: {0}")] + DatabaseError(#[from] diesel::result::Error), + #[error("Connection error: {0}")] + ConnectionError(r2d2::Error), + #[error("Currency conversion error: {0}")] + CurrencyConversionError(String), + #[error("Asset not found: {0}")] + AssetNotFoundError(String), + #[error("Invalid data: {0}")] + InvalidDataError(String), + #[error("Parse error: {0}")] + ParseError(String), +} + +impl From for PortfolioError { + fn from(err: r2d2::Error) -> Self { + PortfolioError::ConnectionError(err) + } +} + +pub type Result = std::result::Result; diff --git a/src-core/src/fx/fx_repository.rs b/src-core/src/fx/fx_repository.rs new file mode 100644 index 0000000..a651f88 --- /dev/null +++ b/src-core/src/fx/fx_repository.rs @@ -0,0 +1,68 @@ +use crate::models::ExchangeRate; +use crate::schema::exchange_rates; +use diesel::prelude::*; +use diesel::sqlite::SqliteConnection; + +pub struct FxRepository; + +impl FxRepository { + pub fn get_exchange_rates(conn: &mut SqliteConnection) -> QueryResult> { + exchange_rates::table.load::(conn) + } + + pub fn get_exchange_rate( + conn: &mut SqliteConnection, + from: &str, + to: &str, + ) -> QueryResult> { + let id = format!("{}{}=X", from, to); + exchange_rates::table.find(id).first(conn).optional() + } + + pub fn get_exchange_rate_by_id( + conn: &mut SqliteConnection, + id: &str, + ) -> QueryResult> { + exchange_rates::table.find(id).first(conn).optional() + } + + pub fn upsert_exchange_rate( + conn: &mut SqliteConnection, + new_rate: ExchangeRate, + ) -> QueryResult { + diesel::insert_into(exchange_rates::table) + .values(&new_rate) + .on_conflict(exchange_rates::id) + .do_update() + .set(( + exchange_rates::rate.eq(new_rate.rate), + exchange_rates::source.eq(&new_rate.source.clone()), + exchange_rates::updated_at.eq(chrono::Utc::now().naive_utc()), + )) + .get_result(conn) + } + + pub fn update_exchange_rate( + conn: &mut SqliteConnection, + rate: &ExchangeRate, + ) -> QueryResult { + diesel::update(exchange_rates::table.find(&rate.id)) + .set(( + exchange_rates::rate.eq(rate.rate), + exchange_rates::source.eq(&rate.source), + exchange_rates::updated_at.eq(chrono::Utc::now().naive_utc()), + )) + .get_result(conn) + } + + // pub fn get_supported_currencies(conn: &mut SqliteConnection) -> QueryResult> { + // use diesel::dsl::sql; + // use diesel::sql_types::Text; + + // let currencies: Vec = exchange_rates::table + // .select(sql::("DISTINCT substr(id, 1, 3)")) + // .load(conn)?; + + // Ok(currencies) + // } +} diff --git a/src-core/src/fx/fx_service.rs b/src-core/src/fx/fx_service.rs new file mode 100644 index 0000000..08c9b28 --- /dev/null +++ b/src-core/src/fx/fx_service.rs @@ -0,0 +1,161 @@ +use crate::fx::fx_repository::FxRepository; +use crate::models::ExchangeRate; +use diesel::r2d2::{ConnectionManager, Pool}; +use diesel::SqliteConnection; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +pub struct CurrencyExchangeService { + pool: Pool>, + exchange_rates: Arc>>, +} + +impl CurrencyExchangeService { + pub fn new(pool: Pool>) -> Self { + Self { + pool, + exchange_rates: Arc::new(RwLock::new(HashMap::new())), + } + } + + pub fn get_latest_exchange_rate( + &self, + from_currency: &str, + to_currency: &str, + ) -> Result> { + if from_currency == to_currency { + return Ok(1.0); + } + + let key = format!("{}{}", from_currency, to_currency); + + // Check cache first + { + let cache = self.exchange_rates.read().map_err(|_| "RwLock poisoned")?; + if let Some(&rate) = cache.get(&key) { + return Ok(rate); + } + } + + let mut conn = self.pool.get()?; + + // Try to get the direct rate + if let Some(rate) = FxRepository::get_exchange_rate(&mut conn, from_currency, to_currency)? + { + self.cache_rate(&key, rate.rate)?; + return Ok(rate.rate); + } + + // If not found, try the inverse rate + if let Some(rate) = FxRepository::get_exchange_rate(&mut conn, to_currency, from_currency)? + { + let inverse_rate = 1.0 / rate.rate; + self.cache_rate(&key, inverse_rate)?; + return Ok(inverse_rate); + } + + // If still not found, try USD conversion + let from_usd = self.get_usd_rate(&mut conn, from_currency)?; + let to_usd = self.get_usd_rate(&mut conn, to_currency)?; + + let rate = from_usd / to_usd; + self.cache_rate(&key, rate)?; + Ok(rate) + } + + fn get_usd_rate( + &self, + conn: &mut SqliteConnection, + currency: &str, + ) -> Result> { + if currency == "USD" { + return Ok(1.0); + } + + FxRepository::get_exchange_rate(conn, currency, "USD")? + .map(|rate| rate.rate) + .ok_or_else(|| format!("No USD rate found for {}", currency).into()) + } + + fn cache_rate(&self, key: &str, rate: f64) -> Result<(), Box> { + let mut cache = self.exchange_rates.write().map_err(|_| "RwLock poisoned")?; + cache.insert(key.to_string(), rate); + Ok(()) + } + + pub fn convert_currency( + &self, + amount: f64, + from_currency: &str, + to_currency: &str, + ) -> Result> { + if from_currency == to_currency { + return Ok(amount); + } + + let rate = self.get_latest_exchange_rate(from_currency, to_currency)?; + Ok(amount * rate) + } + + pub fn update_exchange_rate( + &self, + rate: &ExchangeRate, + ) -> Result> { + let mut conn = self.pool.get()?; + let updated_rate = FxRepository::update_exchange_rate(&mut conn, rate)?; + self.cache_rate(&updated_rate.id, updated_rate.rate)?; + Ok(updated_rate) + } + + pub fn get_exchange_rates(&self) -> Result, Box> { + let mut conn = self.pool.get()?; + let rates = FxRepository::get_exchange_rates(&mut conn)?; + Ok(rates) + } + + pub fn upsert_exchange_rate( + &self, + conn: &mut SqliteConnection, + new_rate: ExchangeRate, + ) -> Result> { + let updated_rate = FxRepository::upsert_exchange_rate(conn, new_rate)?; + self.cache_rate(&updated_rate.id, updated_rate.rate)?; + Ok(updated_rate) + } + + pub fn add_exchange_rate( + &self, + from: String, + to: String, + ) -> Result> { + let mut conn = self.pool.get()?; + + // Check for direct conversion + let direct_id = format!("{}{}=X", from, to); + if let Some(existing_rate) = FxRepository::get_exchange_rate_by_id(&mut conn, &direct_id)? { + return Ok(existing_rate); + } + + // Check for inverse conversion + let inverse_id = format!("{}{}=X", to, from); + if let Some(existing_rate) = FxRepository::get_exchange_rate_by_id(&mut conn, &inverse_id)? + { + return Ok(existing_rate); + } + + // If neither direct nor inverse rate exists, create a new rate + let exchange_rate = ExchangeRate { + id: direct_id, + from_currency: from, + to_currency: to, + rate: 1.0, // Default rate, should be updated with actual rate + source: "MANUAL".to_string(), + created_at: chrono::Utc::now().naive_utc(), + updated_at: chrono::Utc::now().naive_utc(), + }; + + let result = self.upsert_exchange_rate(&mut conn, exchange_rate)?; + self.cache_rate(&result.id, result.rate)?; + Ok(result) + } +} diff --git a/src-core/src/fx/mod.rs b/src-core/src/fx/mod.rs new file mode 100644 index 0000000..f0da596 --- /dev/null +++ b/src-core/src/fx/mod.rs @@ -0,0 +1,2 @@ +pub mod fx_repository; +pub mod fx_service; diff --git a/src-core/src/lib.rs b/src-core/src/lib.rs index 2ceb355..41ae734 100644 --- a/src-core/src/lib.rs +++ b/src-core/src/lib.rs @@ -3,11 +3,12 @@ pub mod db; pub mod account; pub mod activity; pub mod asset; +pub mod error; +pub mod fx; pub mod goal; +pub mod market_data; pub mod models; pub mod portfolio; pub mod providers; pub mod schema; pub mod settings; - -pub mod app_state; diff --git a/src-core/src/market_data/market_data_service.rs b/src-core/src/market_data/market_data_service.rs new file mode 100644 index 0000000..d57f2df --- /dev/null +++ b/src-core/src/market_data/market_data_service.rs @@ -0,0 +1,262 @@ +use crate::models::{Asset, ExchangeRate, NewAsset, Quote, QuoteSummary}; +use crate::providers::yahoo_provider::YahooProvider; +use crate::schema::{activities, exchange_rates, quotes}; +use chrono::{Duration, NaiveDate, NaiveDateTime, TimeZone, Utc}; +use diesel::prelude::*; +use diesel::r2d2::{ConnectionManager, Pool}; +use diesel::SqliteConnection; +use std::collections::HashMap; +use std::time::SystemTime; + +pub struct MarketDataService { + provider: YahooProvider, + pool: Pool>, +} + +impl MarketDataService { + pub async fn new(pool: Pool>) -> Self { + MarketDataService { + provider: YahooProvider::new() + .await + .expect("Failed to initialize YahooProvider"), + pool, + } + } + + pub async fn search_symbol(&self, query: &str) -> Result, String> { + self.provider + .search_ticker(query) + .await + .map_err(|e| e.to_string()) + } + + pub fn get_latest_quote(&self, symbol: &str) -> QueryResult { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + quotes::table + .filter(quotes::symbol.eq(symbol)) + .order(quotes::date.desc()) + .first::(&mut conn) + } + + pub fn get_history_quotes(&self) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + quotes::table.load::(&mut conn) + } + + pub fn load_quotes(&self) -> HashMap<(String, NaiveDate), Quote> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + let quotes_result: QueryResult> = quotes::table.load::(&mut conn); + + match quotes_result { + Ok(quotes) => quotes + .into_iter() + .map(|quote| { + let quote_date = quote.date.date(); + ((quote.symbol.clone(), quote_date), quote) + }) + .collect(), + Err(e) => { + eprintln!("Error loading quotes: {}", e); + HashMap::new() + } + } + } + + pub async fn sync_quotes(&self, symbols: &[String]) -> Result<(), String> { + println!("Syncing history quotes for all assets..."); + let end_date = SystemTime::now(); + let mut all_quotes_to_insert = Vec::new(); + + for symbol in symbols { + let last_sync_date = self + .get_last_quote_sync_date(symbol) + .map_err(|e| format!("Error getting last sync date for {}: {}", symbol, e))? + .unwrap_or_else(|| Utc::now().naive_utc() - Duration::days(3 * 365)); + + let start_date: SystemTime = Utc + .from_utc_datetime(&(last_sync_date - Duration::days(1))) + .into(); + + match self + .provider + .fetch_stock_history(symbol, start_date, end_date) + .await + { + Ok(quotes) => all_quotes_to_insert.extend(quotes), + Err(e) => eprintln!("Error fetching history for {}: {}. Skipping.", symbol, e), + } + } + + self.insert_quotes(&all_quotes_to_insert) + } + + fn get_last_quote_sync_date( + &self, + ticker: &str, + ) -> Result, diesel::result::Error> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + + quotes::table + .filter(quotes::symbol.eq(ticker)) + .select(diesel::dsl::max(quotes::date)) + .first::>(&mut conn) + .or_else(|_| { + activities::table + .filter(activities::asset_id.eq(ticker)) + .select(diesel::dsl::min(activities::activity_date)) + .first::>(&mut conn) + }) + } + + fn insert_quotes(&self, quotes: &[Quote]) -> Result<(), String> { + let mut conn = self.pool.get().expect("Couldn't get db connection"); + diesel::replace_into(quotes::table) + .values(quotes) + .execute(&mut conn) + .map_err(|e| format!("Failed to insert quotes: {}", e))?; + Ok(()) + } + + pub async fn initialize_and_sync_quotes(&self) -> Result<(), String> { + use crate::schema::assets::dsl::*; + let conn = &mut self.pool.get().map_err(|e| e.to_string())?; + self.sync_exchange_rates().await?; + let asset_list: Vec = assets + .load::(conn) + .map_err(|e| format!("Failed to load assets: {}", e))?; + + self.sync_quotes( + &asset_list + .iter() + .map(|asset| asset.symbol.clone()) + .collect::>(), + ) + .await?; + + Ok(()) + } + //self.initialize_provider().await?; + pub async fn fetch_symbol_summary(&self, symbol: &str) -> Result { + self.provider + .fetch_symbol_summary(symbol) + .await + .map_err(|e| e.to_string()) + } + + pub fn get_asset_currencies(&self, asset_ids: Vec) -> HashMap { + use crate::schema::assets::dsl::*; + + let db_connection = &mut self.pool.get().expect("Couldn't get db connection"); + + assets + .filter(id.eq_any(asset_ids)) + .select((id, currency)) + .load::<(String, String)>(db_connection) + .map(|results| results.into_iter().collect::>()) + .unwrap_or_else(|e| { + eprintln!("Error fetching asset currencies: {}", e); + HashMap::new() + }) + } + + pub async fn sync_exchange_rates(&self) -> Result<(), String> { + println!("Syncing exchange rates..."); + let mut conn = self.pool.get().expect("Couldn't get db connection"); + + // Load existing exchange rates + let existing_rates: Vec = exchange_rates::table + .load::(&mut conn) + .map_err(|e| format!("Failed to load existing exchange rates: {}", e))?; + + let mut updated_rates = Vec::new(); + + for rate in existing_rates { + match self + .fetch_exchange_rate(&rate.from_currency, &rate.to_currency) + .await + { + Ok(new_rate) => { + if new_rate > 0.0 { + updated_rates.push(ExchangeRate { + id: rate.id, + from_currency: rate.from_currency, + to_currency: rate.to_currency, + rate: new_rate, + source: "YAHOO".to_string(), + created_at: rate.created_at, + updated_at: Utc::now().naive_utc(), + }); + } + } + Err(e) => { + eprintln!( + "Failed to fetch rate for {}-{}: {}. Skipping update.", + rate.from_currency, rate.to_currency, e + ); + } + } + } + + // Update rates in the database + diesel::replace_into(exchange_rates::table) + .values(&updated_rates) + .execute(&mut conn) + .map_err(|e| format!("Failed to update exchange rates: {}", e))?; + + Ok(()) + } + + async fn fetch_exchange_rate(&self, from: &str, to: &str) -> Result { + // Handle GBP and GBp case like manually + if from != from.to_uppercase() || to != to.to_uppercase() { + return Ok(-1.0); + } + if from == to { + return Ok(1.0); + } + + // Try direct conversion + let symbol = format!("{}{}=X", from, to); + if let Ok(quote) = self.provider.get_latest_quote(&symbol).await { + return Ok(quote.close); + } + + // Try reverse conversion + let reverse_symbol = format!("{}{}=X", to, from); + if let Ok(quote) = self.provider.get_latest_quote(&reverse_symbol).await { + return Ok(1.0 / quote.close); + } + + // Try conversion through USD + let from_usd_symbol = if from != "USD" { + format!("{}USD=X", from) + } else { + "".to_string() + }; + + let to_usd_symbol = if to != "USD" { + format!("{}USD=X", to) + } else { + "".to_string() + }; + let from_usd = if !from_usd_symbol.is_empty() { + match self.provider.get_latest_quote(&from_usd_symbol).await { + Ok(quote) => quote.close, + Err(_) => return Ok(-1.0), + } + } else { + -1.0 + }; + + let to_usd = if !to_usd_symbol.is_empty() { + match self.provider.get_latest_quote(&to_usd_symbol).await { + Ok(quote) => quote.close, + Err(_) => return Ok(-1.0), + } + } else { + 1.0 + }; + + Ok(from_usd / to_usd) + } +} diff --git a/src-core/src/market_data/mod.rs b/src-core/src/market_data/mod.rs new file mode 100644 index 0000000..a30c25d --- /dev/null +++ b/src-core/src/market_data/mod.rs @@ -0,0 +1 @@ +pub mod market_data_service; diff --git a/src-core/src/models.rs b/src-core/src/models.rs index e2f51c5..f1720b8 100644 --- a/src-core/src/models.rs +++ b/src-core/src/models.rs @@ -191,7 +191,15 @@ pub struct NewActivity { } #[derive( - Queryable, Identifiable, Insertable, Associations, Serialize, AsChangeset, Deserialize, Debug, + Queryable, + Identifiable, + Insertable, + Associations, + Serialize, + AsChangeset, + Deserialize, + Debug, + Clone, )] #[diesel(belongs_to(Asset, foreign_key = symbol))] #[diesel(table_name= crate::schema::quotes)] @@ -237,15 +245,15 @@ pub struct ActivityDetails { pub account_id: String, pub asset_id: String, pub activity_type: String, - pub date: chrono::NaiveDateTime, + pub date: String, pub quantity: f64, pub unit_price: f64, pub currency: String, pub fee: f64, pub is_draft: bool, pub comment: Option, - pub created_at: chrono::NaiveDateTime, - pub updated_at: chrono::NaiveDateTime, + pub created_at: String, + pub updated_at: String, pub account_name: String, pub account_currency: String, pub asset_symbol: String, @@ -327,31 +335,11 @@ pub struct Holding { pub sectors: Option>, } -// FinancialSnapshot and FinancialHistory structs with serde for serialization/deserialization -#[derive(Serialize, Deserialize, Debug)] -#[serde(rename_all = "camelCase")] -pub struct FinancialSnapshot { - pub date: String, - pub total_value: f64, - pub market_value: f64, - pub book_cost: f64, - pub available_cash: f64, - pub net_deposit: f64, - pub currency: String, - pub base_currency: String, - pub total_gain_value: f64, - pub total_gain_percentage: f64, - pub day_gain_percentage: f64, - pub day_gain_value: f64, - pub allocation_percentage: Option, - pub exchange_rate: Option, -} - #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct FinancialHistory { - pub account: Account, // Define Account struct accordingly - pub history: Vec, + pub account: Account, + pub history: Vec, } #[derive(Serialize, Deserialize, Debug)] @@ -405,24 +393,21 @@ pub struct Sort { } #[derive(Queryable, Insertable, Serialize, Deserialize, Debug)] -#[diesel(table_name= crate::schema::settings)] +#[diesel(table_name= crate::schema::app_settings)] +#[serde(rename_all = "camelCase")] +pub struct AppSetting { + pub setting_key: String, + pub setting_value: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct Settings { - pub id: i32, pub theme: String, pub font: String, pub base_currency: String, } -#[derive(Insertable, Serialize, AsChangeset, Deserialize, Debug)] -#[diesel(table_name= crate::schema::settings)] -#[serde(rename_all = "camelCase")] -pub struct NewSettings<'a> { - pub theme: &'a str, - pub font: &'a str, - pub base_currency: &'a str, -} - #[derive( Queryable, Identifiable, @@ -499,3 +484,67 @@ pub struct IncomeSummary { pub total_income_ytd: f64, pub currency: String, } + +#[derive(Debug, Clone, Queryable, Insertable, Serialize, Deserialize)] +#[diesel(table_name = crate::schema::portfolio_history)] +#[serde(rename_all = "camelCase")] +pub struct PortfolioHistory { + pub id: String, + pub account_id: String, + pub date: String, + pub total_value: f64, + pub market_value: f64, + pub book_cost: f64, + pub available_cash: f64, + pub net_deposit: f64, + pub currency: String, + pub base_currency: String, + pub total_gain_value: f64, + pub total_gain_percentage: f64, + pub day_gain_percentage: f64, + pub day_gain_value: f64, + pub allocation_percentage: f64, + pub exchange_rate: f64, + pub holdings: Option, // Holdings JSON +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct HistorySummary { + pub id: Option, + pub start_date: String, + pub end_date: String, + pub entries_count: usize, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct AccountSummary { + pub account: Account, + pub performance: PortfolioHistory, +} + +#[derive( + Queryable, Insertable, Identifiable, AsChangeset, Serialize, Deserialize, Debug, Clone, +)] +#[diesel(table_name = crate::schema::exchange_rates)] +#[serde(rename_all = "camelCase")] +pub struct ExchangeRate { + pub id: String, + pub from_currency: String, + pub to_currency: String, + pub rate: f64, + pub source: String, + pub created_at: chrono::NaiveDateTime, + pub updated_at: chrono::NaiveDateTime, +} + +#[derive(Insertable, AsChangeset, Serialize, Deserialize, Debug, Clone)] +#[diesel(table_name = crate::schema::exchange_rates)] +#[serde(rename_all = "camelCase")] +pub struct NewExchangeRate { + pub from_currency: String, + pub to_currency: String, + pub rate: f64, + pub source: String, +} diff --git a/src-core/src/portfolio/history_service.rs b/src-core/src/portfolio/history_service.rs new file mode 100644 index 0000000..0e904b2 --- /dev/null +++ b/src-core/src/portfolio/history_service.rs @@ -0,0 +1,621 @@ +use crate::error::{PortfolioError, Result}; +use crate::fx::fx_service::CurrencyExchangeService; +use crate::market_data::market_data_service::MarketDataService; +use crate::models::{Account, Activity, HistorySummary, PortfolioHistory, Quote}; +use chrono::{Duration, NaiveDate, Utc}; + +use dashmap::DashMap; +use diesel::prelude::*; +use diesel::r2d2::{ConnectionManager, Pool}; + +use diesel::SqliteConnection; +use rayon::prelude::*; +use std::collections::HashMap; +use std::sync::Arc; + +pub struct HistoryService { + pool: Pool>, + base_currency: String, + market_data_service: Arc, + fx_service: CurrencyExchangeService, +} + +impl HistoryService { + pub fn new( + pool: Pool>, + base_currency: String, + market_data_service: Arc, + ) -> Self { + Self { + pool: pool.clone(), + base_currency, + market_data_service, + fx_service: CurrencyExchangeService::new(pool), + } + } + + pub fn get_account_history(&self, input_account_id: &str) -> Result> { + use crate::schema::portfolio_history::dsl::*; + use diesel::prelude::*; + + let db_connection = &mut self.pool.get().map_err(PortfolioError::from)?; + + let history_data: Vec = portfolio_history + .filter(account_id.eq(input_account_id)) + .order(date.asc()) + .load::(db_connection)?; + + Ok(history_data) + } + + pub fn get_latest_account_history(&self, input_account_id: &str) -> Result { + use crate::schema::portfolio_history::dsl::*; + use diesel::prelude::*; + + let db_connection = &mut self.pool.get().map_err(PortfolioError::from)?; + + let latest_history: PortfolioHistory = portfolio_history + .filter(account_id.eq(input_account_id)) + .order(date.desc()) + .first(db_connection) + .map_err(|e| PortfolioError::DatabaseError(e))?; + + Ok(latest_history) + } + + pub fn calculate_historical_data( + &self, + accounts: &[Account], + activities: &[Activity], + force_full_calculation: bool, + ) -> Result> { + let end_date = Utc::now().naive_utc().date(); + let quotes = Arc::new(self.market_data_service.load_quotes()); + + // Process accounts in parallel and collect results + let summaries_and_histories: Vec<(HistorySummary, Vec)> = accounts + .par_iter() + .map(|account| { + let account_activities: Vec<_> = activities + .iter() + .filter(|a| a.account_id == account.id) + .cloned() + .collect(); + + if account_activities.is_empty() { + return ( + HistorySummary { + id: Some(account.id.clone()), + start_date: "".to_string(), + end_date: "".to_string(), + entries_count: 0, + }, + Vec::new(), + ); + } + + let account_start_date = if force_full_calculation { + account_activities + .iter() + .map(|a| a.activity_date.date()) + .min() + .unwrap_or_else(|| Utc::now().naive_utc().date()) + } else { + self.get_last_historical_date(&account.id) + .unwrap_or(None) + .map(|d| d - Duration::days(1)) + .unwrap_or_else(|| { + account_activities + .iter() + .map(|a| a.activity_date.date()) + .min() + .unwrap_or_else(|| Utc::now().naive_utc().date()) + }) + }; + + let new_history = self.calculate_historical_value( + &account.id, + &account_activities, + "es, + account_start_date, + end_date, + force_full_calculation, + ); + + let summary = HistorySummary { + id: Some(account.id.clone()), + start_date: new_history + .first() + .map(|h| h.date.clone()) + .unwrap_or_default(), + end_date: new_history + .last() + .map(|h| h.date.clone()) + .unwrap_or_default(), + entries_count: new_history.len(), + }; + + (summary, new_history) + }) + .collect(); + + // Extract summaries and flatten histories + let mut summaries: Vec = summaries_and_histories + .iter() + .map(|(summary, _)| (*summary).clone()) + .collect(); + let account_histories: Vec = summaries_and_histories + .into_iter() + .flat_map(|(_, histories)| histories) + .collect(); + + // Save account histories + let db_connection = &mut self.pool.get().map_err(PortfolioError::from)?; + self.save_historical_data(&account_histories, db_connection)?; + + // Calculate total portfolio history + let total_history = self.calculate_total_portfolio_history_for_all_accounts()?; + + // Save total history separately + self.save_historical_data(&total_history, db_connection)?; + + let total_summary = HistorySummary { + id: Some("TOTAL".to_string()), + start_date: total_history + .first() + .map(|h| h.date.clone()) + .unwrap_or_default(), + end_date: total_history + .last() + .map(|h| h.date.clone()) + .unwrap_or_default(), + entries_count: total_history.len(), + }; + + summaries.push(total_summary); + Ok(summaries) + } + + // New method to calculate total portfolio history for all accounts + fn calculate_total_portfolio_history_for_all_accounts(&self) -> Result> { + use crate::schema::accounts::dsl as accounts_dsl; + use crate::schema::portfolio_history::dsl::*; + let db_connection = &mut self.pool.get().map_err(PortfolioError::from)?; + + // Get active account IDs + let active_account_ids: Vec = accounts_dsl::accounts + .filter(accounts_dsl::is_active.eq(true)) + .select(accounts_dsl::id) + .load::(db_connection)?; + + let all_histories: Vec = portfolio_history + .filter(account_id.ne("TOTAL")) + .filter(account_id.eq_any(active_account_ids)) + .order(date.asc()) + .load::(db_connection)?; + + let grouped_histories: HashMap> = all_histories + .into_iter() + .fold(HashMap::new(), |mut acc, history| { + acc.entry(history.date.clone()).or_default().push(history); + acc + }); + + let mut total_history: Vec = grouped_histories + .into_iter() + .map(|(history_date, histories)| { + let mut total = PortfolioHistory { + id: format!("TOTAL_{}", history_date), + account_id: "TOTAL".to_string(), + date: history_date, + total_value: 0.0, + market_value: 0.0, + book_cost: 0.0, + available_cash: 0.0, + net_deposit: 0.0, + currency: self.base_currency.clone(), + base_currency: self.base_currency.clone(), + total_gain_value: 0.0, + total_gain_percentage: 0.0, + day_gain_percentage: 0.0, + day_gain_value: 0.0, + allocation_percentage: 100.0, + exchange_rate: 1.0, + holdings: Some("{}".to_string()), + }; + + for history in histories { + let currency_exchange_rate = self + .fx_service + .get_latest_exchange_rate(&history.currency, &self.base_currency) + .unwrap_or(1.0); + + total.total_value += history.total_value * currency_exchange_rate; + total.market_value += history.market_value * currency_exchange_rate; + total.book_cost += history.book_cost * currency_exchange_rate; + total.available_cash += history.available_cash * currency_exchange_rate; + total.net_deposit += history.net_deposit * currency_exchange_rate; + total.day_gain_value += history.day_gain_value * currency_exchange_rate; + } + + // Recalculate percentages + total.total_gain_value = total.total_value - total.net_deposit; + total.total_gain_percentage = if total.net_deposit != 0.0 { + (total.total_gain_value / total.net_deposit) * 100.0 + } else { + 0.0 + }; + total.day_gain_percentage = if total.market_value != 0.0 { + (total.day_gain_value / total.market_value) * 100.0 + } else { + 0.0 + }; + + total + }) + .collect(); + + total_history.sort_by(|a, b| a.date.cmp(&b.date)); + Ok(total_history) + } + + fn calculate_historical_value( + &self, + account_id: &str, + activities: &[Activity], + quotes: &HashMap<(String, NaiveDate), Quote>, + start_date: NaiveDate, + end_date: NaiveDate, + force_full_calculation: bool, + ) -> Vec { + let max_history_days = 36500; // For example, 100 years + let today = Utc::now().naive_utc().date(); + let start_date = start_date.max(today - Duration::days(max_history_days)); + let end_date = end_date.min(today); + + let last_history = if force_full_calculation { + None + } else { + self.get_last_portfolio_history(account_id).unwrap_or(None) + }; + + let account_currency = self + .get_account_currency(account_id) + .unwrap_or(self.base_currency.clone()); + + // Initialize values from the last PortfolioHistory or use default values + let mut cumulative_cash = last_history.as_ref().map_or(0.0, |h| h.available_cash); + let mut net_deposit = last_history.as_ref().map_or(0.0, |h| h.net_deposit); + let mut book_cost = last_history.as_ref().map_or(0.0, |h| h.book_cost); + + // Initialize holdings based on the last history or use an empty HashMap + let mut holdings: HashMap = last_history + .as_ref() + .and_then(|h| h.holdings.as_ref()) + .and_then(|json_str| serde_json::from_str(json_str).ok()) + .unwrap_or_default(); + + // If there's a last history entry and we're not forcing full calculation, start from the day after + let actual_start_date = if force_full_calculation { + start_date + } else { + last_history + .as_ref() + .map(|h| { + NaiveDate::parse_from_str(&h.date, "%Y-%m-%d").unwrap() + Duration::days(1) + }) + .unwrap_or(start_date) + }; + + let all_dates = Self::get_days_between(actual_start_date, end_date); + + let quote_cache: DashMap<(String, NaiveDate), Option<&Quote>> = DashMap::new(); + + let results: Vec = all_dates + .iter() + .map(|&date| { + // Process activities for the current date + for activity in activities.iter().filter(|a| a.activity_date.date() == date) { + self.process_activity( + activity, + &mut holdings, + &mut cumulative_cash, + &mut net_deposit, + &mut book_cost, + &account_currency, + ); + } + + // Update market value based on quotes + let (updated_market_value, day_gain_value, opening_market_value) = self + .calculate_holdings_value( + &holdings, + quotes, + date, + "e_cache, + &account_currency, + ); + + let market_value = updated_market_value; + let total_value = cumulative_cash + market_value; + + let day_gain_percentage = if opening_market_value != 0.0 { + (day_gain_value / opening_market_value) * 100.0 + } else { + 0.0 + }; + + let total_gain_value = total_value - net_deposit; + let total_gain_percentage = if net_deposit != 0.0 { + (total_gain_value / net_deposit) * 100.0 + } else { + 0.0 + }; + + let exchange_rate = self + .fx_service + .get_latest_exchange_rate(&account_currency, &self.base_currency) + .unwrap_or(1.0); + + PortfolioHistory { + id: format!("{}_{}", account_id, date.format("%Y-%m-%d")), + account_id: account_id.to_string(), + date: date.format("%Y-%m-%d").to_string(), + total_value, + market_value, + book_cost, + available_cash: cumulative_cash, + net_deposit, + currency: account_currency.clone(), + base_currency: self.base_currency.to_string(), + total_gain_value, + total_gain_percentage, + day_gain_percentage, + day_gain_value, + allocation_percentage: 0.0, + exchange_rate, + holdings: Some(serde_json::to_string(&holdings).unwrap_or_default()), + } + }) + .collect(); + + results + } + + fn process_activity( + &self, + activity: &Activity, + holdings: &mut HashMap, + cumulative_cash: &mut f64, + net_deposit: &mut f64, + book_cost: &mut f64, + account_currency: &str, + ) { + // Get echange rate if activity currency is different of account currency + let exchange_rate = self + .fx_service + .get_latest_exchange_rate(&activity.currency, account_currency) + .unwrap_or(1.0); + + let activity_amount = activity.quantity * activity.unit_price * exchange_rate; + let activity_fee = activity.fee * exchange_rate; + + match activity.activity_type.as_str() { + "BUY" => { + let buy_cost = activity_amount + activity_fee; + *cumulative_cash -= buy_cost; + *book_cost += buy_cost; + *holdings.entry(activity.asset_id.clone()).or_insert(0.0) += activity.quantity; + } + "SELL" => { + let sell_profit = activity_amount - activity_fee; + *cumulative_cash += sell_profit; + *book_cost -= activity_amount + activity_fee; + *holdings.entry(activity.asset_id.clone()).or_insert(0.0) -= activity.quantity; + } + "DEPOSIT" | "TRANSFER_IN" | "CONVERSION_IN" => { + *cumulative_cash += activity_amount - activity_fee; + *net_deposit += activity_amount; + } + "DIVIDEND" | "INTEREST" => { + *cumulative_cash += activity_amount - activity_fee; + } + "WITHDRAWAL" | "TRANSFER_OUT" | "CONVERSION_OUT" => { + *cumulative_cash -= activity_amount + activity_fee; + *net_deposit -= activity_amount; + } + "FEE" | "TAX" => { + *cumulative_cash -= activity_fee; + } + _ => {} + } + } + + fn save_historical_data( + &self, + history_data: &[PortfolioHistory], + db_connection: &mut SqliteConnection, + ) -> Result<()> { + use crate::schema::portfolio_history::dsl::*; + + for record in history_data { + diesel::insert_into(portfolio_history) + .values(( + id.eq(&record.id), + account_id.eq(&record.account_id), + date.eq(&record.date), + total_value.eq(record.total_value), + market_value.eq(record.market_value), + book_cost.eq(record.book_cost), + available_cash.eq(record.available_cash), + net_deposit.eq(record.net_deposit), + currency.eq(&record.currency), + base_currency.eq(&record.base_currency), + total_gain_value.eq(record.total_gain_value), + total_gain_percentage.eq(record.total_gain_percentage), + day_gain_percentage.eq(record.day_gain_percentage), + day_gain_value.eq(record.day_gain_value), + allocation_percentage.eq(record.allocation_percentage), + exchange_rate.eq(record.exchange_rate), + holdings.eq(&record.holdings), + )) + .on_conflict(id) + .do_update() + .set(( + total_value.eq(record.total_value), + market_value.eq(record.market_value), + book_cost.eq(record.book_cost), + available_cash.eq(record.available_cash), + net_deposit.eq(record.net_deposit), + currency.eq(&record.currency), + base_currency.eq(&record.base_currency), + total_gain_value.eq(record.total_gain_value), + total_gain_percentage.eq(record.total_gain_percentage), + day_gain_percentage.eq(record.day_gain_percentage), + day_gain_value.eq(record.day_gain_value), + allocation_percentage.eq(record.allocation_percentage), + exchange_rate.eq(record.exchange_rate), + holdings.eq(&record.holdings), + )) + .execute(db_connection) + .map_err(PortfolioError::from)?; + } + + Ok(()) + } + + fn calculate_holdings_value<'a>( + &self, + holdings: &HashMap, + quotes: &'a HashMap<(String, NaiveDate), Quote>, + date: NaiveDate, + quote_cache: &DashMap<(String, NaiveDate), Option<&'a Quote>>, + account_currency: &str, + ) -> (f64, f64, f64) { + let mut holdings_value = 0.0; + let mut day_gain_value = 0.0; + let mut opening_market_value = 0.0; + + // Fetch all asset currencies at once + let asset_currencies = self + .market_data_service + .get_asset_currencies(holdings.keys().cloned().collect()); + + for (asset_id, &quantity) in holdings { + if let Some(quote) = self.get_last_available_quote(asset_id, date, quotes, quote_cache) + { + // Use the pre-fetched asset currency + let asset_currency = asset_currencies + .get(asset_id) + .map(String::as_str) + .unwrap_or(account_currency); + + let exchange_rate = self + .fx_service + .get_latest_exchange_rate(asset_currency, account_currency) + .unwrap_or(1.0); + + let holding_value = quantity * quote.close * exchange_rate; + let opening_value = quantity * quote.open * exchange_rate; + let day_gain = quantity * (quote.close - quote.open) * exchange_rate; + + holdings_value += holding_value; + day_gain_value += day_gain; + opening_market_value += opening_value; + } else { + println!( + "Warning: No quote found for asset {} on date {}", + asset_id, date + ); + } + } + + (holdings_value, day_gain_value, opening_market_value) + } + + fn get_last_available_quote<'a>( + &self, + asset_id: &str, + date: NaiveDate, + quotes: &'a HashMap<(String, NaiveDate), Quote>, + quote_cache: &DashMap<(String, NaiveDate), Option<&'a Quote>>, + ) -> Option<&'a Quote> { + quote_cache + .entry((asset_id.to_string(), date)) + .or_insert_with(|| { + quotes.get(&(asset_id.to_string(), date)).or_else(|| { + (1..=30).find_map(|days_back| { + let lookup_date = date - chrono::Duration::days(days_back); + quotes.get(&(asset_id.to_string(), lookup_date)) + }) + }) + }) + .clone() + } + + fn get_last_portfolio_history( + &self, + some_account_id: &str, + ) -> Result> { + use crate::schema::portfolio_history::dsl::*; + + let db_connection = &mut self.pool.get().map_err(PortfolioError::from)?; + let last_history_opt = portfolio_history + .filter(account_id.eq(some_account_id)) + .order(date.desc()) + .first::(db_connection) + .optional() + .map_err(PortfolioError::from)?; + + if let Some(last_history) = last_history_opt { + Ok(Some(last_history)) + } else { + Ok(None) + } + } + + fn get_days_between(start: NaiveDate, end: NaiveDate) -> Vec { + let mut days = Vec::new(); + let mut current = start; + + while current <= end { + days.push(current); + current += Duration::days(1); + } + + days + } + + fn get_last_historical_date(&self, some_account_id: &str) -> Result> { + use crate::schema::portfolio_history::dsl::*; + let db_connection = &mut self.pool.get().map_err(PortfolioError::from)?; + + let last_date_opt = portfolio_history + .filter(account_id.eq(some_account_id)) + .select(date) + .order(date.desc()) + .first::(db_connection) + .optional() + .map_err(PortfolioError::from)?; + + if let Some(last_date_str) = last_date_opt { + NaiveDate::parse_from_str(&last_date_str, "%Y-%m-%d") + .map(Some) + .map_err(|_| PortfolioError::ParseError("Invalid date format".to_string())) + } else { + Ok(None) + } + } + + // Add this new method to get account currency + fn get_account_currency(&self, account_id: &str) -> Result { + use crate::schema::accounts::dsl::*; + let db_connection = &mut self.pool.get().map_err(PortfolioError::from)?; + + accounts + .filter(id.eq(account_id)) + .select(currency) + .first::(db_connection) + .map_err(PortfolioError::from) + } +} diff --git a/src-core/src/portfolio/holdings_service.rs b/src-core/src/portfolio/holdings_service.rs new file mode 100644 index 0000000..ad0b8b1 --- /dev/null +++ b/src-core/src/portfolio/holdings_service.rs @@ -0,0 +1,188 @@ +use crate::account::account_service::AccountService; +use crate::activity::activity_service::ActivityService; +use crate::asset::asset_service::AssetService; +use crate::error::{PortfolioError, Result}; +use crate::fx::fx_service::CurrencyExchangeService; +use crate::models::{Holding, Performance}; +use diesel::r2d2::{ConnectionManager, Pool}; +use diesel::SqliteConnection; +use std::collections::{HashMap, HashSet}; + +pub struct HoldingsService { + account_service: AccountService, + activity_service: ActivityService, + asset_service: AssetService, + fx_service: CurrencyExchangeService, + base_currency: String, +} + +impl HoldingsService { + pub async fn new( + pool: Pool>, + base_currency: String, + ) -> Self { + HoldingsService { + account_service: AccountService::new(pool.clone(), base_currency.clone()), + activity_service: ActivityService::new(pool.clone(), base_currency.clone()), + asset_service: AssetService::new(pool.clone()).await, + fx_service: CurrencyExchangeService::new(pool.clone()), + base_currency, + } + } + pub fn compute_holdings(&self) -> Result> { + let start_time = std::time::Instant::now(); + + let mut holdings: HashMap = HashMap::new(); + let accounts = self.account_service.get_accounts()?; + let activities = self.activity_service.get_trading_activities()?; + let assets = self.asset_service.get_assets()?; + + println!( + "Found {} accounts, {} activities, and {} assets", + accounts.len(), + activities.len(), + assets.len() + ); + + for activity in activities { + let asset = assets + .iter() + .find(|a| a.id == activity.asset_id) + .ok_or_else(|| PortfolioError::AssetNotFoundError(activity.asset_id.clone()))?; + + let account = accounts + .iter() + .find(|a| a.id == activity.account_id) + .ok_or_else(|| PortfolioError::InvalidDataError("Account not found".to_string()))?; + + let key = format!("{}-{}", activity.account_id, activity.asset_id); + + let holding = holdings.entry(key.clone()).or_insert_with(|| Holding { + id: key, + symbol: activity.asset_id.clone(), + symbol_name: asset.name.clone(), + holding_type: asset.asset_type.clone().unwrap_or_default(), + quantity: 0.0, + currency: activity.currency.clone(), + base_currency: self.base_currency.clone(), + market_price: None, + average_cost: None, + market_value: 0.0, + book_value: 0.0, + market_value_converted: 0.0, + book_value_converted: 0.0, + performance: Performance { + total_gain_percent: 0.0, + total_gain_amount: 0.0, + total_gain_amount_converted: 0.0, + day_gain_percent: Some(0.0), + day_gain_amount: Some(0.0), + day_gain_amount_converted: Some(0.0), + }, + account: Some(account.clone()), + asset_class: asset.asset_class.clone(), + asset_sub_class: asset.asset_sub_class.clone(), + sectors: asset + .sectors + .clone() + .map(|s| serde_json::from_str(&s).unwrap_or_default()), + }); + + match activity.activity_type.as_str() { + "BUY" => { + holding.quantity += activity.quantity; + holding.book_value += activity.quantity * activity.unit_price + activity.fee; + } + "SELL" => { + holding.quantity -= activity.quantity; + holding.book_value -= activity.quantity * activity.unit_price + activity.fee; + } + _ => println!("Unhandled activity type: {}", activity.activity_type), + } + } + + // Collect all unique symbols from holdings + let unique_symbols: HashSet = holdings + .values() + .map(|holding| holding.symbol.clone()) + .collect(); + + let symbols: Vec = unique_symbols.into_iter().collect(); + + // Fetch quotes for each symbol asynchronously + let mut quotes = HashMap::new(); + for symbol in symbols { + match self.asset_service.get_latest_quote(&symbol) { + Ok(quote) => { + quotes.insert(symbol.clone(), quote); + } + Err(e) => { + eprintln!("Error fetching quote for symbol {}: {}", symbol, e); + } + } + } + + // Post-processing for each holding + for holding in holdings.values_mut() { + if let Some(quote) = quotes.get(&holding.symbol) { + holding.market_price = Some(quote.close); + + // Calculate day gain using quote open and close prices + let opening_value = holding.quantity * quote.open; + let closing_value = holding.quantity * quote.close; + holding.performance.day_gain_amount = Some(closing_value - opening_value); + holding.performance.day_gain_percent = Some(if opening_value != 0.0 { + (closing_value - opening_value) / opening_value * 100.0 + } else { + 0.0 + }); + } + holding.average_cost = Some(holding.book_value / holding.quantity); + holding.market_value = holding.quantity * holding.market_price.unwrap_or(0.0); + + // Get exchange rate for the holding's currency to base currency + let exchange_rate = match self + .fx_service + .get_latest_exchange_rate(&holding.currency, &self.base_currency.clone()) + { + Ok(rate) => rate, + Err(e) => { + eprintln!( + "Error getting exchange rate for {} to {}: {}. Using 1 as default.", + holding.currency, + self.base_currency.clone(), + e + ); + 1.0 + } + }; + + holding.market_value_converted = holding.market_value * exchange_rate; + holding.book_value_converted = holding.book_value * exchange_rate; + + // Calculate performance metrics + holding.performance.total_gain_amount = holding.market_value - holding.book_value; + holding.performance.total_gain_percent = if holding.book_value != 0.0 { + holding.performance.total_gain_amount / holding.book_value * 100.0 + } else { + 0.0 + }; + holding.performance.total_gain_amount_converted = + holding.performance.total_gain_amount * exchange_rate; + + // Convert day gain to base currency + if let Some(day_gain_amount) = holding.performance.day_gain_amount { + holding.performance.day_gain_amount_converted = + Some(day_gain_amount * exchange_rate); + } + } + + let duration = start_time.elapsed(); + println!("Computed {} holdings in {:?}", holdings.len(), duration); + + Ok(holdings + .into_values() + .filter(|holding| holding.quantity > 0.0) + .collect()) + } +} diff --git a/src-core/src/portfolio/income_service.rs b/src-core/src/portfolio/income_service.rs new file mode 100644 index 0000000..bfd3902 --- /dev/null +++ b/src-core/src/portfolio/income_service.rs @@ -0,0 +1,93 @@ +use crate::fx::fx_service::CurrencyExchangeService; +use crate::models::{IncomeData, IncomeSummary}; +use chrono::{Datelike, NaiveDateTime}; +use diesel::prelude::*; +use diesel::r2d2::{ConnectionManager, Pool}; +use diesel::SqliteConnection; +use std::collections::HashMap; + +pub struct IncomeService { + fx_service: CurrencyExchangeService, + base_currency: String, + pool: Pool>, +} + +impl IncomeService { + pub fn new( + pool: Pool>, + fx_service: CurrencyExchangeService, + base_currency: String, + ) -> Self { + IncomeService { + fx_service, + base_currency, + pool, + } + } + + pub fn get_income_data(&self) -> Result, diesel::result::Error> { + use crate::schema::activities; + let mut conn = self.pool.get().expect("Couldn't get db connection"); + activities::table + .filter(activities::activity_type.eq_any(vec!["DIVIDEND", "INTEREST"])) + .select(( + activities::activity_date, + activities::activity_type, + activities::asset_id, + activities::quantity * activities::unit_price, + activities::currency, + )) + .load::<(NaiveDateTime, String, String, f64, String)>(&mut conn) + .map(|results| { + results + .into_iter() + .map(|(date, income_type, symbol, amount, currency)| IncomeData { + date, + income_type, + symbol, + amount, + currency, + }) + .collect() + }) + } + + pub fn get_income_summary(&self) -> Result { + let income_data = self.get_income_data()?; + let base_currency = self.base_currency.clone(); + + let mut by_month: HashMap = HashMap::new(); + let mut by_type: HashMap = HashMap::new(); + let mut by_symbol: HashMap = HashMap::new(); + let mut total_income = 0.0; + let mut total_income_ytd = 0.0; + + let current_year = chrono::Local::now().year(); + + for data in income_data { + let month = data.date.format("%Y-%m").to_string(); + let converted_amount = self + .fx_service + .convert_currency(data.amount, &data.currency, &base_currency) + .unwrap_or(data.amount); + + *by_month.entry(month).or_insert(0.0) += converted_amount; + *by_type.entry(data.income_type).or_insert(0.0) += converted_amount; + *by_symbol.entry(data.symbol).or_insert(0.0) += converted_amount; + total_income += converted_amount; + + if data.date.year() == current_year { + total_income_ytd += converted_amount; + } + } + + Ok(IncomeSummary { + by_month, + by_type, + by_symbol, + total_income, + total_income_ytd, + currency: base_currency, + }) + } +} diff --git a/src-core/src/portfolio/mod.rs b/src-core/src/portfolio/mod.rs index 76d5b6d..e014989 100644 --- a/src-core/src/portfolio/mod.rs +++ b/src-core/src/portfolio/mod.rs @@ -1 +1,7 @@ +mod history_service; +mod holdings_service; +mod income_service; +// mod portfolio_calculator; pub mod portfolio_service; + +pub use portfolio_service::PortfolioService; diff --git a/src-core/src/portfolio/portfolio_service.rs b/src-core/src/portfolio/portfolio_service.rs index 14f4983..98f3e99 100644 --- a/src-core/src/portfolio/portfolio_service.rs +++ b/src-core/src/portfolio/portfolio_service.rs @@ -1,560 +1,149 @@ use crate::account::account_service::AccountService; use crate::activity::activity_service::ActivityService; -use crate::asset::asset_service::AssetService; +use crate::fx::fx_service::CurrencyExchangeService; +use crate::market_data::market_data_service::MarketDataService; use crate::models::{ - Account, Activity, FinancialHistory, FinancialSnapshot, Holding, IncomeData, IncomeSummary, - Performance, Quote, + AccountSummary, HistorySummary, Holding, IncomeData, IncomeSummary, PortfolioHistory, }; -use crate::settings::SettingsService; -use chrono::NaiveDateTime; -use rayon::prelude::*; -use std::collections::{HashMap, HashSet}; -use chrono::Datelike; -use chrono::{Duration, NaiveDate, Utc}; +use diesel::r2d2::{ConnectionManager, Pool}; use diesel::SqliteConnection; +use std::sync::Arc; + +use crate::portfolio::history_service::HistoryService; +use crate::portfolio::holdings_service::HoldingsService; +use crate::portfolio::income_service::IncomeService; + pub struct PortfolioService { account_service: AccountService, activity_service: ActivityService, - asset_service: AssetService, - base_currency: String, - exchange_rates: HashMap, + market_data_service: Arc, + income_service: IncomeService, + holdings_service: HoldingsService, + history_service: HistoryService, } -/// This module contains the implementation of the `PortfolioService` struct. -/// The `PortfolioService` struct provides methods for fetching and aggregating holdings, -/// computing holdings, calculating historical portfolio values, and aggregating account history. -/// It also includes helper methods for converting currency, fetching exchange rates, -/// and getting dates between two given dates. - impl PortfolioService { - pub fn new(conn: &mut SqliteConnection) -> Result> { - let mut service = PortfolioService { - account_service: AccountService::new(), - activity_service: ActivityService::new(), - asset_service: AssetService::new(), - base_currency: String::new(), - exchange_rates: HashMap::new(), - }; - service.initialize(conn)?; - Ok(service) - } - - fn initialize( - &mut self, - conn: &mut SqliteConnection, - ) -> Result<(), Box> { - let settings_service = SettingsService::new(); - let settings = settings_service.get_settings(conn)?; - self.base_currency.clone_from(&settings.base_currency); - self.exchange_rates = self - .asset_service - .load_exchange_rates(conn, &settings.base_currency)?; - Ok(()) - } - - fn convert_to_base_currency(&self, amount: f64, currency: &str) -> f64 { - if currency == self.base_currency { - amount - } else { - let rate = self.get_exchange_rate(currency); - amount * rate - } + pub async fn new( + pool: Pool>, + base_currency: String, + ) -> Result> { + let market_data_service = Arc::new(MarketDataService::new(pool.clone()).await); + + Ok(PortfolioService { + account_service: AccountService::new(pool.clone(), base_currency.clone()), + activity_service: ActivityService::new(pool.clone(), base_currency.clone()), + market_data_service: market_data_service.clone(), + income_service: IncomeService::new( + pool.clone(), + CurrencyExchangeService::new(pool.clone()), + base_currency.clone(), + ), + holdings_service: HoldingsService::new(pool.clone(), base_currency.clone()).await, + history_service: HistoryService::new( + pool.clone(), + base_currency.clone(), + market_data_service, + ), + }) } - fn get_exchange_rate(&self, currency: &str) -> f64 { - if currency == self.base_currency { - 1.0 - } else { - let currency_key = format!("{}{}=X", self.base_currency, currency); - 1.0 / *self - .exchange_rates - .get(¤cy_key.to_string()) - .unwrap_or(&1.0) - } + pub async fn compute_holdings(&self) -> Result, Box> { + self.holdings_service + .compute_holdings() + .map_err(|e| Box::new(e) as Box) } - pub fn compute_holdings( + pub async fn calculate_historical_data( &self, - conn: &mut SqliteConnection, - ) -> Result, Box> { - let mut holdings: HashMap = HashMap::new(); - let accounts = self.account_service.get_accounts(conn)?; - let activities = self.activity_service.get_trading_activities(conn)?; - let assets = self.asset_service.get_assets(conn)?; - - for activity in activities { - //find asset by id - let asset = match assets.iter().find(|a| a.id == activity.asset_id) { - Some(found_asset) => found_asset, - None => { - println!("Asset not found for id: {}", activity.asset_id); - continue; // Skip this iteration if the asset is not found - } - }; - - //find account by id - let account = accounts - .iter() - .find(|a| a.id == activity.account_id) - .unwrap(); - - let key = format!("{}-{}", activity.account_id, activity.asset_id); - let holding = holdings.entry(key.clone()).or_insert_with(|| Holding { - id: key, - symbol: activity.asset_id.clone(), - symbol_name: asset.name.clone(), - holding_type: asset.asset_type.clone().unwrap_or_default(), - quantity: 0.0, - currency: activity.currency.clone(), - base_currency: "CAD".to_string(), - market_price: None, // You need to provide market price - average_cost: None, // Will be calculated - market_value: 0.0, // Will be calculated - book_value: 0.0, // Will be calculated - market_value_converted: 0.0, // Will be calculated - book_value_converted: 0.0, // Will be calculated - performance: Performance { - total_gain_percent: 0.0, - total_gain_amount: 0.0, - total_gain_amount_converted: 0.0, - day_gain_percent: Some(0.0), - day_gain_amount: Some(0.0), - day_gain_amount_converted: Some(0.0), - }, - account: Some(account.clone()), - asset_class: asset.asset_class.clone(), - asset_sub_class: asset.asset_sub_class.clone(), - sectors: asset - .sectors - .clone() - .map(|s| serde_json::from_str(&s).unwrap_or_default()), - }); - - match activity.activity_type.as_str() { - "BUY" => { - holding.quantity += activity.quantity; - holding.book_value += activity.quantity * activity.unit_price + activity.fee; - } - "SELL" => { - holding.quantity -= activity.quantity; - holding.book_value -= activity.quantity * activity.unit_price + activity.fee; - } - "SPLIT" => { - // TODO:: Handle the split logic here - } - _ => {} - } - } - - // Collect all unique symbols from holdings - let unique_symbols: HashSet = holdings - .values() - .map(|holding| holding.symbol.clone()) - .collect(); - - let symbols: Vec = unique_symbols.into_iter().collect(); - - // Fetch quotes for each symbol asynchronously - let mut quotes = HashMap::new(); - for symbol in symbols { - match self.asset_service.get_latest_quote(conn, &symbol) { - Ok(quote) => { - quotes.insert(symbol, quote); - } - Err(e) => { - println!("Error fetching quote for symbol {}: {}", symbol, e); - // Handle the error as per your logic, e.g., continue, return an error, etc. - } - } - } + account_ids: Option>, + force_full_calculation: bool, + ) -> Result, Box> { + // First, sync quotes + self.market_data_service.sync_exchange_rates().await?; + let accounts = match &account_ids { + Some(ids) => self.account_service.get_accounts_by_ids(ids)?, + None => self.account_service.get_accounts()?, + }; - // Post-processing for each holding - for holding in holdings.values_mut() { - if let Some(quote) = quotes.get(&holding.symbol) { - //prinln!("Quote: {:?}", quote); - holding.market_price = Some(quote.close); // Assuming you want to use the 'close' value as market price - } - holding.average_cost = Some(holding.book_value / holding.quantity); - holding.market_value = holding.quantity * holding.market_price.unwrap_or(0.0); - holding.market_value_converted = - self.convert_to_base_currency(holding.market_value, &holding.currency); - holding.book_value_converted = - self.convert_to_base_currency(holding.book_value, &holding.currency); + let activities = match &account_ids { + Some(ids) => self.activity_service.get_activities_by_account_ids(ids)?, + None => self.activity_service.get_activities()?, + }; - // Calculate performance metrics - holding.performance.total_gain_amount = holding.market_value - holding.book_value; - holding.performance.total_gain_percent = if holding.book_value != 0.0 { - holding.performance.total_gain_amount / holding.book_value * 100.0 - } else { - 0.0 - }; - holding.performance.total_gain_amount_converted = self - .convert_to_base_currency(holding.performance.total_gain_amount, &holding.currency); - } + let results = self.history_service.calculate_historical_data( + &accounts, + &activities, + force_full_calculation, + )?; - holdings - .into_values() - .filter(|holding| holding.quantity > 0.0) - .map(Ok) - .collect::, _>>() + Ok(results) } - fn get_dates_between(start: NaiveDate, end: NaiveDate) -> Vec { - let mut dates = Vec::new(); - let mut current = start; - - while current <= end { - dates.push(current); - current = current.checked_add_signed(Duration::days(1)).unwrap(); - } - - dates + pub fn get_income_data(&self) -> Result, diesel::result::Error> { + self.income_service.get_income_data() } - fn fetch_data( - &self, - conn: &mut SqliteConnection, - ) -> Result<(Vec, Vec, Vec), Box> { - let accounts = self.account_service.get_accounts(conn)?; - let activities = self.activity_service.get_activities(conn)?; - let market_data = self.asset_service.get_history_quotes(conn)?; - //let assets = self.asset_service.get_assets(conn)?; - - Ok((accounts, activities, market_data)) + pub fn get_income_summary(&self) -> Result { + self.income_service.get_income_summary() } - pub fn calculate_historical_portfolio_values( + pub async fn update_portfolio( &self, - conn: &mut SqliteConnection, - ) -> Result, Box> { - let strt_time = std::time::Instant::now(); - - let (accounts, activities, market_data) = self.fetch_data(conn)?; - - // Use Rayon's par_iter to process each account in parallel - let results: Vec = accounts - .par_iter() - .filter_map(|account| { - let account_activities: Vec<_> = activities - .iter() - .filter(|a| a.account_id == account.id) - .cloned() - .collect(); - - if account_activities.is_empty() { - None - } else { - let history = - self.calculate_historical_value(&account_activities, &market_data); - Some(FinancialHistory { - account: account.clone(), - history, - }) - } - }) - .collect(); - - // Calculate the total value of the portfolio - let portfolio_total_value = results - .iter() - .map(|fh| fh.history.last().map_or(0.0, |s| s.total_value)) - .sum::(); - - // Calculate the percentage of each account - let mut results_with_percentage = results - .into_iter() - .map(|mut fh| { - let account_total: f64 = fh.history.last().map_or(0.0, |s| s.total_value); - let percentage = account_total / portfolio_total_value * 100.0; - if let Some(last_snapshot) = fh.history.last_mut() { - last_snapshot.allocation_percentage = Some(percentage); - } - fh - }) - .collect::>(); - - // Aggregate historical data from all accounts - let mut aggregated_history: HashMap = HashMap::new(); - for financial_history in &results_with_percentage { - self.aggregate_account_history(&mut aggregated_history, &financial_history.history); - } - - let mut total_history: Vec<_> = aggregated_history.into_values().collect(); - total_history.sort_by(|a, b| a.date.cmp(&b.date)); - - let total_account = self.create_total_account(); - results_with_percentage.push(FinancialHistory { - account: total_account, - history: total_history, - }); - - println!( - "Calculating historical portfolio values took: {:?}", - std::time::Instant::now() - strt_time - ); - - Ok(results_with_percentage) + ) -> Result, Box> { + // First, sync quotes + self.market_data_service + .initialize_and_sync_quotes() + .await?; + + // Then, calculate historical data + self.calculate_historical_data(None, false).await } - fn aggregate_account_history( + pub fn get_account_history( &self, - aggregated_history: &mut HashMap, - history: &[FinancialSnapshot], - ) { - for snapshot in history { - let entry = aggregated_history - .entry(snapshot.date.clone()) - .or_insert_with(|| FinancialSnapshot { - date: snapshot.date.clone(), - total_value: 0.0, - market_value: 0.0, - book_cost: 0.0, - available_cash: 0.0, - net_deposit: 0.0, - currency: snapshot.currency.to_string(), - base_currency: self.base_currency.to_string(), - total_gain_value: 0.0, - total_gain_percentage: 0.0, - day_gain_percentage: 0.0, - day_gain_value: 0.0, - allocation_percentage: None, - exchange_rate: Some(1.0), // Default exchange rate for base currency - }); - - let exchange_rate = snapshot.exchange_rate.unwrap_or(1.0); - - // Convert values to base currency before aggregating - entry.total_value += snapshot.total_value * exchange_rate; - entry.market_value += snapshot.market_value * exchange_rate; - entry.book_cost += snapshot.book_cost * exchange_rate; - entry.available_cash += snapshot.available_cash * exchange_rate; - entry.net_deposit += snapshot.net_deposit * exchange_rate; - entry.total_gain_value += snapshot.total_gain_value * exchange_rate; - - // Recalculate percentage values based on aggregated totals - entry.total_gain_percentage = if entry.book_cost != 0.0 { - entry.total_gain_value / entry.book_cost * 100.0 - } else { - 0.0 - }; - - // Assuming day gain values are already in base currency or need similar conversion - entry.day_gain_percentage += snapshot.day_gain_percentage; - entry.day_gain_value += snapshot.day_gain_value * exchange_rate; - } - } - - fn create_total_account(&self) -> Account { - Account { - id: "TOTAL".to_string(), - name: "Total".to_string(), - account_type: "TOTAL".to_string(), - group: Some("TOTAL".to_string()), - is_default: true, - is_active: true, - created_at: Utc::now().naive_utc(), - updated_at: Utc::now().naive_utc(), - platform_id: None, - currency: self.base_currency.to_string(), - } + account_id: &str, + ) -> Result, Box> { + self.history_service + .get_account_history(account_id) + .map_err(|e| Box::new(e) as Box) // Convert PortfolioError to Box } - fn calculate_historical_value( - &self, - activities: &[Activity], - quotes: &[Quote], - ) -> Vec { - let first_activity = activities[0].clone(); - - let start_date = first_activity.activity_date.date(); - - let end_date = Utc::now().naive_utc().date(); - let all_dates = Self::get_dates_between(start_date, end_date); - - let mut currency = self.base_currency.as_str(); - let mut cumulative_cash = 0.0; - let mut holdings: HashMap = HashMap::new(); - - let mut results = Vec::new(); - let mut _initial_investment = 0.0; - let mut net_deposit = 0.0; - let mut book_cost = 0.0; - - // HashMap to keep the last available quote for each symbol - let mut last_available_quotes: HashMap = HashMap::new(); - - for date in all_dates { - for activity in activities.iter().filter(|a| a.activity_date.date() == date) { - currency = activity.currency.as_str(); - let activity_amount = activity.quantity; - let activity_fee = activity.fee; - - match activity.activity_type.as_str() { - "BUY" => { - let entry = holdings.entry(activity.asset_id.clone()).or_insert(0.0); - *entry += activity_amount; - let buy_cost = activity_amount * activity.unit_price + activity_fee; - cumulative_cash -= buy_cost; - _initial_investment += activity_amount * activity.unit_price; - book_cost += buy_cost; - } - "SELL" => { - let entry = holdings.entry(activity.asset_id.clone()).or_insert(0.0); - *entry -= activity_amount; - let sell_profit = activity_amount * activity.unit_price - activity_fee; - cumulative_cash += sell_profit; - _initial_investment -= activity_amount * activity.unit_price; - book_cost -= activity_amount * activity.unit_price + activity_fee; - } - "DEPOSIT" | "TRANSFER_IN" | "CONVERSION_IN" => { - cumulative_cash += activity_amount * activity.unit_price - activity_fee; - net_deposit += activity_amount * activity.unit_price; - } - "DIVIDEND" | "INTEREST" => { - cumulative_cash += activity_amount * activity.unit_price - activity_fee; - } - "WITHDRAWAL" | "TRANSFER_OUT" | "CONVERSION_OUT" => { - cumulative_cash -= activity_amount * activity.unit_price + activity_fee; - net_deposit -= activity_amount * activity.unit_price; - } - "FEE" | "TAX" => { - cumulative_cash -= activity_fee; - } - _ => {} - } - } - - let mut holdings_value = 0.0; - let mut day_gain_value = 0.0; - - // println!("{:?}", &holdings); - - for (symbol, &holding_amount) in &holdings { - let quote = quotes - .iter() - .find(|q| q.date.date() == date && q.symbol == *symbol) - .or_else(|| last_available_quotes.get(symbol).cloned()) // Copy the reference to the quote - ; // Copy the reference to the quote - - if let Some(quote) = quote { - let holding_value_for_symbol = holding_amount * quote.close; - let daily_change_percent = ((quote.close - quote.open) / quote.open) * 100.0; - let day_gain_for_symbol = - (daily_change_percent / 100.0) * holding_value_for_symbol; - - holdings_value += holding_value_for_symbol; - day_gain_value += day_gain_for_symbol; + pub fn get_accounts_summary(&self) -> Result, Box> { + let accounts = self.account_service.get_accounts()?; + let mut account_summaries = Vec::new(); - // Update the last available quote for the symbol - last_available_quotes.insert(symbol.clone(), quote); - } - } - - let day_gain_percentage = if holdings_value != 0.0 { - (day_gain_value / holdings_value) * 100.0 - } else { - 0.0 - }; - - let total_value = cumulative_cash + holdings_value; - let total_gain_value = holdings_value - book_cost; - let total_gain_percentage = if book_cost != 0.0 { - (total_gain_value / book_cost) * 100.0 + // First, get the total portfolio value + let total_portfolio_value = + if let Ok(total_history) = self.history_service.get_latest_account_history("TOTAL") { + total_history.market_value } else { - 0.0 + return Err(Box::new(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Total portfolio history not found", + ))); }; - let exchange_rate = self.get_exchange_rate(currency); - - results.push(FinancialSnapshot { - date: date.format("%Y-%m-%d").to_string(), - total_value, - market_value: holdings_value, - book_cost, - available_cash: cumulative_cash, - net_deposit, - currency: currency.to_string(), - base_currency: self.base_currency.to_string(), - total_gain_value: holdings_value - book_cost, - total_gain_percentage, - day_gain_percentage, - day_gain_value, - allocation_percentage: None, // to Calculate later - exchange_rate: Some(exchange_rate), - }); - } - - results - } - - pub fn get_income_data( - &self, - conn: &mut SqliteConnection, - ) -> Result, diesel::result::Error> { - use crate::schema::activities; - use diesel::prelude::*; - - activities::table - .filter(activities::activity_type.eq_any(vec!["DIVIDEND", "INTEREST"])) - .select(( - activities::activity_date, - activities::activity_type, - activities::asset_id, - activities::quantity * activities::unit_price, - activities::currency, - )) - .load::<(NaiveDateTime, String, String, f64, String)>(conn) - .map(|results| { - results - .into_iter() - .map(|(date, income_type, symbol, amount, currency)| IncomeData { - date, - income_type, - symbol, - amount, - currency, - }) - .collect() - }) - } - - pub fn get_income_summary( - &self, - conn: &mut SqliteConnection, - ) -> Result { - let income_data = self.get_income_data(conn)?; - - let mut by_month: HashMap = HashMap::new(); - let mut by_type: HashMap = HashMap::new(); - let mut by_symbol: HashMap = HashMap::new(); - let mut total_income = 0.0; - let mut total_income_ytd = 0.0; - - let current_year = chrono::Local::now().year(); - - for data in income_data { - let month = data.date.format("%Y-%m").to_string(); - let converted_amount = self.convert_to_base_currency(data.amount, &data.currency); - - *by_month.entry(month).or_insert(0.0) += converted_amount; - *by_type.entry(data.income_type).or_insert(0.0) += converted_amount; - *by_symbol.entry(data.symbol).or_insert(0.0) += converted_amount; - total_income += converted_amount; - - if data.date.year() == current_year { - total_income_ytd += converted_amount; + // Then, calculate the allocation percentage for each account + for account in accounts { + if let Ok(history) = self.history_service.get_latest_account_history(&account.id) { + let allocation_percentage = if total_portfolio_value > 0.0 { + (history.market_value / total_portfolio_value) * 100.0 + } else { + 0.0 + }; + + account_summaries.push(AccountSummary { + account, + performance: PortfolioHistory { + allocation_percentage, + ..history + }, + }); } } - Ok(IncomeSummary { - by_month, - by_type, - by_symbol, - total_income, - total_income_ytd, - currency: self.base_currency.clone(), - }) + Ok(account_summaries) } } diff --git a/src-core/src/providers/company.rs b/src-core/src/providers/company.rs deleted file mode 100644 index 12fe35e..0000000 --- a/src-core/src/providers/company.rs +++ /dev/null @@ -1,69 +0,0 @@ -// use reqwest::header::{HeaderMap, COOKIE}; -// use reqwest::Client; - -// async fn fetch_company_profile() -> Result { -// let client = Client::new(); - -// // Make the first call to extract the Crumb cookie -// let response = client -// .get("https://finance.yahoo.com/quote/MSFT/profile?p=MSFT") -// .send() -// .await?; - -// // Extract the Crumb cookie from the response headers -// let headers = response.headers(); -// let crumb_cookie = headers -// .get("Set-Cookie") -// .and_then(|cookie| cookie.to_str().ok()) -// .and_then(|cookie| cookie.split(';').next()) -// .and_then(|cookie| cookie.strip_prefix("B=")) -// .unwrap_or(""); - -// //https://query1.finance.yahoo.com/v1/test/getcrumb -// Ok(crumb_cookie.to_string()) -// } - -// use async_std::sync::Arc; -// use futures::future::BoxFuture; - -// use super::*; -// use crate::api::model::CompanyData; -// use crate::YAHOO_CRUMB; - -// /// Returns a companies profile information. Only needs to be returned once. -// pub struct Company { -// symbol: String, -// } - -// impl Company { -// pub fn new(symbol: String) -> Company { -// Company { symbol } -// } -// } - -// impl AsyncTask for Company { -// type Input = String; -// type Response = CompanyData; - -// fn update_interval(&self) -> Option { -// None -// } - -// fn input(&self) -> Self::Input { -// self.symbol.clone() -// } - -// fn task<'a>(input: Arc) -> BoxFuture<'a, Option> { -// Box::pin(async move { -// let symbol = input.as_ref(); - -// let crumb = YAHOO_CRUMB.read().clone(); - -// if let Some(crumb) = crumb { -// crate::CLIENT.get_company_data(symbol, crumb).await.ok() -// } else { -// None -// } -// }) -// } -// } diff --git a/src-core/src/providers/mod.rs b/src-core/src/providers/mod.rs index 1007113..a86ac21 100644 --- a/src-core/src/providers/mod.rs +++ b/src-core/src/providers/mod.rs @@ -1,3 +1,2 @@ -// pub mod yahoo_connector; pub mod models; pub mod yahoo_provider; diff --git a/src-core/src/providers/models copy.rs b/src-core/src/providers/models copy.rs deleted file mode 100644 index 17df28a..0000000 --- a/src-core/src/providers/models copy.rs +++ /dev/null @@ -1,370 +0,0 @@ -// use std::{collections::HashMap, fmt}; - -// use serde::{Deserialize, Serialize}; -// #[derive(Serialize, Deserialize, Debug)] -// #[serde(rename_all = "camelCase")] -// pub struct YahooAssetProfile { -// pub name: String, -// pub address1: String, -// pub city: String, -// pub state: String, -// pub zip: String, -// pub country: String, -// pub phone: String, -// pub website: String, -// pub industry: String, -// pub sector: String, -// pub long_business_summary: String, -// pub full_time_employees: i64, -// pub audit_risk: i64, -// pub board_risk: i64, -// pub compensation_risk: i64, -// pub share_holder_rights_risk: i64, -// pub overall_risk: i64, -// pub governance_epoch_date: String, // Handling dates as strings for simplicity -// pub compensation_as_of_epoch_date: String, -// pub max_age: i64, -// } - -// #[derive(Debug, Serialize, Deserialize)] -// #[serde(rename_all = "camelCase")] -// pub struct YahooFinanceResponse { -// pub asset_profile: YahooAssetProfile, -// } - -// #[derive(Serialize, Deserialize)] -// #[serde(rename_all = "camelCase", deny_unknown_fields)] -// pub struct YahooResult { -// pub quote_summary: YahooQuoteSummary, -// } - -// #[derive(Serialize, Deserialize)] -// #[serde(rename_all = "camelCase")] -// pub struct YahooQuoteSummary { -// pub result: Vec, -// pub error: Option, -// } - -// #[derive(Debug, Serialize, Deserialize, Default)] -// #[serde(rename_all = "camelCase")] -// pub struct QuoteSummaryResult { -// pub price: Option, -// //pub summary_profile: Option, -// //pub top_holdings: Option, -// } - -// #[derive(Debug, Serialize, Deserialize, Default)] -// #[serde(rename_all = "camelCase")] -// pub struct Price { -// #[serde(flatten)] -// pub other: HashMap, -// pub average_daily_volume_10_day: Option, -// pub average_daily_volume_3_month: Option, -// pub exchange: String, -// pub exchange_name: String, -// pub exchange_data_delayed_by: i64, -// pub max_age: i64, -// pub post_market_change_percent: Option, -// pub short_name: String, -// pub long_name: String, -// pub quote_type: String, -// pub symbol: String, -// pub currency: Option, -// pub currency_symbol: Option, -// pub from_currency: Option, -// pub to_currency: Option, -// } - -// #[derive(Debug, Serialize, Deserialize, Default)] -// #[serde(rename_all = "camelCase")] -// pub struct QuoteType { -// #[serde(flatten)] -// pub other: HashMap, -// pub exchange: String, -// pub quote_type: String, -// pub symbol: String, -// pub underlying_symbol: String, -// pub short_name: Option, -// pub long_name: Option, -// pub first_trade_date_epoch_utc: Option, -// pub time_zone_full_name: String, -// pub time_zone_short_name: String, -// pub uuid: String, -// pub message_board_id: Option, -// pub gmt_off_set_milliseconds: i64, -// pub max_age: i64, -// } -// #[derive(Debug, Serialize, Deserialize, Default)] -// #[serde(rename_all = "camelCase")] -// pub struct SummaryProfile { -// #[serde(flatten)] -// pub other: HashMap, -// pub address1: Option, -// pub address2: Option, -// pub address3: Option, -// pub city: Option, -// pub state: Option, -// pub zip: Option, -// pub country: Option, -// pub phone: Option, -// pub fax: Option, -// pub website: Option, -// pub industry: Option, -// pub industry_disp: Option, -// pub sector: Option, -// pub sector_disp: Option, -// pub long_business_summary: Option, -// pub full_time_employees: Option, -// pub company_officers: Vec, // or a more specific type if known -// pub max_age: i64, -// pub twitter: Option, -// pub name: Option, -// pub start_date: Option, // String representation of Date -// pub description: Option, -// } - -// #[derive(Debug)] -// pub enum AssetClass { -// Alternative, -// Cryptocurrency, -// Equity, -// Commodity, -// } -// impl fmt::Display for AssetClass { -// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { -// let display_string = match self { -// AssetClass::Alternative => "Alternative", -// AssetClass::Cryptocurrency => "Cryptocurrency", -// AssetClass::Equity => "Equity", -// AssetClass::Commodity => "Commodity", -// }; -// write!(f, "{}", display_string) -// } -// } - -// #[derive(Debug)] -// pub enum AssetSubClass { -// Alternative, -// Cryptocurrency, -// Stock, -// Etf, -// Commodity, -// PreciousMetal, -// MutualFund, -// } -// impl fmt::Display for AssetSubClass { -// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { -// let display_string = match self { -// AssetSubClass::Alternative => "Alternative", -// AssetSubClass::Cryptocurrency => "Cryptocurrency", -// AssetSubClass::Stock => "Stock", -// AssetSubClass::Etf => "ETF", -// AssetSubClass::Commodity => "Commodity", -// AssetSubClass::PreciousMetal => "Precious Metal", -// AssetSubClass::MutualFund => "Mutual Fund", -// }; -// write!(f, "{}", display_string) -// } -// } - -use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, fmt}; - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct YahooResult { - pub quote_summary: QuoteSummary, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct QuoteSummary { - pub result: Vec, - pub error: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct QuoteSummaryResult { - pub price: Option, - pub summary_profile: Option, - pub top_holdings: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Price { - pub max_age: Option, - pub pre_market_change_percent: Option, - pub pre_market_change: Option, - pub pre_market_time: Option, - pub pre_market_price: Option, - pub pre_market_source: Option, - pub post_market_change_percent: Option, - pub post_market_change: Option, - pub post_market_time: Option, - pub post_market_price: Option, - pub post_market_source: Option, - pub regular_market_change_percent: Option, - pub regular_market_change: Option, - pub regular_market_time: Option, - pub regular_market_price: Option, - pub regular_market_day_high: Option, - pub regular_market_day_low: Option, - pub regular_market_volume: Option, - pub average_daily_volume_10_day: Option, - pub average_daily_volume_3_month: Option, - pub regular_market_previous_close: Option, - pub regular_market_source: Option, - pub regular_market_open: Option, - pub strike_price: Option, - pub open_interest: Option, - pub exchange: Option, - pub exchange_name: Option, - pub exchange_data_delayed_by: Option, - pub market_state: String, - pub quote_type: String, - pub symbol: String, - pub underlying_symbol: Option, - pub short_name: String, - pub long_name: String, - pub currency: Option, - pub quote_source_name: Option, - pub currency_symbol: Option, - pub from_currency: Option, - pub to_currency: Option, - pub last_market: Option, - pub volume_24_hr: Option, - pub volume_all_currencies: Option, - pub circulating_supply: Option, - pub market_cap: Option, - - #[serde(flatten)] - pub other: HashMap, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Change { - pub raw: Option, - pub fmt: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct PriceDetail { - pub raw: Option, - pub fmt: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Volume { - pub raw: Option, - pub fmt: Option, - pub long_fmt: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MarketCap { - pub raw: Option, - pub fmt: Option, - pub long_fmt: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SummaryProfile { - pub address1: Option, - pub city: Option, - pub state: Option, - pub zip: Option, - pub country: Option, - pub phone: Option, - pub website: Option, - pub industry: Option, - pub industry_key: Option, - pub industry_disp: Option, - pub sector: Option, - pub sector_key: Option, - pub sector_disp: Option, - pub long_business_summary: Option, - pub full_time_employees: Option, - pub company_officers: Option>, - pub max_age: Option, -} - -#[derive(Debug, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct TopHoldings { - pub stock_position: Option, - pub bond_position: Option, - pub sector_weightings: Vec, - pub cash_position: Option, - pub other_position: Option, - pub preferred_position: Option, - pub convertible_position: Option, -} - -#[derive(Debug, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub struct TopHoldingsSectorWeighting { - #[serde(flatten)] - pub other: HashMap, - pub realestate: Option, - pub consumer_cyclical: Option, - pub basic_materials: Option, - pub consumer_defensive: Option, - pub technology: Option, - pub communication_services: Option, - pub financial_services: Option, - pub utilities: Option, - pub industrials: Option, - pub energy: Option, - pub healthcare: Option, -} - -#[derive(Debug)] -pub enum AssetClass { - Alternative, - Cryptocurrency, - Equity, - Commodity, -} -impl fmt::Display for AssetClass { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let display_string = match self { - AssetClass::Alternative => "Alternative", - AssetClass::Cryptocurrency => "Cryptocurrency", - AssetClass::Equity => "Equity", - AssetClass::Commodity => "Commodity", - }; - write!(f, "{}", display_string) - } -} - -#[derive(Debug)] -pub enum AssetSubClass { - Alternative, - Cryptocurrency, - Stock, - Etf, - Commodity, - PreciousMetal, - MutualFund, -} -impl fmt::Display for AssetSubClass { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let display_string = match self { - AssetSubClass::Alternative => "Alternative", - AssetSubClass::Cryptocurrency => "Cryptocurrency", - AssetSubClass::Stock => "Stock", - AssetSubClass::Etf => "ETF", - AssetSubClass::Commodity => "Commodity", - AssetSubClass::PreciousMetal => "Precious Metal", - AssetSubClass::MutualFund => "Mutual Fund", - }; - write!(f, "{}", display_string) - } -} diff --git a/src-core/src/providers/models.rs b/src-core/src/providers/models.rs index 37d0465..f74c55f 100644 --- a/src-core/src/providers/models.rs +++ b/src-core/src/providers/models.rs @@ -1,172 +1,3 @@ -// use std::{collections::HashMap, fmt}; - -// use serde::{Deserialize, Serialize}; -// #[derive(Serialize, Deserialize, Debug)] -// #[serde(rename_all = "camelCase")] -// pub struct YahooAssetProfile { -// pub name: String, -// pub address1: String, -// pub city: String, -// pub state: String, -// pub zip: String, -// pub country: String, -// pub phone: String, -// pub website: String, -// pub industry: String, -// pub sector: String, -// pub long_business_summary: String, -// pub full_time_employees: i64, -// pub audit_risk: i64, -// pub board_risk: i64, -// pub compensation_risk: i64, -// pub share_holder_rights_risk: i64, -// pub overall_risk: i64, -// pub governance_epoch_date: String, // Handling dates as strings for simplicity -// pub compensation_as_of_epoch_date: String, -// pub max_age: i64, -// } - -// #[derive(Debug, Serialize, Deserialize)] -// #[serde(rename_all = "camelCase")] -// pub struct YahooFinanceResponse { -// pub asset_profile: YahooAssetProfile, -// } - -// #[derive(Serialize, Deserialize)] -// #[serde(rename_all = "camelCase", deny_unknown_fields)] -// pub struct YahooResult { -// pub quote_summary: YahooQuoteSummary, -// } - -// #[derive(Serialize, Deserialize)] -// #[serde(rename_all = "camelCase")] -// pub struct YahooQuoteSummary { -// pub result: Vec, -// pub error: Option, -// } - -// #[derive(Debug, Serialize, Deserialize, Default)] -// #[serde(rename_all = "camelCase")] -// pub struct QuoteSummaryResult { -// pub price: Option, -// //pub summary_profile: Option, -// //pub top_holdings: Option, -// } - -// #[derive(Debug, Serialize, Deserialize, Default)] -// #[serde(rename_all = "camelCase")] -// pub struct Price { -// #[serde(flatten)] -// pub other: HashMap, -// pub average_daily_volume_10_day: Option, -// pub average_daily_volume_3_month: Option, -// pub exchange: String, -// pub exchange_name: String, -// pub exchange_data_delayed_by: i64, -// pub max_age: i64, -// pub post_market_change_percent: Option, -// pub short_name: String, -// pub long_name: String, -// pub quote_type: String, -// pub symbol: String, -// pub currency: Option, -// pub currency_symbol: Option, -// pub from_currency: Option, -// pub to_currency: Option, -// } - -// #[derive(Debug, Serialize, Deserialize, Default)] -// #[serde(rename_all = "camelCase")] -// pub struct QuoteType { -// #[serde(flatten)] -// pub other: HashMap, -// pub exchange: String, -// pub quote_type: String, -// pub symbol: String, -// pub underlying_symbol: String, -// pub short_name: Option, -// pub long_name: Option, -// pub first_trade_date_epoch_utc: Option, -// pub time_zone_full_name: String, -// pub time_zone_short_name: String, -// pub uuid: String, -// pub message_board_id: Option, -// pub gmt_off_set_milliseconds: i64, -// pub max_age: i64, -// } -// #[derive(Debug, Serialize, Deserialize, Default)] -// #[serde(rename_all = "camelCase")] -// pub struct SummaryProfile { -// #[serde(flatten)] -// pub other: HashMap, -// pub address1: Option, -// pub address2: Option, -// pub address3: Option, -// pub city: Option, -// pub state: Option, -// pub zip: Option, -// pub country: Option, -// pub phone: Option, -// pub fax: Option, -// pub website: Option, -// pub industry: Option, -// pub industry_disp: Option, -// pub sector: Option, -// pub sector_disp: Option, -// pub long_business_summary: Option, -// pub full_time_employees: Option, -// pub company_officers: Vec, // or a more specific type if known -// pub max_age: i64, -// pub twitter: Option, -// pub name: Option, -// pub start_date: Option, // String representation of Date -// pub description: Option, -// } - -// #[derive(Debug)] -// pub enum AssetClass { -// Alternative, -// Cryptocurrency, -// Equity, -// Commodity, -// } -// impl fmt::Display for AssetClass { -// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { -// let display_string = match self { -// AssetClass::Alternative => "Alternative", -// AssetClass::Cryptocurrency => "Cryptocurrency", -// AssetClass::Equity => "Equity", -// AssetClass::Commodity => "Commodity", -// }; -// write!(f, "{}", display_string) -// } -// } - -// #[derive(Debug)] -// pub enum AssetSubClass { -// Alternative, -// Cryptocurrency, -// Stock, -// Etf, -// Commodity, -// PreciousMetal, -// MutualFund, -// } -// impl fmt::Display for AssetSubClass { -// fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { -// let display_string = match self { -// AssetSubClass::Alternative => "Alternative", -// AssetSubClass::Cryptocurrency => "Cryptocurrency", -// AssetSubClass::Stock => "Stock", -// AssetSubClass::Etf => "ETF", -// AssetSubClass::Commodity => "Commodity", -// AssetSubClass::PreciousMetal => "Precious Metal", -// AssetSubClass::MutualFund => "Mutual Fund", -// }; -// write!(f, "{}", display_string) -// } -// } - use serde::{Deserialize, Serialize}; use std::{collections::HashMap, fmt}; diff --git a/src-core/src/providers/res.json b/src-core/src/providers/res.json deleted file mode 100644 index 14141c6..0000000 --- a/src-core/src/providers/res.json +++ /dev/null @@ -1,442 +0,0 @@ -// { -// "quoteSummary": { -// "result": [ -// { -// "summaryProfile": { "phone": "+1 8003611392", "companyOfficers": [], "maxAge": 86400 }, -// "topHoldings": { -// "maxAge": 1, -// "cashPosition": { "raw": 0.0016, "fmt": "0.16%" }, -// "stockPosition": { "raw": 0.8007, "fmt": "80.07%" }, -// "bondPosition": { "raw": 0.19600001, "fmt": "19.60%" }, -// "otherPosition": { "raw": 0.0015, "fmt": "0.15%" }, -// "preferredPosition": { "raw": 1.0e-4, "fmt": "0.01%" }, -// "convertiblePosition": { "raw": 0.0, "fmt": "0.00%" }, -// "holdings": [ -// { -// "symbol": "ZSP", -// "holdingName": "BMO S&P 500 ETF (CAD)", -// "holdingPercent": { "raw": 0.35031158, "fmt": "35.03%" } -// }, -// { -// "symbol": "ZCN", -// "holdingName": "BMO S&P/TSX Capped Composite ETF", -// "holdingPercent": { "raw": 0.19643499, "fmt": "19.64%" } -// }, -// { -// "symbol": "ZEA", -// "holdingName": "BMO MSCI EAFE ETF", -// "holdingPercent": { "raw": 0.16319071, "fmt": "16.32%" } -// }, -// { -// "symbol": "ZAG", -// "holdingName": "BMO Aggregate Bond ETF", -// "holdingPercent": { "raw": 0.1375522, "fmt": "13.76%" } -// }, -// { -// "symbol": "ZEM", -// "holdingName": "BMO MSCI Emerging Markets ETF", -// "holdingPercent": { "raw": 0.0634233, "fmt": "6.34%" } -// }, -// { -// "symbol": "ZUAG.F", -// "holdingName": "BMO US Aggregate Bond ETF Hedged Units", -// "holdingPercent": { "raw": 0.0589472, "fmt": "5.89%" } -// }, -// { -// "symbol": "ZMID", -// "holdingName": "BMO S&P US Mid Cap ETF", -// "holdingPercent": { "raw": 0.021700699, "fmt": "2.17%" } -// }, -// { -// "symbol": "ZSML", -// "holdingName": "BMO S&P US Small Cap ETF", -// "holdingPercent": { "raw": 0.0087133, "fmt": "0.87%" } -// } -// ], -// "equityHoldings": { -// "priceToEarnings": { "raw": 0.0615, "fmt": "0.06" }, -// "priceToBook": { "raw": 0.44667, "fmt": "0.45" }, -// "priceToSales": { "raw": 0.59001, "fmt": "0.59" }, -// "priceToCashflow": { "raw": 0.09053, "fmt": "0.09" }, -// "medianMarketCap": {}, -// "threeYearEarningsGrowth": {}, -// "priceToEarningsCat": {}, -// "priceToBookCat": {}, -// "priceToSalesCat": {}, -// "priceToCashflowCat": {}, -// "medianMarketCapCat": {}, -// "threeYearEarningsGrowthCat": {} -// }, -// "bondHoldings": { -// "maturity": {}, -// "duration": { "raw": 2.94, "fmt": "2.94" }, -// "creditQuality": {}, -// "maturityCat": {}, -// "durationCat": {}, -// "creditQualityCat": {} -// }, -// "bondRatings": [ -// { "bb": { "raw": 0.0011, "fmt": "0.11%" } }, -// { "aa": { "raw": 0.42290002, "fmt": "42.29%" } }, -// { "aaa": { "raw": 0.29540002, "fmt": "29.54%" } }, -// { "a": { "raw": 0.12189999, "fmt": "12.19%" } }, -// { "other": { "raw": 0.0507, "fmt": "5.07%" } }, -// { "b": { "raw": 0.0, "fmt": "0.00%" } }, -// { "bbb": { "raw": 0.108, "fmt": "10.80%" } }, -// { "below_b": { "raw": 0.0, "fmt": "0.00%" } }, -// { "us_government": { "raw": 0.1287, "fmt": "12.87%" } } -// ], -// "sectorWeightings": [ -// { "realestate": { "raw": 0.026400002, "fmt": "2.64%" } }, -// { "consumer_cyclical": { "raw": 0.0991, "fmt": "9.91%" } }, -// { "basic_materials": { "raw": 0.0614, "fmt": "6.14%" } }, -// { "consumer_defensive": { "raw": 0.0627, "fmt": "6.27%" } }, -// { "technology": { "raw": 0.18540001, "fmt": "18.54%" } }, -// { "communication_services": { "raw": 0.065, "fmt": "6.50%" } }, -// { "financial_services": { "raw": 0.184, "fmt": "18.40%" } }, -// { "utilities": { "raw": 0.0295, "fmt": "2.95%" } }, -// { "industrials": { "raw": 0.1158, "fmt": "11.58%" } }, -// { "energy": { "raw": 0.0784, "fmt": "7.84%" } }, -// { "healthcare": { "raw": 0.0925, "fmt": "9.25%" } } -// ] -// }, -// "price": { -// "maxAge": 1, -// "preMarketChange": {}, -// "preMarketPrice": {}, -// "postMarketChange": {}, -// "postMarketPrice": {}, -// "regularMarketChangePercent": { "raw": -0.0029123805, "fmt": "-0.29%" }, -// "regularMarketChange": { "raw": -0.11000061, "fmt": "-0.11" }, -// "regularMarketTime": 1701184076, -// "priceHint": { "raw": 2, "fmt": "2", "longFmt": "2" }, -// "regularMarketPrice": { "raw": 37.66, "fmt": "37.66" }, -// "regularMarketDayHigh": { "raw": 37.76, "fmt": "37.76" }, -// "regularMarketDayLow": { "raw": 37.66, "fmt": "37.66" }, -// "regularMarketVolume": { "raw": 2668, "fmt": "2.67k", "longFmt": "2,668.00" }, -// "averageDailyVolume10Day": {}, -// "averageDailyVolume3Month": {}, -// "regularMarketPreviousClose": { "raw": 37.77, "fmt": "37.77" }, -// "regularMarketSource": "FREE_REALTIME", -// "regularMarketOpen": { "raw": 37.76, "fmt": "37.76" }, -// "strikePrice": {}, -// "openInterest": {}, -// "exchange": "TOR", -// "exchangeName": "Toronto", -// "exchangeDataDelayedBy": 15, -// "marketState": "REGULAR", -// "quoteType": "ETF", -// "symbol": "ZGRO.TO", -// "underlyingSymbol": null, -// "shortName": "BMO GROWTH ETF", -// "longName": "BMO Growth ETF", -// "currency": "CAD", -// "quoteSourceName": "Free Realtime Quote", -// "currencySymbol": "$", -// "fromCurrency": null, -// "toCurrency": null, -// "lastMarket": null, -// "volume24Hr": {}, -// "volumeAllCurrencies": {}, -// "circulatingSupply": {}, -// "marketCap": {} -// } -// } -// ], -// "error": null -// } -// } - -// [ -// { "name": "Consumer Cyclical", "weight": { "fmt": "9.91%", "raw": 0.0991 } }, -// { "name": "Basic Materials", "weight": { "fmt": "6.14%", "raw": 0.0614 } }, -// { "name": "Consumer Staples", "weight": { "fmt": "6.27%", "raw": 0.0627 } }, -// { "name": "Communication Services", "weight": { "fmt": "6.50%", "raw": 0.065 } }, -// { "name": "Financial Services", "weight": { "fmt": "18.40%", "raw": 0.184 } } -// ] - -// { -// "quoteSummary": { -// "result": [ -// { -// "summaryProfile": { "phone": "1-800-474-2737", "companyOfficers": [], "maxAge": 86400 }, -// "topHoldings": { -// "maxAge": 1, -// "cashPosition": { "raw": 0.0058, "fmt": "0.58%" }, -// "stockPosition": { "raw": 0.8051, "fmt": "80.51%" }, -// "bondPosition": { "raw": 0.1885, "fmt": "18.85%" }, -// "otherPosition": { "raw": 2.0e-4, "fmt": "0.02%" }, -// "preferredPosition": { "raw": 2.0e-4, "fmt": "0.02%" }, -// "convertiblePosition": { "raw": 2.0e-4, "fmt": "0.02%" }, -// "holdings": [ -// { -// "symbol": "ITOT", -// "holdingName": "iShares Core S&P Total US Stock Mkt ETF", -// "holdingPercent": { "raw": 0.3811914, "fmt": "38.12%" } -// }, -// { -// "symbol": "XEF.TO", -// "holdingName": "iShares Core MSCI EAFE IMI ETF", -// "holdingPercent": { "raw": 0.19702668, "fmt": "19.70%" } -// }, -// { -// "symbol": "XIC", -// "holdingName": "iShares Core S&P/TSX Capped Compost ETF", -// "holdingPercent": { "raw": 0.18982759, "fmt": "18.98%" } -// }, -// { -// "symbol": "XBB", -// "holdingName": "iShares Core Canadian Universe Bond ETF", -// "holdingPercent": { "raw": 0.1196289, "fmt": "11.96%" } -// }, -// { -// "symbol": "XEC", -// "holdingName": "iShares Core MSCI Emer Mkts IMI ETF", -// "holdingPercent": { "raw": 0.0400306, "fmt": "4.00%" } -// }, -// { -// "symbol": "XSH", -// "holdingName": "iShares Core Canadian ST Corp Bd ETF", -// "holdingPercent": { "raw": 0.0311276, "fmt": "3.11%" } -// }, -// { -// "symbol": "GOVT", -// "holdingName": "iShares US Treasury Bond ETF", -// "holdingPercent": { "raw": 0.0190967, "fmt": "1.91%" } -// }, -// { -// "symbol": "USIG", -// "holdingName": "iShares Broad USD Invm Grd Corp Bd ETF", -// "holdingPercent": { "raw": 0.0189961, "fmt": "1.90%" } -// } -// ], -// "equityHoldings": { -// "priceToEarnings": { "raw": 0.06465, "fmt": "0.06" }, -// "priceToBook": { "raw": 0.47801, "fmt": "0.48" }, -// "priceToSales": { "raw": 0.63484, "fmt": "0.63" }, -// "priceToCashflow": { "raw": 0.09472, "fmt": "0.09" }, -// "medianMarketCap": {}, -// "threeYearEarningsGrowth": {}, -// "priceToEarningsCat": {}, -// "priceToBookCat": {}, -// "priceToSalesCat": {}, -// "priceToCashflowCat": {}, -// "medianMarketCapCat": {}, -// "threeYearEarningsGrowthCat": {} -// }, -// "bondHoldings": { -// "maturity": {}, -// "duration": { "raw": 3.21, "fmt": "3.21" }, -// "creditQuality": {}, -// "maturityCat": {}, -// "durationCat": {}, -// "creditQualityCat": {} -// }, -// "bondRatings": [ -// { "bb": { "raw": 0.0021, "fmt": "0.21%" } }, -// { "aa": { "raw": 0.36470002, "fmt": "36.47%" } }, -// { "aaa": { "raw": 0.2604, "fmt": "26.04%" } }, -// { "a": { "raw": 0.1966, "fmt": "19.66%" } }, -// { "other": { "raw": 0.0074, "fmt": "0.74%" } }, -// { "b": { "raw": 0.0, "fmt": "0.00%" } }, -// { "bbb": { "raw": 0.16870001, "fmt": "16.87%" } }, -// { "below_b": { "raw": 0.0, "fmt": "0.00%" } }, -// { "us_government": { "raw": 0.1069, "fmt": "10.69%" } } -// ], -// "sectorWeightings": [ -// { "realestate": { "raw": 0.0286, "fmt": "2.86%" } }, -// { "consumer_cyclical": { "raw": 0.09729999, "fmt": "9.73%" } }, -// { "basic_materials": { "raw": 0.060700003, "fmt": "6.07%" } }, -// { "consumer_defensive": { "raw": 0.0627, "fmt": "6.27%" } }, -// { "technology": { "raw": 0.18049999, "fmt": "18.05%" } }, -// { "communication_services": { "raw": 0.0617, "fmt": "6.17%" } }, -// { "financial_services": { "raw": 0.18200001, "fmt": "18.20%" } }, -// { "utilities": { "raw": 0.0292, "fmt": "2.92%" } }, -// { "industrials": { "raw": 0.12, "fmt": "12.00%" } }, -// { "energy": { "raw": 0.0825, "fmt": "8.25%" } }, -// { "healthcare": { "raw": 0.0947, "fmt": "9.47%" } } -// ] -// }, -// "price": { -// "maxAge": 1, -// "preMarketChange": {}, -// "preMarketPrice": {}, -// "postMarketChange": {}, -// "postMarketPrice": {}, -// "regularMarketChangePercent": { "raw": -0.0019888321, "fmt": "-0.20%" }, -// "regularMarketChange": { "raw": -0.049999237, "fmt": "-0.05" }, -// "regularMarketTime": 1701199596, -// "priceHint": { "raw": 2, "fmt": "2", "longFmt": "2" }, -// "regularMarketPrice": { "raw": 25.09, "fmt": "25.09" }, -// "regularMarketDayHigh": { "raw": 25.16, "fmt": "25.16" }, -// "regularMarketDayLow": { "raw": 25.07, "fmt": "25.07" }, -// "regularMarketVolume": { "raw": 12452, "fmt": "12.45k", "longFmt": "12,452.00" }, -// "averageDailyVolume10Day": {}, -// "averageDailyVolume3Month": {}, -// "regularMarketPreviousClose": { "raw": 25.14, "fmt": "25.14" }, -// "regularMarketSource": "FREE_REALTIME", -// "regularMarketOpen": { "raw": 25.1, "fmt": "25.10" }, -// "strikePrice": {}, -// "openInterest": {}, -// "exchange": "TOR", -// "exchangeName": "Toronto", -// "exchangeDataDelayedBy": 15, -// "marketState": "REGULAR", -// "quoteType": "ETF", -// "symbol": "XGRO.TO", -// "underlyingSymbol": null, -// "shortName": "ISHARES CORE GROWTH ETF PORTFOL", -// "longName": "iShares Core Growth ETF Portfolio", -// "currency": "CAD", -// "currencySymbol": "$", -// "fromCurrency": null, -// "toCurrency": null, -// "lastMarket": null, -// "volume24Hr": {}, -// "volumeAllCurrencies": {}, -// "circulatingSupply": {}, -// "marketCap": {} -// } -// } -// ], -// "error": null -// } -// } - -{ - "quoteSummary": { - "result": [ - { - "price": { - "maxAge": 1, - "preMarketChange": {}, - "preMarketPrice": {}, - "postMarketChange": {}, - "postMarketPrice": {}, - "regularMarketChangePercent": { "raw": 0.0334232, "fmt": "3.34%" }, - "regularMarketChange": { "raw": 1671.9453, "fmt": "1,671.95" }, - "regularMarketTime": 1701202025, - "priceHint": { "raw": 2, "fmt": "2", "longFmt": "2" }, - "regularMarketPrice": { "raw": 51695.484, "fmt": "51,695.48" }, - "regularMarketDayHigh": { "raw": 51938.348, "fmt": "51,938.35" }, - "regularMarketDayLow": { "raw": 50211.676, "fmt": "50,211.68" }, - "regularMarketVolume": { - "raw": 27614005248, - "fmt": "27.61B", - "longFmt": "27,614,005,248.00" - }, - "averageDailyVolume10Day": {}, - "averageDailyVolume3Month": {}, - "regularMarketPreviousClose": { "raw": 50680.848, "fmt": "50,680.85" }, - "regularMarketSource": "FREE_REALTIME", - "regularMarketOpen": { "raw": 50680.848, "fmt": "50,680.85" }, - "strikePrice": {}, - "openInterest": {}, - "exchange": "CCC", - "exchangeName": "CCC", - "exchangeDataDelayedBy": 0, - "marketState": "REGULAR", - "quoteType": "CRYPTOCURRENCY", - "symbol": "BTC-CAD", - "underlyingSymbol": null, - "shortName": "Bitcoin CAD", - "longName": "Bitcoin CAD", - "currency": "CAD", - "quoteSourceName": "CoinMarketCap", - "currencySymbol": "$", - "fromCurrency": "BTC", - "toCurrency": "CAD=X", - "lastMarket": "CoinMarketCap", - "volume24Hr": { "raw": 27614005248, "fmt": "27.61B", "longFmt": "27,614,005,248.00" }, - "volumeAllCurrencies": { - "raw": 27614005248, - "fmt": "27.61B", - "longFmt": "27,614,005,248.00" - }, - "circulatingSupply": { "raw": 19555532, "fmt": "19.56M", "longFmt": "19,555,532.00" }, - "marketCap": { "raw": 1010932645888, "fmt": "1.01T", "longFmt": "1,010,932,645,888.00" } - }, - "summaryProfile": { - "companyOfficers": [], - "name": "Bitcoin", - "startDate": "2010-07-13", - "description": "Bitcoin (BTC) is a cryptocurrency launched in 2010. Users are able to generate BTC through the process of mining. Bitcoin has a current supply of 19,555,118. The last known price of Bitcoin is 37,128.22689696 USD and is down -0.85 over the last 24 hours. It is currently trading on 10574 active market(s) with $18,703,915,541.89 traded over the last 24 hours. More information can be found at https://bitcoin.org/.", - "maxAge": 86400 - } - } - ], - "error": null - } -} - -// { -// "quoteSummary": { -// "result": [ -// { -// "price": { -// "maxAge": 1, -// "preMarketChange": {}, -// "preMarketPrice": {}, -// "postMarketChange": {}, -// "postMarketPrice": {}, -// "regularMarketChangePercent": { "raw": 0.033825435, "fmt": "3.38%" }, -// "regularMarketChange": { "raw": 0.017199755, "fmt": "0.017200" }, -// "regularMarketTime": 1701201905, -// "priceHint": { "raw": 6, "fmt": "6", "longFmt": "6" }, -// "regularMarketPrice": { "raw": 0.52568704, "fmt": "0.525687" }, -// "regularMarketDayHigh": { "raw": 0.5264007, "fmt": "0.526401" }, -// "regularMarketDayLow": { "raw": 0.5036838, "fmt": "0.503684" }, -// "regularMarketVolume": { -// "raw": 373698496, -// "fmt": "373.70M", -// "longFmt": "373,698,496.00" -// }, -// "averageDailyVolume10Day": {}, -// "averageDailyVolume3Month": {}, -// "regularMarketPreviousClose": { "raw": 0.51478374, "fmt": "0.514784" }, -// "regularMarketSource": "FREE_REALTIME", -// "regularMarketOpen": { "raw": 0.51478374, "fmt": "0.514784" }, -// "strikePrice": {}, -// "openInterest": {}, -// "exchange": "CCC", -// "exchangeName": "CCC", -// "exchangeDataDelayedBy": 0, -// "marketState": "REGULAR", -// "quoteType": "CRYPTOCURRENCY", -// "symbol": "ADA-CAD", -// "underlyingSymbol": null, -// "shortName": "Cardano CAD", -// "longName": "Cardano CAD", -// "currency": "CAD", -// "quoteSourceName": "CoinMarketCap", -// "currencySymbol": "$", -// "fromCurrency": "ADA", -// "toCurrency": "CAD=X", -// "lastMarket": "CoinMarketCap", -// "volume24Hr": { "raw": 373698496, "fmt": "373.70M", "longFmt": "373,698,496.00" }, -// "volumeAllCurrencies": { -// "raw": 373698496, -// "fmt": "373.70M", -// "longFmt": "373,698,496.00" -// }, -// "circulatingSupply": { -// "raw": 35298205696, -// "fmt": "35.30B", -// "longFmt": "35,298,205,696.00" -// }, -// "marketCap": { "raw": 18555809792, "fmt": "18.56B", "longFmt": "18,555,809,792.00" } -// }, -// "summaryProfile": { -// "companyOfficers": [], -// "twitter": "\"https://twitter.com/cardano\"", -// "name": "Cardano", -// "startDate": "2017-10-01", -// "description": "Cardano (ADA) is a cryptocurrency launched in 2017. Cardano has a current supply of 36,395,268,923.436 with 35,298,225,395.595 in circulation. The last known price of Cardano is 0.3735945 USD and is down -3.20 over the last 24 hours. It is currently trading on 984 active market(s) with $263,724,779.52 traded over the last 24 hours. More information can be found at https://www.cardano.org.", -// "maxAge": 86400 -// } -// } -// ], -// "error": null -// } - -// } \ No newline at end of file diff --git a/src-core/src/providers/yahoo_connector.rs b/src-core/src/providers/yahoo_connector.rs deleted file mode 100644 index 0104700..0000000 --- a/src-core/src/providers/yahoo_connector.rs +++ /dev/null @@ -1,97 +0,0 @@ -use reqwest::Client; - -const YCHART_URL: &str = "https://query1.finance.yahoo.com/v8/finance/chart"; -const YSEARCH_URL: &str = "https://query2.finance.yahoo.com/v1/finance/search"; - -pub struct YahooConnector { - client: Client, - url: &'static str, - search_url: &'static str, -} - -impl YahooConnector { - /// Retrieve the quotes of the last day for the given ticker - pub async fn get_latest_quotes( - &self, - ticker: &str, - interval: &str, - ) -> Result { - self.get_quote_range(ticker, interval, "1mo").await - } - - /// Retrieve the quote history for the given ticker form date start to end (inclusive), if available - pub async fn get_quote_history( - &self, - ticker: &str, - start: OffsetDateTime, - end: OffsetDateTime, - ) -> Result { - self.get_quote_history_interval(ticker, start, end, "1d") - .await - } - - /// Retrieve quotes for the given ticker for an arbitrary range - pub async fn get_quote_range( - &self, - ticker: &str, - interval: &str, - range: &str, - ) -> Result { - let url: String = format!( - YCHART_RANGE_QUERY!(), - url = self.url, - symbol = ticker, - interval = interval, - range = range - ); - YResponse::from_json(self.send_request(&url).await?) - } - - /// Retrieve the quote history for the given ticker form date start to end (inclusive), if available; specifying the interval of the ticker. - pub async fn get_quote_history_interval( - &self, - ticker: &str, - start: OffsetDateTime, - end: OffsetDateTime, - interval: &str, - ) -> Result { - let url = format!( - YCHART_PERIOD_QUERY!(), - url = self.url, - symbol = ticker, - start = start.unix_timestamp(), - end = end.unix_timestamp(), - interval = interval - ); - YResponse::from_json(self.send_request(&url).await?) - } - - /// Retrieve the list of quotes found searching a given name - pub async fn search_ticker_opt(&self, name: &str) -> Result { - let url = format!(YTICKER_QUERY!(), url = self.search_url, name = name); - YSearchResultOpt::from_json(self.send_request(&url).await?) - } - - /// Retrieve the list of quotes found searching a given name - pub async fn search_ticker(&self, name: &str) -> Result { - let result = self.search_ticker_opt(name).await?; - Ok(YSearchResult::from_opt(&result)) - } - - /// Get list for options for a given name - pub async fn search_options(&self, name: &str) -> Result { - let url = format!("https://finance.yahoo.com/quote/{name}/options?p={name}"); - let resp = self.client.get(url).send().await?.text().await?; - Ok(YOptionResults::scrape(&resp)) - } - - /// Send request to yahoo! finance server and transform response to JSON value - async fn send_request(&self, url: &str) -> Result { - let resp = self.client.get(url).send().await?; - - match resp.status() { - StatusCode::OK => Ok(resp.json().await?), - status => Err(YahooError::FetchFailed(format!("{}", status))), - } - } -} diff --git a/src-core/src/providers/yahoo_provider.rs b/src-core/src/providers/yahoo_provider.rs index b64a048..85c7f9f 100644 --- a/src-core/src/providers/yahoo_provider.rs +++ b/src-core/src/providers/yahoo_provider.rs @@ -1,6 +1,8 @@ use std::{sync::RwLock, time::SystemTime}; -use crate::models::{CrumbData, NewAsset, QuoteSummary}; +use super::models::{AssetClass, AssetSubClass, PriceDetail, YahooResult}; +use crate::models::{CrumbData, NewAsset, Quote as ModelQuote, QuoteSummary}; +use chrono::{DateTime, Utc}; use lazy_static::lazy_static; use reqwest::{header, Client}; use serde_json::json; @@ -8,8 +10,6 @@ use thiserror::Error; use yahoo::{YQuoteItem, YahooError}; use yahoo_finance_api as yahoo; -use super::models::{AssetClass, AssetSubClass, PriceDetail, YahooResult}; - impl From<&YQuoteItem> for QuoteSummary { fn from(item: &YQuoteItem) -> Self { QuoteSummary { @@ -33,8 +33,8 @@ impl From<&YQuoteItem> for QuoteSummary { impl From<&YQuoteItem> for NewAsset { fn from(item: &YQuoteItem) -> Self { NewAsset { - id: item.symbol.clone(), // Assuming the symbol is used as the id - isin: None, // Map the rest of the fields accordingly + id: item.symbol.clone(), + isin: None, // TODO: Implement isin name: Some(item.long_name.clone()), asset_type: Some(item.quote_type.clone()), symbol: item.symbol.clone(), @@ -61,13 +61,41 @@ pub struct YahooProvider { } impl YahooProvider { - pub fn new() -> Result { + pub async fn new() -> Result { let provider = yahoo::YahooConnector::new()?; - Ok(YahooProvider { provider }) + let yahoo_provider = YahooProvider { provider }; + yahoo_provider.set_crumb().await?; + Ok(yahoo_provider) + } + + pub async fn get_latest_quote(&self, symbol: &str) -> Result { + let response = self.provider.get_latest_quotes(symbol, "1d").await?; + response + .last_quote() + .map_err(|_| yahoo::YahooError::EmptyDataSet) + } + + fn yahoo_quote_to_model_quote(&self, symbol: String, yahoo_quote: yahoo::Quote) -> ModelQuote { + let date = DateTime::::from_timestamp(yahoo_quote.timestamp as i64, 0) + .unwrap_or_default() + .naive_utc(); + + ModelQuote { + id: format!("{}_{}", date.format("%Y%m%d"), symbol), + created_at: chrono::Utc::now().naive_utc(), + data_source: "YAHOO".to_string(), + date, + symbol: symbol, + open: yahoo_quote.open, + high: yahoo_quote.high, + low: yahoo_quote.low, + volume: yahoo_quote.volume as f64, + close: yahoo_quote.close, + adjclose: yahoo_quote.adjclose, + } } - // pub async fn set_crumb() -> Result<(), yahoo::YahooError> { - pub async fn set_crumb(&self) -> Result<(), yahoo::YahooError> { + async fn set_crumb(&self) -> Result<(), yahoo::YahooError> { let client = Client::new(); // Make the first call to extract the Crumb cookie @@ -125,7 +153,7 @@ impl YahooProvider { Ok(asset_profiles) } - pub async fn fetch_quote_summary(&self, symbol: &str) -> Result { + pub async fn fetch_symbol_summary(&self, symbol: &str) -> Result { // Handle the cash asset case if let Some(currency) = symbol.strip_prefix("$CASH-") { return Ok(self.create_cash_asset(symbol, currency)); @@ -253,12 +281,11 @@ impl YahooProvider { symbol: &str, start: SystemTime, end: SystemTime, - ) -> Result, yahoo::YahooError> { + ) -> Result, yahoo::YahooError> { if symbol.starts_with("$CASH-") { return Ok(vec![]); } - // Convert SystemTime to OffsetDateTime as required by get_quote_history let start_offset = start.into(); let end_offset = end.into(); @@ -267,18 +294,27 @@ impl YahooProvider { .get_quote_history(symbol, start_offset, end_offset) .await?; - response.quotes() + // Use the new method to convert quotes + let quotes = response + .quotes()? + .into_iter() + .map(|q| self.yahoo_quote_to_model_quote(symbol.to_string(), q)) + .collect(); + + Ok(quotes) } pub async fn fetch_asset_profile( &self, symbol: &str, ) -> Result { - let crumb_data = YAHOO_CRUMB.read().unwrap(); - - let crumb_data = crumb_data - .as_ref() - .ok_or_else(|| YahooError::FetchFailed("Crumb data not found".into()))?; + let crumb_data = { + let guard = YAHOO_CRUMB.read().unwrap(); + guard + .as_ref() + .ok_or_else(|| YahooError::FetchFailed("Crumb data not found".into()))? + .clone() + }; let url = format!( "https://query1.finance.yahoo.com/v10/finance/quoteSummary/{}?modules=price,summaryProfile,topHoldings&crumb={}", @@ -306,9 +342,6 @@ impl YahooProvider { .await .map_err(|err| YahooError::FetchFailed(err.to_string()))?; - // Print the raw JSON response - println!("Raw JSON Response: {}", response_text); - // Deserialize the JSON response into your struct let deserialized: YahooResult = serde_json::from_str(&response_text).map_err(|err| { println!("JSON Deserialization Error: {}", err); @@ -398,7 +431,7 @@ impl YahooProvider { "realestate" => "Real Estate".to_string(), "technology" => "Technology".to_string(), "utilities" => "Utilities".to_string(), - _ => "UNKNOWN".to_string(), + _ => a_string.to_string(), } } } diff --git a/src-core/src/schema.rs b/src-core/src/schema.rs index e3cdcdf..b45b356 100644 --- a/src-core/src/schema.rs +++ b/src-core/src/schema.rs @@ -33,6 +33,13 @@ diesel::table! { } } +diesel::table! { + app_settings (setting_key) { + setting_key -> Text, + setting_value -> Text, + } +} + diesel::table! { assets (id) { id -> Text, @@ -58,35 +65,14 @@ diesel::table! { } diesel::table! { - platforms (id) { - id -> Text, - name -> Nullable, - url -> Text, - } -} - -diesel::table! { - quotes (id) { + exchange_rates (id) { id -> Text, + from_currency -> Text, + to_currency -> Text, + rate -> Double, + source -> Text, created_at -> Timestamp, - data_source -> Text, - date -> Timestamp, - symbol -> Text, - open -> Double, - high -> Double, - low -> Double, - volume -> Double, - close -> Double, - adjclose -> Double, - } -} - -diesel::table! { - settings (id) { - id -> Integer, - theme -> Text, - font -> Text, - base_currency -> Text, + updated_at -> Timestamp, } } @@ -109,14 +95,68 @@ diesel::table! { } } +diesel::table! { + platforms (id) { + id -> Text, + name -> Nullable, + url -> Text, + } +} + +diesel::table! { + portfolio_history (id) { + id -> Text, + account_id -> Text, + date -> Date, + total_value -> Double, + market_value -> Double, + book_cost -> Double, + available_cash -> Double, + net_deposit -> Double, + currency -> Text, + base_currency -> Text, + total_gain_value -> Double, + total_gain_percentage -> Double, + day_gain_percentage -> Double, + day_gain_value -> Double, + allocation_percentage -> Double, + exchange_rate -> Double, + holdings -> Nullable, + } +} + +diesel::table! { + quotes (id) { + id -> Text, + created_at -> Timestamp, + data_source -> Text, + date -> Timestamp, + symbol -> Text, + open -> Double, + high -> Double, + low -> Double, + volume -> Double, + close -> Double, + adjclose -> Double, + } +} + diesel::joinable!(accounts -> platforms (platform_id)); diesel::joinable!(activities -> accounts (account_id)); diesel::joinable!(activities -> assets (asset_id)); -diesel::joinable!(quotes -> assets (symbol)); +diesel::joinable!(goals_allocation -> accounts (account_id)); diesel::joinable!(goals_allocation -> goals (goal_id)); +diesel::joinable!(quotes -> assets (symbol)); diesel::allow_tables_to_appear_in_same_query!( - accounts, activities, assets, platforms, quotes, settings, + accounts, + activities, + app_settings, + assets, + exchange_rates, + goals, + goals_allocation, + platforms, + portfolio_history, + quotes, ); - -diesel::allow_tables_to_appear_in_same_query!(goals, goals_allocation); diff --git a/src-core/src/settings/mod.rs b/src-core/src/settings/mod.rs index e183b15..dc0f355 100644 --- a/src-core/src/settings/mod.rs +++ b/src-core/src/settings/mod.rs @@ -1,3 +1,5 @@ +pub mod settings_repository; pub mod settings_service; +pub use settings_repository::SettingsRepository; pub use settings_service::SettingsService; diff --git a/src-core/src/settings/settings_repository.rs b/src-core/src/settings/settings_repository.rs new file mode 100644 index 0000000..95d2ef9 --- /dev/null +++ b/src-core/src/settings/settings_repository.rs @@ -0,0 +1,81 @@ +use crate::models::{AppSetting, Settings}; +use crate::schema::app_settings::dsl::*; +use diesel::prelude::*; +use diesel::sqlite::SqliteConnection; + +pub struct SettingsRepository; + +impl SettingsRepository { + pub fn get_settings(conn: &mut SqliteConnection) -> Result { + let theme = app_settings + .filter(setting_key.eq("theme")) + .select(setting_value) + .first::(conn)?; + + let font = app_settings + .filter(setting_key.eq("font")) + .select(setting_value) + .first::(conn)?; + + let base_currency = app_settings + .filter(setting_key.eq("base_currency")) + .select(setting_value) + .first::(conn)?; + + Ok(Settings { + theme, + font, + base_currency, + }) + } + + pub fn update_settings( + conn: &mut SqliteConnection, + new_settings: &Settings, + ) -> Result<(), diesel::result::Error> { + let settings_to_insert = vec![ + AppSetting { + setting_key: "theme".to_string(), + setting_value: new_settings.theme.clone(), + }, + AppSetting { + setting_key: "font".to_string(), + setting_value: new_settings.font.clone(), + }, + AppSetting { + setting_key: "base_currency".to_string(), + setting_value: new_settings.base_currency.clone(), + }, + ]; + + diesel::replace_into(app_settings) + .values(&settings_to_insert) + .execute(conn)?; + + Ok(()) + } + + pub fn get_setting( + conn: &mut SqliteConnection, + setting_key_param: &str, + ) -> Result { + app_settings + .filter(setting_key.eq(setting_key_param)) + .select(setting_value) + .first(conn) + } + + pub fn update_setting( + conn: &mut SqliteConnection, + setting_key_param: &str, + setting_value_param: &str, + ) -> Result<(), diesel::result::Error> { + diesel::replace_into(app_settings) + .values(AppSetting { + setting_key: setting_key_param.to_string(), + setting_value: setting_value_param.to_string(), + }) + .execute(conn)?; + Ok(()) + } +} diff --git a/src-core/src/settings/settings_service.rs b/src-core/src/settings/settings_service.rs index c457442..cbae646 100644 --- a/src-core/src/settings/settings_service.rs +++ b/src-core/src/settings/settings_service.rs @@ -1,45 +1,51 @@ -// settings_service.rs - -use crate::models::{NewSettings, Settings}; -use crate::schema::settings::dsl::*; -use diesel::prelude::*; +use super::settings_repository::SettingsRepository; +use crate::models::Settings; use diesel::sqlite::SqliteConnection; -pub struct SettingsService { - settings_id: i32, -} +pub struct SettingsService; impl SettingsService { pub fn new() -> Self { - SettingsService { settings_id: 1 } + SettingsService } pub fn get_settings( &self, conn: &mut SqliteConnection, ) -> Result { - settings.find(self.settings_id).first::(conn) + SettingsRepository::get_settings(conn) } pub fn update_settings( &self, conn: &mut SqliteConnection, - new_setting: &NewSettings, + new_settings: &Settings, ) -> Result<(), diesel::result::Error> { - // First, try to update - let rows_affected = diesel::update(settings.find(self.settings_id)) - .set(new_setting) - .execute(conn)?; - - // Check if the update affected any rows - if rows_affected == 0 { - // If no rows were affected, perform an insert - diesel::insert_into(settings) - .values(new_setting) - .execute(conn)?; - } - - Ok(()) + SettingsRepository::update_settings(conn, new_settings) + } + + pub fn get_setting( + &self, + conn: &mut SqliteConnection, + setting_key: &str, + ) -> Result { + SettingsRepository::get_setting(conn, setting_key) + } + + pub fn update_setting( + &self, + conn: &mut SqliteConnection, + setting_key: &str, + setting_value: &str, + ) -> Result<(), diesel::result::Error> { + SettingsRepository::update_setting(conn, setting_key, setting_value) + } + + pub fn get_base_currency( + &self, + conn: &mut SqliteConnection, + ) -> Result { + self.get_setting(conn, "base_currency") } pub fn update_base_currency( @@ -47,9 +53,6 @@ impl SettingsService { conn: &mut SqliteConnection, new_base_currency: &str, ) -> Result<(), diesel::result::Error> { - diesel::update(settings.find(self.settings_id)) - .set(base_currency.eq(new_base_currency)) - .execute(conn)?; - Ok(()) + self.update_setting(conn, "base_currency", new_base_currency) } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 0b16659..b407981 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -637,6 +637,20 @@ dependencies = [ "syn 2.0.77", ] +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "deranged" version = "0.3.11" @@ -673,6 +687,7 @@ dependencies = [ "num-bigint", "num-integer", "num-traits", + "r2d2", "time", ] @@ -2709,6 +2724,17 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + [[package]] name = "rand" version = "0.7.3" @@ -3139,6 +3165,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4414,8 +4449,9 @@ dependencies = [ [[package]] name = "wealthfolio-app" -version = "1.0.13" +version = "1.0.14" dependencies = [ + "diesel", "dotenvy", "tauri", "tauri-build", @@ -4428,9 +4464,11 @@ version = "1.0.11" dependencies = [ "chrono", "csv", + "dashmap", "diesel", "diesel_migrations", "lazy_static", + "r2d2", "rayon", "regex", "reqwest 0.12.7", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 340a567..2590318 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wealthfolio-app" -version = "1.0.13" +version = "1.0.14" description = "Portfolio tracker" authors = ["Aziz Fadil"] license = "LGPL-3.0" @@ -15,6 +15,7 @@ tauri-build = { version = "1.5.4", features = [] } [dependencies] wealthfolio_core = { path = "../src-core" } tauri = { version = "1.7.2", features = [ "updater", "dialog-open", "fs-all", "path-all", "window-start-dragging", "shell-open"] } +diesel = { version = "2.2.4", features = ["sqlite", "chrono", "r2d2", "numeric", "returning_clauses_for_sqlite_3_35"] } dotenvy = "0.15.7" [features] diff --git a/src-tauri/src/commands/account.rs b/src-tauri/src/commands/account.rs index 2b3dd57..9539d72 100644 --- a/src-tauri/src/commands/account.rs +++ b/src-tauri/src/commands/account.rs @@ -1,44 +1,54 @@ -use crate::account::account_service; +use crate::account::account_service::AccountService; use crate::models::{Account, AccountUpdate, NewAccount}; use crate::AppState; use tauri::State; #[tauri::command] -pub fn get_accounts(state: State) -> Result, String> { - println!("Fetching active accounts..."); // Log message - let mut conn = state.conn.lock().unwrap(); - let service = account_service::AccountService::new(); +pub async fn get_accounts(state: State<'_, AppState>) -> Result, String> { + println!("Fetching active accounts..."); + let base_currency = state.base_currency.read().unwrap().clone(); + let service = AccountService::new((*state.pool).clone(), base_currency); service - .get_accounts(&mut conn) + .get_accounts() .map_err(|e| format!("Failed to load accounts: {}", e)) } #[tauri::command] -pub fn create_account(account: NewAccount, state: State) -> Result { - println!("Adding new account..."); // Log message - let mut conn = state.conn.lock().unwrap(); - let service = account_service::AccountService::new(); +pub async fn create_account( + account: NewAccount, + state: State<'_, AppState>, +) -> Result { + println!("Adding new account..."); + let base_currency = state.base_currency.read().unwrap().clone(); + let service = AccountService::new((*state.pool).clone(), base_currency); service - .create_account(&mut conn, account) + .create_account(account) + .await .map_err(|e| format!("Failed to add new account: {}", e)) } #[tauri::command] -pub fn update_account(account: AccountUpdate, state: State) -> Result { - println!("Updating account..."); // Log message - let mut conn = state.conn.lock().unwrap(); - let service = account_service::AccountService::new(); +pub async fn update_account( + account: AccountUpdate, + state: State<'_, AppState>, +) -> Result { + println!("Updating account..."); + let base_currency = state.base_currency.read().unwrap().clone(); + let service = AccountService::new((*state.pool).clone(), base_currency); service - .update_account(&mut conn, account) + .update_account(account) .map_err(|e| format!("Failed to update account: {}", e)) } #[tauri::command] -pub fn delete_account(account_id: String, state: State) -> Result { - println!("Deleting account..."); // Log message - let mut conn = state.conn.lock().unwrap(); - let service = account_service::AccountService::new(); +pub async fn delete_account( + account_id: String, + state: State<'_, AppState>, +) -> Result { + println!("Deleting account..."); + let base_currency = state.base_currency.read().unwrap().clone(); + let service = AccountService::new((*state.pool).clone(), base_currency); service - .delete_account(&mut conn, account_id) + .delete_account(account_id) .map_err(|e| format!("Failed to delete account: {}", e)) } diff --git a/src-tauri/src/commands/activity.rs b/src-tauri/src/commands/activity.rs index 80db4ac..5084c58 100644 --- a/src-tauri/src/commands/activity.rs +++ b/src-tauri/src/commands/activity.rs @@ -5,24 +5,22 @@ use crate::models::{ use crate::AppState; use tauri::State; - #[tauri::command] -pub fn search_activities( +pub async fn search_activities( page: i64, // Page number, 1-based page_size: i64, // Number of items per page account_id_filter: Option>, // Optional account_id filter activity_type_filter: Option>, // Optional activity_type filter asset_id_keyword: Option, // Optional asset_id keyword for search sort: Option, - state: State, + state: State<'_, AppState>, ) -> Result { println!("Search activities... {}, {}", page, page_size); - let mut conn = state.conn.lock().unwrap(); - let service = activity_service::ActivityService::new(); + let base_currency = state.base_currency.read().unwrap().clone(); + let service = activity_service::ActivityService::new((*state.pool).clone(), base_currency); service .search_activities( - &mut conn, page, page_size, account_id_filter, @@ -30,79 +28,77 @@ pub fn search_activities( asset_id_keyword, sort, ) - .map_err(|e| format!("Seach activities: {}", e)) + .map_err(|e| format!("Search activities: {}", e)) } #[tauri::command] -pub fn create_activity(activity: NewActivity, state: State) -> Result { +pub async fn create_activity( + activity: NewActivity, + state: State<'_, AppState>, +) -> Result { println!("Adding new activity..."); + let base_currency = state.base_currency.read().unwrap().clone(); + let service = activity_service::ActivityService::new((*state.pool).clone(), base_currency); + service + .create_activity(activity) + .await + .map_err(|e| format!("Failed to add new activity: {}", e)) +} - let result = tauri::async_runtime::block_on(async { - let mut conn = state.conn.lock().unwrap(); - let service = activity_service::ActivityService::new(); - service.create_activity(&mut conn, activity).await - }); - - result.map_err(|e| format!("Failed to add new activity: {}", e)) +#[tauri::command] +pub async fn update_activity( + activity: ActivityUpdate, + state: State<'_, AppState>, +) -> Result { + println!("Updating activity..."); + let base_currency = state.base_currency.read().unwrap().clone(); + let service = activity_service::ActivityService::new((*state.pool).clone(), base_currency); + service + .update_activity(activity) + .await + .map_err(|e| format!("Failed to update activity: {}", e)) } #[tauri::command] -pub fn check_activities_import( +pub async fn check_activities_import( account_id: String, file_path: String, - state: State, + state: State<'_, AppState>, ) -> Result, String> { println!( "Checking activities import...: {}, {}", account_id, file_path ); - - let result = tauri::async_runtime::block_on(async { - let mut conn = state.conn.lock().unwrap(); - let service = activity_service::ActivityService::new(); - service - .check_activities_import(&mut conn, account_id, file_path) - .await - }); - - result.map_err(|e| e.to_string()) + let base_currency = state.base_currency.read().unwrap().clone(); + let service = activity_service::ActivityService::new((*state.pool).clone(), base_currency); + service + .check_activities_import(account_id, file_path) + .await + .map_err(|e| e.to_string()) } #[tauri::command] -pub fn create_activities( +pub async fn create_activities( activities: Vec, - state: State, + state: State<'_, AppState>, ) -> Result { - // Return a Result with the count or an error message println!("Importing activities..."); - let mut conn = state.conn.lock().unwrap(); - let service = activity_service::ActivityService::new(); + let base_currency = state.base_currency.read().unwrap().clone(); + let service = activity_service::ActivityService::new((*state.pool).clone(), base_currency); service - .create_activities(&mut conn, activities) + .create_activities(activities) .map_err(|err| format!("Failed to import activities: {}", err)) - .map(|count| count) // You can directly return the count here } #[tauri::command] -pub fn update_activity( - activity: ActivityUpdate, - state: State, +pub async fn delete_activity( + activity_id: String, + state: State<'_, AppState>, ) -> Result { - println!("Updating activity..."); - let mut conn = state.conn.lock().unwrap(); - let service = activity_service::ActivityService::new(); - service - .update_activity(&mut conn, activity) - .map_err(|e| format!("Failed to update activity: {}", e)) -} - - -#[tauri::command] -pub fn delete_activity(activity_id: String, state: State) -> Result { - println!("Deleting activity..."); - let mut conn = state.conn.lock().unwrap(); - let service = activity_service::ActivityService::new(); + println!("Deleting activity..."); + let base_currency = state.base_currency.read().unwrap().clone(); + let service = activity_service::ActivityService::new((*state.pool).clone(), base_currency); service - .delete_activity(&mut conn, activity_id) + .delete_activity(activity_id) .map_err(|e| format!("Failed to delete activity: {}", e)) } diff --git a/src-tauri/src/commands/asset.rs b/src-tauri/src/commands/asset.rs deleted file mode 100644 index fe9e522..0000000 --- a/src-tauri/src/commands/asset.rs +++ /dev/null @@ -1,40 +0,0 @@ -use crate::asset::asset_service; -use crate::db; -use crate::models::{AssetProfile, QuoteSummary}; -use crate::AppState; -use tauri::State; - -#[tauri::command] -pub async fn search_ticker(query: String) -> Result, String> { - println!("Searching for ticker symbol: {}", query); - let service = asset_service::AssetService::new(); - - service - .search_ticker(&query) - .await - .map_err(|e| format!("Failed to search ticker: {}", e)) -} - -#[tauri::command] -pub fn get_asset_data(asset_id: String, state: State) -> Result { - let mut conn = state.conn.lock().unwrap(); - let service = asset_service::AssetService::new(); - service - .get_asset_data(&mut conn, &asset_id) - .map_err(|e| e.to_string()) -} - -#[tauri::command] -pub async fn synch_quotes(state: State<'_, AppState>) -> Result<(), String> { - println!("Synching quotes history"); - let service = asset_service::AssetService::new(); - - // Get the database path from the AppState - let db_path = state.db_path.clone(); - - println!("Sync B Path: {}", db_path); - // Create a new connection - let mut new_conn = db::establish_connection(&db_path); - - service.initialize_and_sync_quotes(&mut new_conn).await -} diff --git a/src-tauri/src/commands/goal.rs b/src-tauri/src/commands/goal.rs index 920e070..f49ce3e 100644 --- a/src-tauri/src/commands/goal.rs +++ b/src-tauri/src/commands/goal.rs @@ -1,12 +1,24 @@ use crate::goal::goal_service; use crate::models::{Goal, GoalsAllocation, NewGoal}; use crate::AppState; +use diesel::r2d2::ConnectionManager; +use diesel::SqliteConnection; use tauri::State; +fn get_connection( + state: &State, +) -> Result>, String> { + state + .pool + .clone() + .get() + .map_err(|e| format!("Failed to get database connection: {}", e)) +} + #[tauri::command] -pub fn get_goals(state: State) -> Result, String> { - println!("Fetching active goals..."); // Log message - let mut conn = state.conn.lock().unwrap(); +pub async fn get_goals(state: State<'_, AppState>) -> Result, String> { + println!("Fetching active goals..."); + let mut conn = get_connection(&state)?; let service = goal_service::GoalService::new(); service .get_goals(&mut conn) @@ -14,9 +26,9 @@ pub fn get_goals(state: State) -> Result, String> { } #[tauri::command] -pub fn create_goal(goal: NewGoal, state: State) -> Result { - println!("Adding new goal..."); // Log message - let mut conn = state.conn.lock().unwrap(); +pub async fn create_goal(goal: NewGoal, state: State<'_, AppState>) -> Result { + println!("Adding new goal..."); + let mut conn = get_connection(&state)?; let service = goal_service::GoalService::new(); service .create_goal(&mut conn, goal) @@ -24,9 +36,9 @@ pub fn create_goal(goal: NewGoal, state: State) -> Result) -> Result { - println!("Updating goal..."); // Log message - let mut conn = state.conn.lock().unwrap(); +pub async fn update_goal(goal: Goal, state: State<'_, AppState>) -> Result { + println!("Updating goal..."); + let mut conn = get_connection(&state)?; let service = goal_service::GoalService::new(); service .update_goal(&mut conn, goal) @@ -34,9 +46,9 @@ pub fn update_goal(goal: Goal, state: State) -> Result { } #[tauri::command] -pub fn delete_goal(goal_id: String, state: State) -> Result { - println!("Deleting goal..."); // Log message - let mut conn = state.conn.lock().unwrap(); +pub async fn delete_goal(goal_id: String, state: State<'_, AppState>) -> Result { + println!("Deleting goal..."); + let mut conn = get_connection(&state)?; let service = goal_service::GoalService::new(); service .delete_goal(&mut conn, goal_id) @@ -44,12 +56,12 @@ pub fn delete_goal(goal_id: String, state: State) -> Result, - state: State, + state: State<'_, AppState>, ) -> Result { - print!("Get goals allocations..."); - let mut conn = state.conn.lock().unwrap(); + println!("Updating goal allocations..."); + let mut conn = get_connection(&state)?; let service = goal_service::GoalService::new(); service .upsert_goal_allocations(&mut conn, allocations) @@ -57,9 +69,11 @@ pub fn update_goal_allocations( } #[tauri::command] -pub fn load_goals_allocations(state: State) -> Result, String> { - print!("Upserting goal allocations..."); - let mut conn = state.conn.lock().unwrap(); +pub async fn load_goals_allocations( + state: State<'_, AppState>, +) -> Result, String> { + println!("Loading goal allocations..."); + let mut conn = get_connection(&state)?; let service = goal_service::GoalService::new(); service .load_goals_allocations(&mut conn) diff --git a/src-tauri/src/commands/market_data.rs b/src-tauri/src/commands/market_data.rs new file mode 100644 index 0000000..ab859cb --- /dev/null +++ b/src-tauri/src/commands/market_data.rs @@ -0,0 +1,38 @@ +use crate::asset::asset_service::AssetService; +use crate::market_data::market_data_service::MarketDataService; + +use crate::models::{AssetProfile, QuoteSummary}; +use crate::AppState; +use tauri::State; + +#[tauri::command] +pub async fn search_symbol( + query: String, + state: State<'_, AppState>, +) -> Result, String> { + println!("Searching for ticker symbol: {}", query); + let service = MarketDataService::new((*state.pool).clone()).await; + service + .search_symbol(&query) + .await + .map_err(|e| format!("Failed to search ticker: {}", e)) +} + +#[tauri::command] +pub async fn get_asset_data( + asset_id: String, + state: State<'_, AppState>, +) -> Result { + let service = AssetService::new((*state.pool).clone()).await; + service.get_asset_data(&asset_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn synch_quotes(state: State<'_, AppState>) -> Result<(), String> { + println!("Synching quotes history"); + let service = MarketDataService::new((*state.pool).clone()).await; + service + .initialize_and_sync_quotes() + .await + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a14c7b2..c3a86aa 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,6 @@ pub mod account; pub mod activity; -pub mod asset; pub mod goal; +pub mod market_data; pub mod portfolio; pub mod settings; diff --git a/src-tauri/src/commands/portfolio.rs b/src-tauri/src/commands/portfolio.rs index a035ca2..77a23e8 100644 --- a/src-tauri/src/commands/portfolio.rs +++ b/src-tauri/src/commands/portfolio.rs @@ -1,49 +1,87 @@ -use crate::models::{FinancialHistory, Holding, IncomeSummary}; -use crate::portfolio::portfolio_service; +use crate::models::{AccountSummary, HistorySummary, Holding, IncomeSummary, PortfolioHistory}; +use crate::portfolio::portfolio_service::PortfolioService; use crate::AppState; -#[tauri::command] -pub async fn get_historical( - state: tauri::State<'_, AppState>, -) -> Result, String> { - println!("Fetching portfolio historical..."); +use tauri::State; - let mut conn = state.conn.lock().unwrap(); +async fn create_portfolio_service(state: &State<'_, AppState>) -> Result { + let base_currency = state.base_currency.read().unwrap().clone(); + PortfolioService::new((*state.pool).clone(), base_currency) + .await + .map_err(|e| format!("Failed to create PortfolioService: {}", e)) +} - let service = portfolio_service::PortfolioService::new(&mut *conn) - .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; +#[tauri::command] +pub async fn calculate_historical_data( + state: State<'_, AppState>, + account_ids: Option>, + force_full_calculation: bool, +) -> Result, String> { + println!("Calculate portfolio historical..."); + let service = create_portfolio_service(&state).await?; service - .calculate_historical_portfolio_values(&mut *conn) - .map_err(|e| format!("Failed to fetch activities: {}", e)) + .calculate_historical_data(account_ids, force_full_calculation) + .await + .map_err(|e| e.to_string()) } #[tauri::command] -pub async fn compute_holdings(state: tauri::State<'_, AppState>) -> Result, String> { +pub async fn compute_holdings(state: State<'_, AppState>) -> Result, String> { println!("Compute holdings..."); + let service = create_portfolio_service(&state).await?; - let mut conn = state.conn.lock().unwrap(); + service + .compute_holdings() + .await + .map_err(|e| e.to_string()) + .map(|vec| Ok(vec))? +} - let service = portfolio_service::PortfolioService::new(&mut *conn) - .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; +#[tauri::command] +pub async fn get_account_history( + state: State<'_, AppState>, + account_id: String, +) -> Result, String> { + println!("Fetching account history for account ID: {}", account_id); + let service = create_portfolio_service(&state).await?; service - .compute_holdings(&mut *conn) - .map_err(|e| format!("Failed to fetch activities: {}", e)) + .get_account_history(&account_id) + .map_err(|e| format!("Failed to fetch account history: {}", e)) } #[tauri::command] -pub async fn get_income_summary( - state: tauri::State<'_, AppState>, -) -> Result { - println!("Fetching income summary..."); +pub async fn get_accounts_summary( + state: State<'_, AppState>, +) -> Result, String> { + println!("Fetching active accounts performance..."); + let service = create_portfolio_service(&state).await?; + + service + .get_accounts_summary() + .map_err(|e| format!("Failed to fetch active accounts performance: {}", e)) +} + +#[tauri::command] +pub async fn recalculate_portfolio( + state: State<'_, AppState>, +) -> Result, String> { + println!("Recalculating portfolio..."); + let service = create_portfolio_service(&state).await?; - let mut conn = state.conn.lock().unwrap(); + service + .update_portfolio() + .await + .map_err(|e| format!("Failed to recalculate portfolio: {}", e)) +} - let service = portfolio_service::PortfolioService::new(&mut *conn) - .map_err(|e| format!("Failed to create PortfolioService: {}", e))?; +#[tauri::command] +pub async fn get_income_summary(state: State<'_, AppState>) -> Result { + println!("Fetching income summary..."); + let service = create_portfolio_service(&state).await?; service - .get_income_summary(&mut *conn) + .get_income_summary() .map_err(|e| format!("Failed to fetch income summary: {}", e)) } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index f6d74ca..e8541aa 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -1,12 +1,25 @@ -use crate::models::{NewSettings, Settings}; +use crate::fx::fx_service::CurrencyExchangeService; +use crate::models::{ExchangeRate, Settings}; use crate::settings::settings_service; use crate::AppState; +use diesel::r2d2::ConnectionManager; +use diesel::SqliteConnection; use tauri::State; +fn get_connection( + state: &State, +) -> Result>, String> { + state + .pool + .clone() + .get() + .map_err(|e| format!("Failed to get database connection: {}", e)) +} + #[tauri::command] -pub fn get_settings(state: State) -> Result { +pub async fn get_settings(state: State<'_, AppState>) -> Result { println!("Fetching active settings..."); - let mut conn = state.conn.lock().unwrap(); + let mut conn = get_connection(&state)?; let service = settings_service::SettingsService::new(); service .get_settings(&mut conn) @@ -14,27 +27,41 @@ pub fn get_settings(state: State) -> Result { } #[tauri::command] -pub fn update_settings(settings: NewSettings, state: State) -> Result { +pub async fn update_settings( + settings: Settings, + state: State<'_, AppState>, +) -> Result { println!("Updating settings..."); // Log message - let mut conn = state.conn.lock().unwrap(); + let mut conn = get_connection(&state)?; let service = settings_service::SettingsService::new(); service .update_settings(&mut conn, &settings) .map_err(|e| format!("Failed to update settings: {}", e))?; + // Update the app state + let mut base_currency = state.base_currency.write().map_err(|e| e.to_string())?; + *base_currency = settings.base_currency; service .get_settings(&mut conn) .map_err(|e| format!("Failed to load settings: {}", e)) } #[tauri::command] -pub fn update_currency(currency: String, state: State) -> Result { - println!("Updating base currency..."); // Log message - let mut conn = state.conn.lock().unwrap(); - let service = settings_service::SettingsService::new(); - service - .update_base_currency(&mut conn, ¤cy) - .map_err(|e| format!("Failed to update settings: {}", e))?; - service - .get_settings(&mut conn) - .map_err(|e| format!("Failed to load settings: {}", e)) +pub async fn update_exchange_rate( + rate: ExchangeRate, + state: State<'_, AppState>, +) -> Result { + println!("Updating exchange rate..."); + let fx_service = CurrencyExchangeService::new((*state.pool).clone()); + fx_service + .update_exchange_rate(&rate) + .map_err(|e| format!("Failed to update exchange rate: {}", e)) +} + +#[tauri::command] +pub async fn get_exchange_rates(state: State<'_, AppState>) -> Result, String> { + println!("Fetching exchange rates..."); + let fx_service = CurrencyExchangeService::new((*state.pool).clone()); + fx_service + .get_exchange_rates() + .map_err(|e| format!("Failed to load exchange rates: {}", e)) } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2793149..fbfdd64 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -8,101 +8,85 @@ use commands::activity::{ check_activities_import, create_activities, create_activity, delete_activity, search_activities, update_activity, }; -use commands::asset::{get_asset_data, search_ticker, synch_quotes}; use commands::goal::{ create_goal, delete_goal, get_goals, load_goals_allocations, update_goal, update_goal_allocations, }; -use commands::portfolio::{compute_holdings, get_historical, get_income_summary}; -use commands::settings::{get_settings, update_currency, update_settings}; + +use commands::market_data::{get_asset_data, search_symbol, synch_quotes}; +use commands::portfolio::{ + calculate_historical_data, compute_holdings, get_account_history, get_accounts_summary, + get_income_summary, recalculate_portfolio, +}; +use commands::settings::{get_exchange_rates, get_settings, update_exchange_rate, update_settings}; use wealthfolio_core::db; +use wealthfolio_core::models; use wealthfolio_core::account; use wealthfolio_core::activity; use wealthfolio_core::asset; use wealthfolio_core::goal; -use wealthfolio_core::models; +use wealthfolio_core::market_data; + +use wealthfolio_core::fx; use wealthfolio_core::portfolio; use wealthfolio_core::settings; -use wealthfolio_core::app_state; - -use app_state::AppState; -use asset::asset_service; - use dotenvy::dotenv; use std::env; use std::path::Path; -use std::sync::Mutex; +use std::sync::{Arc, RwLock}; +use diesel::r2d2::{self, ConnectionManager}; +use diesel::SqliteConnection; use tauri::async_runtime::spawn; use tauri::{api::dialog, CustomMenuItem, Manager, Menu, Submenu}; -// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command +type DbPool = r2d2::Pool>; + +// AppState +struct AppState { + pool: Arc, + base_currency: Arc>, +} fn main() { dotenv().ok(); // Load environment variables from .env file if available let context = tauri::generate_context!(); - // Customize the menu - let report_issue_menu_item = CustomMenuItem::new("report_issue".to_string(), "Report Issue"); - let menu = tauri::Menu::os_default(&context.package_info().name).add_submenu(Submenu::new( - "Help", - Menu::new().add_item(report_issue_menu_item), - )); + let menu = create_menu(&context); - // Clone the AppHandle let app = tauri::Builder::default() .menu(menu) - .on_menu_event(|event| match event.menu_item_id() { - "report_issue" => { - dialog::message( - Some(event.window()), - "Contact Support", - "If you encounter any issues, please email us at wealthfolio@teymz.com", - ); - } - - _ => {} - }) + .on_menu_event(handle_menu_event) .setup(|app| { let app_handle = app.handle(); let db_path = get_db_path(&app_handle); db::init(&db_path); - // Initialize state and connection + // Create connection pool + let manager = ConnectionManager::::new(&db_path); + let pool = r2d2::Pool::builder() + .build(manager) + .expect("Failed to create database connection pool"); + let pool = Arc::new(pool); + + // Get initial base_currency from settings + let mut conn = pool.get().expect("Failed to get database connection"); + let settings_service = settings::SettingsService::new(); + let base_currency = settings_service + .get_base_currency(&mut conn) + .unwrap_or_else(|_| "USD".to_string()); + + // Initialize state let state = AppState { - conn: Mutex::new(db::establish_connection(&db_path)), - db_path: db_path.to_string(), + pool: pool.clone(), + base_currency: Arc::new(RwLock::new(base_currency)), }; app.manage(state); - spawn(async move { - let asset_service = asset_service::AssetService::new(); - app_handle - .emit_all("QUOTES_SYNC_START", ()) - .expect("Failed to emit event"); - - let mut new_conn = db::establish_connection(&db_path); - let result = asset_service - .initialize_and_sync_quotes(&mut new_conn) - .await; - - match result { - Ok(_) => { - app_handle - .emit_all("QUOTES_SYNC_COMPLETE", ()) - .expect("Failed to emit event"); - } - Err(e) => { - eprintln!("Failed to sync history quotes: {}", e); - app_handle - .emit_all("QUOTES_SYNC_ERROR", ()) - .expect("Failed to emit event"); - } - } - }); + spawn_quote_sync(app_handle, pool); Ok(()) }) @@ -115,16 +99,17 @@ fn main() { create_activity, update_activity, delete_activity, - search_ticker, + search_symbol, check_activities_import, create_activities, - get_historical, + calculate_historical_data, compute_holdings, get_asset_data, synch_quotes, get_settings, update_settings, - update_currency, + get_exchange_rates, + update_exchange_rate, create_goal, update_goal, delete_goal, @@ -132,6 +117,9 @@ fn main() { update_goal_allocations, load_goals_allocations, get_income_summary, + get_account_history, + get_accounts_summary, + recalculate_portfolio, ]) .build(context) .expect("error while running wealthfolio application"); @@ -141,6 +129,55 @@ fn main() { }); } +fn create_menu(context: &tauri::Context) -> Menu { + let report_issue_menu_item = CustomMenuItem::new("report_issue".to_string(), "Report Issue"); + tauri::Menu::os_default(&context.package_info().name).add_submenu(Submenu::new( + "Help", + Menu::new().add_item(report_issue_menu_item), + )) +} + +fn handle_menu_event(event: tauri::WindowMenuEvent) { + if event.menu_item_id() == "report_issue" { + dialog::message( + Some(&event.window()), + "Contact Support", + "If you encounter any issues, please email us at wealthfolio@teymz.com", + ); + } +} + +fn spawn_quote_sync(app_handle: tauri::AppHandle, pool: Arc) { + spawn(async move { + let base_currency = { + let state = app_handle.state::(); + let currency = state.base_currency.read().unwrap().clone(); + currency + }; + let portfolio_service = portfolio::PortfolioService::new((*pool).clone(), base_currency) + .await + .expect("Failed to create PortfolioService"); + + app_handle + .emit_all("PORTFOLIO_UPDATE_START", ()) + .expect("Failed to emit event"); + + match portfolio_service.update_portfolio().await { + Ok(_) => { + app_handle + .emit_all("PORTFOLIO_UPDATE_COMPLETE", ()) + .expect("Failed to emit event"); + } + Err(e) => { + eprintln!("Failed to update portfolio: {}", e); + app_handle + .emit_all("PORTFOLIO_UPDATE_ERROR", ()) + .expect("Failed to emit event"); + } + } + }); +} + fn get_db_path(app_handle: &tauri::AppHandle) -> String { // Try to get the database URL from the environment variable match env::var("DATABASE_URL") { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 3666542..3ac741e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "Wealthfolio", - "version": "1.0.13" + "version": "1.0.14" }, "tauri": { "allowlist": { diff --git a/src/App.tsx b/src/App.tsx index e50a9ca..2ed66ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ function App() { queries: { refetchOnWindowFocus: false, staleTime: 5 * 60 * 1000, + retry: 2, }, }, }), diff --git a/src/adapters/tauri.ts b/src/adapters/tauri.ts index 0bffbf6..39798a1 100644 --- a/src/adapters/tauri.ts +++ b/src/adapters/tauri.ts @@ -1,38 +1,42 @@ +import { invoke } from '@tauri-apps/api'; +import { open } from '@tauri-apps/api/dialog'; +import { listen } from '@tauri-apps/api/event'; import type { EventCallback, UnlistenFn } from '@tauri-apps/api/event'; export type { EventCallback, UnlistenFn }; export const invokeTauri = async (command: string, payload?: Record) => { - const invoke = await import('@tauri-apps/api').then((mod) => mod.invoke); return await invoke(command, payload); -} +}; export const openCsvFileDialogTauri = async (): Promise => { - const open = await import('@tauri-apps/api/dialog').then((mod) => mod.open); return open({ filters: [{ name: 'CSV', extensions: ['csv'] }] }); -} +}; -export const listenFileDropHoverTauri = async (handler: EventCallback): Promise => { - const { listen } = await import('@tauri-apps/api/event'); +export const listenFileDropHoverTauri = async ( + handler: EventCallback, +): Promise => { return listen('tauri://file-drop-hover', handler); -} +}; export const listenFileDropTauri = async (handler: EventCallback): Promise => { - const { listen } = await import('@tauri-apps/api/event'); return listen('tauri://file-drop', handler); -} +}; -export const listenFileDropCancelledTauri = async (handler: EventCallback): Promise => { - const { listen } = await import('@tauri-apps/api/event'); +export const listenFileDropCancelledTauri = async ( + handler: EventCallback, +): Promise => { return listen('tauri://file-drop-cancelled', handler); -} - -export const listenQuotesSyncStartTauri = async (handler: EventCallback): Promise => { - const { listen } = await import('@tauri-apps/api/event'); - return listen('QUOTES_SYNC_START', handler); -} - -export const listenQuotesSyncCompleteTauri = async (handler: EventCallback): Promise => { - const { listen } = await import('@tauri-apps/api/event'); - return listen('QUOTES_SYNC_COMPLETE', handler); -} +}; + +export const listenQuotesSyncStartTauri = async ( + handler: EventCallback, +): Promise => { + return listen('PORTFOLIO_UPDATE_START', handler); +}; + +export const listenQuotesSyncCompleteTauri = async ( + handler: EventCallback, +): Promise => { + return listen('PORTFOLIO_UPDATE_COMPLETE', handler); +}; diff --git a/src/commands/account.ts b/src/commands/account.ts index 63ce4ee..c24b649 100644 --- a/src/commands/account.ts +++ b/src/commands/account.ts @@ -24,7 +24,7 @@ export const createAccount = async (account: NewAccount): Promise => { try { switch (getRunEnv()) { case RUN_ENV.DESKTOP: - return invokeTauri('create_account', { account }); + return invokeTauri('create_account', { account: account }); default: throw new Error(`Unsupported`); } diff --git a/src/commands/activity.ts b/src/commands/activity.ts index ae1385b..bef8066 100644 --- a/src/commands/activity.ts +++ b/src/commands/activity.ts @@ -88,12 +88,11 @@ export const updateActivity = async (activity: NewActivity): Promise = }; // deleteActivity -export const deleteActivity = async (activityId: string): Promise => { +export const deleteActivity = async (activityId: string): Promise => { try { switch (getRunEnv()) { case RUN_ENV.DESKTOP: - await invokeTauri('delete_activity', { activityId }); - return; + return invokeTauri('delete_activity', { activityId }); default: throw new Error(`Unsupported`); } diff --git a/src/commands/exchange-rates.ts b/src/commands/exchange-rates.ts new file mode 100644 index 0000000..33890e1 --- /dev/null +++ b/src/commands/exchange-rates.ts @@ -0,0 +1,30 @@ +import type { ExchangeRate } from '@/lib/types'; +import { getRunEnv, RUN_ENV, invokeTauri } from '@/adapters'; + +export const getExchangeRates = async (): Promise => { + try { + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('get_exchange_rates'); + default: + throw new Error('Unsupported environment'); + } + } catch (error) { + console.error('Error fetching exchange rates:', error); + return []; + } +}; + +export const updateExchangeRate = async (updatedRate: ExchangeRate): Promise => { + try { + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('update_exchange_rate', { rate: updatedRate }); + default: + throw new Error('Unsupported environment'); + } + } catch (error) { + console.error('Error updating exchange rate:', error); + throw error; + } +}; diff --git a/src/commands/symbol.ts b/src/commands/market-data.ts similarity index 95% rename from src/commands/symbol.ts rename to src/commands/market-data.ts index 162af98..5d47dcd 100644 --- a/src/commands/symbol.ts +++ b/src/commands/market-data.ts @@ -5,7 +5,7 @@ export const searchTicker = async (query: string): Promise => { try { switch (getRunEnv()) { case RUN_ENV.DESKTOP: - return invokeTauri('search_ticker', { query }); + return invokeTauri('search_symbol', { query }); default: throw new Error(`Unsupported`); } diff --git a/src/commands/portfolio.ts b/src/commands/portfolio.ts index 43e3061..8417afb 100644 --- a/src/commands/portfolio.ts +++ b/src/commands/portfolio.ts @@ -1,16 +1,25 @@ -import { FinancialHistory, Holding, IncomeSummary } from '@/lib/types'; import { getRunEnv, RUN_ENV, invokeTauri } from '@/adapters'; +import { + Holding, + IncomeSummary, + HistorySummary, + PortfolioHistory, + AccountSummary, +} from '@/lib/types'; -export const getHistorical = async (): Promise => { +export const calculate_historical_data = async (params: { + accountIds?: string[]; + forceFullCalculation: boolean; +}): Promise => { try { switch (getRunEnv()) { case RUN_ENV.DESKTOP: - return invokeTauri('get_historical'); + return invokeTauri('calculate_historical_data', params); default: throw new Error(`Unsupported`); } } catch (error) { - console.error('Error fetching accounts:', error); + console.error('Error calculating historical data:', error); throw error; } }; @@ -42,3 +51,45 @@ export const getIncomeSummary = async (): Promise => { throw error; } }; + +export const getAccountHistory = async (accountId: string): Promise => { + try { + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('get_account_history', { accountId }); + default: + throw new Error(`Unsupported`); + } + } catch (error) { + console.error('Error fetching account history:', error); + throw error; + } +}; + +export const getAccountsSummary = async (): Promise => { + try { + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('get_accounts_summary'); + default: + throw new Error(`Unsupported`); + } + } catch (error) { + console.error('Error fetching active accounts summary:', error); + throw error; + } +}; + +export const recalculatePortfolio = async (): Promise => { + try { + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return invokeTauri('recalculate_portfolio'); + default: + throw new Error(`Unsupported`); + } + } catch (error) { + console.error('Error recalculating portfolio:', error); + throw error; + } +}; diff --git a/src/commands/quote-listener.ts b/src/commands/quote-listener.ts index b4b4ab6..03ac53d 100644 --- a/src/commands/quote-listener.ts +++ b/src/commands/quote-listener.ts @@ -1,5 +1,10 @@ import type { EventCallback, UnlistenFn } from '@/adapters'; -import { getRunEnv, RUN_ENV, listenQuotesSyncStartTauri, listenQuotesSyncCompleteTauri } from "@/adapters"; +import { + getRunEnv, + RUN_ENV, + listenQuotesSyncStartTauri, + listenQuotesSyncCompleteTauri, +} from '@/adapters'; // listenQuotesSyncStart export const listenQuotesSyncStart = async (handler: EventCallback): Promise => { @@ -11,13 +16,15 @@ export const listenQuotesSyncStart = async (handler: EventCallback): Promi throw new Error(`Unsupported`); } } catch (error) { - console.error('Error listen QUOTES_SYNC_START:', error); + console.error('Error listen PORTFOLIO_UPDATE_START:', error); throw error; } }; // listenQuotesSyncComplete -export const listenQuotesSyncComplete = async (handler: EventCallback): Promise => { +export const listenQuotesSyncComplete = async ( + handler: EventCallback, +): Promise => { try { switch (getRunEnv()) { case RUN_ENV.DESKTOP: @@ -26,7 +33,7 @@ export const listenQuotesSyncComplete = async (handler: EventCallback): Pr throw new Error(`Unsupported`); } } catch (error) { - console.error('Error listen QUOTES_SYNC_COMPLETE:', error); + console.error('Error listen PORTFOLIO_UPDATE_COMPLETE:', error); throw error; } }; diff --git a/src/commands/setting.ts b/src/commands/settings.ts similarity index 96% rename from src/commands/setting.ts rename to src/commands/settings.ts index 572aa55..249d0d7 100644 --- a/src/commands/setting.ts +++ b/src/commands/settings.ts @@ -1,7 +1,6 @@ import { Settings } from '@/lib/types'; import { getRunEnv, RUN_ENV, invokeTauri } from '@/adapters'; -// getSettings export const getSettings = async (): Promise => { try { switch (getRunEnv()) { @@ -16,7 +15,6 @@ export const getSettings = async (): Promise => { } }; -// saveSettings export const saveSettings = async (settings: Settings): Promise => { try { switch (getRunEnv()) { diff --git a/src/components/empty-placeholder.tsx b/src/components/empty-placeholder.tsx index 45da9fc..aefc958 100644 --- a/src/components/empty-placeholder.tsx +++ b/src/components/empty-placeholder.tsx @@ -57,7 +57,7 @@ EmptyPlaceholder.Description = function EmptyPlaceholderDescription({ ...props }: EmptyPlacholderDescriptionProps) { return ( -

0 ? 'text-green-500' : 'text-red-500', + value === 0 ? 'text-foreground' : value > 0 ? 'text-success' : 'text-red-400', )} > {formatAmount(value, currency, displayCurrency)} diff --git a/src/components/gain-percent.tsx b/src/components/gain-percent.tsx index 554ae82..a93af13 100644 --- a/src/components/gain-percent.tsx +++ b/src/components/gain-percent.tsx @@ -10,9 +10,9 @@ export function GainPercent({ value, className, ...props }: GainPercentProps) { return (

0 ? 'text-green-500' : 'text-red-500', + value === 0 ? 'text-foreground' : value > 0 ? 'text-success' : 'text-red-400', )} {...props} > diff --git a/src/components/history-chart.tsx b/src/components/history-chart.tsx index eac7453..37dbe30 100644 --- a/src/components/history-chart.tsx +++ b/src/components/history-chart.tsx @@ -85,9 +85,9 @@ export function HistoryChart({ {/* @ts-ignore */} } /> - {interval != 'ALL' && interval != '1Y' ? ( - - ) : undefined} + {interval !== 'ALL' && interval !== '1Y' && ( + + )} svg]:text-destructive', + 'border-destructive/50 text-destructive bg-destructive/20 dark:border-destructive [&>svg]:text-destructive', error: - 'border-red-500/50 text-red-700 dark:border-red-500 [&>svg]:text-red-700 bg-red-50 dark:bg-red-950 ', + 'border-destructive/50 text-destructive bg-destructive/20 dark:border-destructive [&>svg]:text-destructive', success: - 'border-green-500/50 text-green-700 dark:border-green-500 [&>svg]:text-green-700 bg-green-50 dark:bg-green-950 ', + 'success group border-success-background bg-success-background/40 text-success-foreground dark:bg-success', warning: - 'border-yellow-500/50 text-yellow-700 dark:border-yellow-500 [&>svg]:text-yellow-700 bg-yellow-50 dark:bg-yellow-950 ', + 'border-yellow-500/50 bg-yellow-50 text-yellow-800 dark:border-yellow-500 dark:bg-yellow-900/50 dark:text-yellow-300 [&>svg]:text-yellow-600 dark:text-yellow-400', }, }, defaultVariants: { diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index 7487fa3..36d4360 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -14,7 +14,8 @@ const badgeVariants = cva( destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', outline: 'text-foreground', - success: 'border-transparent bg-[#cbd492] hover:bg-green-100/80', + success: + 'border-transparent bg-success-background/40 text-success-foreground hover:bg-success/80 dark:bg-success', error: 'border-transparent bg-red-100 text-red-700 hover:bg-red-100/80', }, }, diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index a822477..e664fab 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -1,11 +1,11 @@ -import * as React from "react" -import * as ToastPrimitives from "@radix-ui/react-toast" -import { cva, type VariantProps } from "class-variance-authority" -import { X } from "lucide-react" +import * as React from 'react'; +import * as ToastPrimitives from '@radix-ui/react-toast'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { X } from 'lucide-react'; -import { cn } from "@/lib/utils" +import { cn } from '@/lib/utils'; -const ToastProvider = ToastPrimitives.Provider +const ToastProvider = ToastPrimitives.Provider; const ToastViewport = React.forwardRef< React.ElementRef, @@ -14,34 +14,35 @@ const ToastViewport = React.forwardRef< -)) -ToastViewport.displayName = ToastPrimitives.Viewport.displayName +)); +ToastViewport.displayName = ToastPrimitives.Viewport.displayName; const toastVariants = cva( - "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + 'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full', { variants: { variant: { - default: "border bg-background text-foreground", + default: 'border bg-background text-foreground', destructive: - "destructive group border-destructive bg-destructive text-destructive-foreground", + 'destructive group border-destructive bg-destructive text-destructive-foreground', + success: + 'success group border-success-background bg-success-background text-success-foreground', }, }, defaultVariants: { - variant: "default", + variant: 'default', }, - } -) + }, +); const Toast = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef & - VariantProps + React.ComponentPropsWithoutRef & VariantProps >(({ className, variant, ...props }, ref) => { return ( - ) -}) -Toast.displayName = ToastPrimitives.Root.displayName + ); +}); +Toast.displayName = ToastPrimitives.Root.displayName; const ToastAction = React.forwardRef< React.ElementRef, @@ -60,13 +61,13 @@ const ToastAction = React.forwardRef< -)) -ToastAction.displayName = ToastPrimitives.Action.displayName +)); +ToastAction.displayName = ToastPrimitives.Action.displayName; const ToastClose = React.forwardRef< React.ElementRef, @@ -75,28 +76,24 @@ const ToastClose = React.forwardRef< -)) -ToastClose.displayName = ToastPrimitives.Close.displayName +)); +ToastClose.displayName = ToastPrimitives.Close.displayName; const ToastTitle = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - -)) -ToastTitle.displayName = ToastPrimitives.Title.displayName + +)); +ToastTitle.displayName = ToastPrimitives.Title.displayName; const ToastDescription = React.forwardRef< React.ElementRef, @@ -104,15 +101,15 @@ const ToastDescription = React.forwardRef< >(({ className, ...props }, ref) => ( -)) -ToastDescription.displayName = ToastPrimitives.Description.displayName +)); +ToastDescription.displayName = ToastPrimitives.Description.displayName; -type ToastProps = React.ComponentPropsWithoutRef +type ToastProps = React.ComponentPropsWithoutRef; -type ToastActionElement = React.ReactElement +type ToastActionElement = React.ReactElement; export { type ToastProps, @@ -124,4 +121,4 @@ export { ToastDescription, ToastClose, ToastAction, -} +}; diff --git a/src/hooks/useCalculateHistory.ts b/src/hooks/useCalculateHistory.ts new file mode 100644 index 0000000..8f2c46b --- /dev/null +++ b/src/hooks/useCalculateHistory.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from '@/components/ui/use-toast'; +import { calculate_historical_data } from '@/commands/portfolio'; + +interface UseCalculateHistoryMutationOptions { + successTitle?: string; + errorTitle?: string; +} + +export function useCalculateHistoryMutation({ + successTitle = 'Portfolio data updated successfully.', + errorTitle = 'Failed to recalculate portfolio data.', +}: UseCalculateHistoryMutationOptions = {}) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: calculate_historical_data, + onSuccess: () => { + queryClient.invalidateQueries(); + toast({ + title: successTitle, + description: + 'Your portfolio data has been recalculated and updated with the latest information.', + variant: 'success', + }); + }, + onError: () => { + queryClient.invalidateQueries(); + toast({ + title: errorTitle, + description: 'Please try again or report an issue if the problem persists.', + variant: 'destructive', + }); + }, + }); +} diff --git a/src/lib/portfolio-helper.ts b/src/lib/portfolio-helper.ts index 585eefc..35728f4 100644 --- a/src/lib/portfolio-helper.ts +++ b/src/lib/portfolio-helper.ts @@ -1,13 +1,6 @@ // import type { Account, Asset } from '@/generated/client'; -import { - AccountTotal, - FinancialHistory, - Goal, - GoalAllocation, - GoalProgress, - Holding, -} from './types'; +import { AccountSummary, Goal, GoalAllocation, GoalProgress, Holding } from './types'; export function aggregateHoldingsBySymbol(holdings: Holding[]): Holding[] { const aggregated: Record = {}; @@ -54,43 +47,21 @@ export function aggregateHoldingsBySymbol(holdings: Holding[]): Holding[] { return result; } -export const formatAccountsData = ( - data: FinancialHistory[], - baseCurrency: String = 'USD', -): AccountTotal[] | undefined => { - return data - ?.filter((history) => history.account?.id !== 'TOTAL') - .map((history) => { - const todayValue = history.history[history.history.length - 1]; - return { - id: history.account?.id || '', - name: history.account?.name || '', - group: history.account?.group || '', - currency: history.account?.currency || '', - marketValue: todayValue?.marketValue || 0, - cashBalance: todayValue?.availableCash || 0, - totalGainAmount: todayValue?.totalGainValue || 0, - totalGainPercent: todayValue?.totalGainPercentage || 0, - totalValue: todayValue?.marketValue + todayValue?.availableCash, - totalValueConverted: - (todayValue?.marketValue + todayValue?.availableCash) * (todayValue?.exchangeRate || 1), - marketValueConverted: todayValue?.marketValue * (todayValue?.exchangeRate || 1) || 0, - cashBalanceConverted: todayValue?.availableCash * (todayValue?.exchangeRate || 1) || 0, - bookValueConverted: todayValue?.bookCost * (todayValue?.exchangeRate || 1) || 0, - baseCurrency: baseCurrency, - } as AccountTotal; - }); -}; - export function calculateGoalProgress( - accounts: AccountTotal[], + accounts: AccountSummary[], goals: Goal[], allocations: GoalAllocation[], ): GoalProgress[] { + // Extract base currency from the first account's performance, or default to 'USD' + const baseCurrency = accounts[0]?.performance?.baseCurrency || 'USD'; + // Create a map of accountId to marketValue for quick lookup const accountValueMap = new Map(); accounts.forEach((account) => { - accountValueMap.set(account.id, account.totalValueConverted); + accountValueMap.set( + account.account.id, + account?.performance?.totalValue * (account?.performance?.exchangeRate || 1), + ); }); // Sort goals by targetValue @@ -112,7 +83,7 @@ export function calculateGoalProgress( targetValue: goal.targetAmount, currentValue: totalAllocatedValue, progress: progress, - currency: accounts[0]?.baseCurrency || 'USD', + currency: baseCurrency, }; }); } diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts new file mode 100644 index 0000000..a0b3b8b --- /dev/null +++ b/src/lib/query-keys.ts @@ -0,0 +1,29 @@ +export const QueryKeys = { + // Account related keys + ACCOUNTS: 'accounts', + ACCOUNTS_SUMMARY: 'accounts_summary', + ACCOUNTS_HISTORY: 'accounts_history', + + // Activity related keys + ACTIVITY_DATA: 'activity-data', + + // Portfolio related keys + PORTFOLIO_HISTORY: 'portfolio_history', + HOLDINGS: 'holdings', + INCOME_SUMMARY: 'incomeSummary', + + // Goals related keys + GOALS: 'goals', + GOALS_ALLOCATIONS: 'goals_allocations', + + // Settings related keys + SETTINGS: 'settings', + EXCHANGE_RATES: 'exchangeRates', + + // New keys for exchange rates + EXCHANGE_RATE_SYMBOLS: 'exchange_rate_symbols', + QUOTE: 'quote', + + // Helper function to create account-specific keys + accountHistory: (id: string) => ['account_history', id], +} as const; diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 263a08a..7212ac6 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -35,7 +35,7 @@ export const newGoalSchema = z.object({ export const newActivitySchema = z.object({ id: z.string().uuid().optional(), accountId: z.string().min(1, { message: 'Please select an account.' }), - activityDate: z.date(), + activityDate: z.union([z.date(), z.string().datetime()]).optional(), currency: z.string().min(1, { message: 'Currency is required' }), fee: z.coerce .number({ diff --git a/src/lib/settings-provider.tsx b/src/lib/settings-provider.tsx index bb24876..7bfc426 100644 --- a/src/lib/settings-provider.tsx +++ b/src/lib/settings-provider.tsx @@ -1,39 +1,16 @@ import { createContext, useState, useEffect, ReactNode, useContext } from 'react'; import { Settings, SettingsContextType } from './types'; import { useSettings } from './useSettings'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { toast } from '@/components/ui/use-toast'; -import { saveSettings } from '@/commands/setting'; +import { useSettingsMutation } from './useSettingsMutation'; const SettingsContext = createContext(undefined); export function SettingsProvider({ children }: { children: ReactNode }) { - const queryClient = useQueryClient(); const { data, isLoading, isError } = useSettings(); const [settings, setSettings] = useState(null); const [accountsGrouped, setAccountsGrouped] = useState(true); - const updateMutation = useMutation({ - mutationFn: saveSettings, - onSuccess: (updatedSettings) => { - setSettings(updatedSettings); - applySettingsToDocument(updatedSettings); - queryClient.invalidateQueries({ queryKey: ['settings'] }); - queryClient.invalidateQueries({ queryKey: ['holdings'] }); - queryClient.invalidateQueries({ queryKey: ['portfolio_history'] }); - toast({ - title: 'Settings updated successfully.', - className: 'bg-green-500 text-white border-none', - }); - }, - onError: () => { - toast({ - title: 'Uh oh! Something went wrong.', - description: 'There was a problem updating your settings.', - className: 'bg-red-500 text-white border-none', - }); - }, - }); + const updateMutation = useSettingsMutation(setSettings, applySettingsToDocument, settings); const updateSettings = (newSettings: Settings) => { updateMutation.mutate(newSettings); diff --git a/src/lib/types.ts b/src/lib/types.ts index 8a3ceaa..e886b76 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -67,28 +67,6 @@ export type ActivitySearchResponse = { export type NewActivity = z.infer; -export interface AccountTotal { - id: string; - name: string; - group: string; - marketValue: number; - bookValue: number; - cashBalance: number; - cashBalanceConverted: number; - marketValueConverted: number; - totalValue: number; - totalValueConverted: number; - bookValueConverted: number; - totalGainPercent: number; - totalGainAmount: number; - totalGainAmountConverted: number; - dayGainPercent: number; - dayGainAmount: number; - dayGainAmountConverted: number; - currency: string; - baseCurrency: string; -} - export interface AssetProfile { id: string; isin: string | null; @@ -142,28 +120,6 @@ export interface Tag { activityId: string | null; } -export interface FinancialSnapshot { - date: string; - totalValue: number; //investment + cash - marketValue: number; - bookCost: number; - availableCash: number; - netDeposit: number; - currency: string; - baseCurrency: string; - totalGainValue: number; - totalGainPercentage: number; - dayGainPercentage: number; - dayGainValue: number; - allocationPercentage?: number; - exchangeRate?: number; -} - -export interface FinancialHistory { - account: Account; // This can be an account or the entire portfolio. - history: FinancialSnapshot[]; -} - export type ValidationResult = { status: 'success' } | { status: 'error'; errors: string[] }; export interface ActivityImport { @@ -267,7 +223,6 @@ export interface AssetData { } export interface Settings { - id: number; theme: string; font: string; baseCurrency: string; @@ -316,3 +271,45 @@ export interface IncomeSummary { } export type TimePeriod = '1D' | '1W' | '1M' | '3M' | '1Y' | 'ALL'; + +export interface HistorySummary { + id?: string; + startDate: string; + endDate: string; + entriesCount: number; +} + +export interface PortfolioHistory { + id: string; + accountId: string; + date: string; + totalValue: number; + marketValue: number; + bookCost: number; + availableCash: number; + netDeposit: number; + currency: string; + baseCurrency: string; + totalGainValue: number; + totalGainPercentage: number; + dayGainPercentage: number; + dayGainValue: number; + allocationPercentage: number | null; + exchangeRate: number | null; +} + +export interface AccountSummary { + account: Account; + performance: PortfolioHistory; +} + +export interface ExchangeRate { + id: string; + fromCurrency: string; + toCurrency: string; + fromCurrencyName?: string; + toCurrencyName?: string; + rate: number; + source: string; + isLoading?: boolean; +} diff --git a/src/lib/useSettings.ts b/src/lib/useSettings.ts index a5cf22c..26cae62 100644 --- a/src/lib/useSettings.ts +++ b/src/lib/useSettings.ts @@ -1,10 +1,11 @@ import { useQuery } from '@tanstack/react-query'; import { Settings } from './types'; -import { getSettings } from '@/commands/setting'; +import { getSettings } from '@/commands/settings'; +import { QueryKeys } from './query-keys'; export function useSettings() { return useQuery({ - queryKey: ['settings'], + queryKey: [QueryKeys.SETTINGS], queryFn: getSettings, }); } diff --git a/src/lib/useSettingsMutation.ts b/src/lib/useSettingsMutation.ts new file mode 100644 index 0000000..47ca6df --- /dev/null +++ b/src/lib/useSettingsMutation.ts @@ -0,0 +1,43 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from '@/components/ui/use-toast'; +import { saveSettings } from '@/commands/settings'; +import { Settings } from './types'; +import { useCalculateHistoryMutation } from '@/hooks/useCalculateHistory'; +import { QueryKeys } from './query-keys'; +export function useSettingsMutation( + setSettings: React.Dispatch>, + applySettingsToDocument: (newSettings: Settings) => void, + currentSettings: Settings | null, +) { + const queryClient = useQueryClient(); + const calculateHistoryMutation = useCalculateHistoryMutation({ + successTitle: 'Base currency updated successfully.', + }); + + return useMutation({ + mutationFn: saveSettings, + onSuccess: (updatedSettings) => { + setSettings(updatedSettings); + applySettingsToDocument(updatedSettings); + queryClient.invalidateQueries({ queryKey: [QueryKeys.SETTINGS] }); + toast({ + title: 'Settings updated successfully.', + variant: 'success', + }); + if (currentSettings?.baseCurrency !== updatedSettings.baseCurrency) { + calculateHistoryMutation.mutate({ + accountIds: undefined, + forceFullCalculation: true, + }); + } + }, + onError: (error) => { + console.error('Error updating settings:', error); + toast({ + title: 'Uh oh! Something went wrong.', + description: 'There was a problem updating your settings.', + variant: 'destructive', + }); + }, + }); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 7e7e4cd..4a408db 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -17,6 +17,34 @@ export function formatDate(input: string | number): string { return format(date, 'MMM d, yyyy'); } +export const formatDateTime = (date: string | Date, timezone?: string) => { + if (!date) return { date: '-', time: '-' }; + + const dateOptions: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: timezone || 'UTC', + }; + + const timeOptions: Intl.DateTimeFormatOptions = { + hour: 'numeric', + minute: 'numeric', + second: 'numeric', + timeZone: timezone || 'UTC', + }; + + const dateFormatter = new Intl.DateTimeFormat('en-US', dateOptions); + const timeFormatter = new Intl.DateTimeFormat('en-US', timeOptions); + + const dateObj = new Date(date); + + return { + date: dateFormatter.format(dateObj), + time: timeFormatter.format(dateObj), + }; +}; + export function formatAmount(amount: number, currency: string, displayCurrency = true) { return new Intl.NumberFormat('en-US', { style: displayCurrency ? 'currency' : undefined, diff --git a/src/pages/account/account-detail.tsx b/src/pages/account/account-detail.tsx index ac75951..ae14b9a 100644 --- a/src/pages/account/account-detail.tsx +++ b/src/pages/account/account-detail.tsx @@ -4,10 +4,10 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { Skeleton } from '@/components/ui/skeleton'; import { formatAmount, formatPercent } from '@/lib/utils'; -import { FinancialSnapshot } from '@/lib/types'; +import { PortfolioHistory } from '@/lib/types'; interface AccountDetailProps { - data?: FinancialSnapshot; + data?: PortfolioHistory; className?: string; } @@ -39,12 +39,12 @@ const AccountDetail: React.FC = ({ data, className }) => { { label: "Today's return", value: `${formatAmount(dayGainValue, currency)} (${formatPercent(dayGainPercentage)})`, - color: dayGainValue < 0 ? 'text-red-500' : 'text-green-500', + color: dayGainValue < 0 ? 'text-red-400' : 'text-success', }, { label: 'Total return', value: `${formatAmount(totalGainValue, currency)} (${formatPercent(totalGainPercentage)})`, - color: totalGainPercentage < 0 ? 'text-red-500' : 'text-green-500', + color: totalGainPercentage < 0 ? 'text-red-400' : 'text-success', }, ]; diff --git a/src/pages/account/account-page.tsx b/src/pages/account/account-page.tsx index 59c82b8..a773599 100644 --- a/src/pages/account/account-page.tsx +++ b/src/pages/account/account-page.tsx @@ -12,32 +12,40 @@ import { useParams } from 'react-router-dom'; import AccountDetail from './account-detail'; import AccountHoldings from './account-holdings'; import { useQuery } from '@tanstack/react-query'; -import { FinancialHistory, Holding } from '@/lib/types'; -import { computeHoldings, getHistorical } from '@/commands/portfolio'; +import { Holding, PortfolioHistory, AccountSummary } from '@/lib/types'; +import { computeHoldings, getAccountHistory, getAccountsSummary } from '@/commands/portfolio'; +import { QueryKeys } from '@/lib/query-keys'; const AccountPage = () => { const { id = '' } = useParams<{ id: string }>(); - const { data: portfolioHistory, isLoading: isLoadingHistory } = useQuery< - FinancialHistory[], + const { data: accounts, isLoading: isAccountsLoading } = useQuery({ + queryKey: [QueryKeys.ACCOUNTS_SUMMARY], + queryFn: getAccountsSummary, + }); + + const accountSummary = accounts?.find((account) => account.account.id === id); + + const { data: accountHistory, isLoading: isLoadingAccountHistory } = useQuery< + PortfolioHistory[], Error >({ - queryKey: ['portfolio_history'], - queryFn: getHistorical, + queryKey: QueryKeys.accountHistory(id), + queryFn: () => getAccountHistory(id), + enabled: !!id, }); const { data: holdings, isLoading: isLoadingHoldings } = useQuery({ - queryKey: ['holdings'], + queryKey: [QueryKeys.HOLDINGS], queryFn: computeHoldings, }); const accountHoldings = holdings ?.filter((holding) => holding.account?.id === id) .sort((a, b) => a.symbol.localeCompare(b.symbol)); - const accountHistory = portfolioHistory?.find((history) => history.account?.id === id); - const todayValue = accountHistory?.history[accountHistory?.history.length - 1]; - const account = accountHistory?.account; + const account = accountSummary?.account; + const performance = accountSummary?.performance; return ( @@ -51,19 +59,19 @@ const AccountPage = () => {

- {formatAmount(todayValue?.totalValue || 0, todayValue?.currency || 'USD')} + {formatAmount(performance?.totalValue || 0, performance?.currency || 'USD')}

@@ -71,20 +79,29 @@ const AccountPage = () => {
- {isLoadingHistory ? ( - + {isLoadingAccountHistory ? ( +
+ + + + + + + + +
) : ( - + )}
- {isLoadingHistory && !todayValue ? ( + {isAccountsLoading && !performance ? ( ) : ( - + )}
diff --git a/src/pages/activity/activity-page.tsx b/src/pages/activity/activity-page.tsx index 310c177..68817ff 100644 --- a/src/pages/activity/activity-page.tsx +++ b/src/pages/activity/activity-page.tsx @@ -6,37 +6,24 @@ import { useCallback, useState } from 'react'; import { Link } from 'react-router-dom'; import { ActivityEditModal } from './components/activity-edit-modal'; import ActivityTable from './components/activity-table'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { Account, ActivityDetails } from '@/lib/types'; import { getAccounts } from '@/commands/account'; import { ActivityDeleteModal } from './components/activity-delete-modal'; -import { deleteActivity } from '@/commands/activity'; -import { toast } from '@/components/ui/use-toast'; +import { QueryKeys } from '@/lib/query-keys'; +import { useActivityMutations } from './hooks/useActivityMutations'; const ActivityPage = () => { const [showEditModal, setShowEditModal] = useState(false); const [showDeleteAlert, setShowDeleteAlert] = useState(false); const [selectedActivity, setSelectedActivity] = useState(); - const queryClient = useQueryClient(); - const { data: accounts } = useQuery({ - queryKey: ['accounts'], + queryKey: [QueryKeys.ACCOUNTS], queryFn: getAccounts, }); - const deleteActivityMutation = useMutation({ - mutationFn: deleteActivity, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['activity-data'] }); - queryClient.invalidateQueries({ queryKey: ['holdings'] }); - queryClient.invalidateQueries({ queryKey: ['portfolio_history'] }); - toast({ - title: 'Account updated successfully.', - className: 'bg-green-500 text-white border-none', - }); - }, - }); + const { deleteActivityMutation } = useActivityMutations(); const handleEdit = useCallback( (activity?: ActivityDetails) => { @@ -54,8 +41,8 @@ const ActivityPage = () => { [showDeleteAlert], ); - const handleDeleteConfirm = () => { - deleteActivityMutation.mutate(selectedActivity.id); + const handleDeleteConfirm = async () => { + await deleteActivityMutation.mutateAsync(selectedActivity.id); setShowDeleteAlert(false); setSelectedActivity(null); }; diff --git a/src/pages/activity/components/activity-form.tsx b/src/pages/activity/components/activity-form.tsx index d2c210e..2ed45f5 100644 --- a/src/pages/activity/components/activity-form.tsx +++ b/src/pages/activity/components/activity-form.tsx @@ -2,7 +2,6 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { format } from 'date-fns'; import { useEffect } from 'react'; import { useForm, useFormContext } from 'react-hook-form'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; import * as z from 'zod'; import { AlertFeedback } from '@/components/alert-feedback'; @@ -33,12 +32,11 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { toast } from '@/components/ui/use-toast'; import { cn } from '@/lib/utils'; import { newActivitySchema } from '@/lib/schemas'; -import { createActivity, updateActivity } from '@/commands/activity'; +import { useActivityMutations } from '../hooks/useActivityMutations'; import TickerSearchInput from './ticker-search'; const activityTypes = [ @@ -47,7 +45,6 @@ const activityTypes = [ { label: 'Deposit', value: 'DEPOSIT' }, { label: 'Withdrawal', value: 'WITHDRAWAL' }, { label: 'Dividend', value: 'DIVIDEND' }, - // { label: 'Split', value: 'SPLIT' }, // { label: 'Transfer', value: 'TRANSFER' }, { label: 'Interest', value: 'INTEREST' }, { label: 'Fee', value: 'FEE' }, @@ -70,57 +67,22 @@ interface ActivityFormProps { } export function ActivityForm({ accounts, defaultValues, onSuccess = () => {} }: ActivityFormProps) { - const queryClient = useQueryClient(); - - const addActivityMutation = useMutation({ - mutationFn: createActivity, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['activity-data'] }); - queryClient.invalidateQueries({ queryKey: ['portfolio_history'] }); - queryClient.invalidateQueries({ queryKey: ['holdings'] }); - toast({ - title: 'Activity added successfully.', - className: 'bg-green-500 text-white border-none', - }); - onSuccess(); - }, - onError: (_error) => { - toast({ - title: 'Uh oh! Something went wrong.', - description: 'There was a problem adding this activity.', - className: 'bg-red-500 text-white border-none', - }); - }, - }); - - const updateActivityMutation = useMutation({ - mutationFn: updateActivity, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['activity-data'] }); - queryClient.invalidateQueries({ queryKey: ['portfolio_history'] }); - queryClient.invalidateQueries({ queryKey: ['holdings'] }); - toast({ - title: 'Activity updated successfully.', - className: 'bg-green-500 text-white border-none', - }); - onSuccess(); - }, - }); + const { addActivityMutation, updateActivityMutation } = useActivityMutations(onSuccess); const form = useForm({ resolver: zodResolver(newActivitySchema), defaultValues, }); - function onSubmit(data: ActivityFormValues) { + async function onSubmit(data: ActivityFormValues) { const { id, ...rest } = data; - if (id) { - return updateActivityMutation.mutate({ id, ...rest }); + return await updateActivityMutation.mutateAsync({ id, ...rest }); } - addActivityMutation.mutate(rest); + return await addActivityMutation.mutateAsync(rest); } + const isLoading = addActivityMutation.isPending || updateActivityMutation.isPending; const watchedType = form.watch('activityType'); const currentAccountCurrency = accounts.find((account) => account.value === form.watch('accountId'))?.currency || 'USD'; @@ -234,7 +196,7 @@ export function ActivityForm({ accounts, defaultValues, onSuccess = () => {} }: date > new Date() || date < new Date('1900-01-01')} initialFocus @@ -255,10 +217,16 @@ export function ActivityForm({ accounts, defaultValues, onSuccess = () => {} }:
- + -
- + + +
); @@ -118,22 +135,28 @@ export function Accounts({ accounts, className, }: { - accounts?: AccountTotal[]; + accounts?: AccountSummary[]; className?: string; }) { const { accountsGrouped, setAccountsGrouped } = useSettingsContext(); const [expandedCategories, setExpandedCategories] = useState>({}); const groupAccountsByCategory = () => { - const groupedAccounts: Record = {}; - for (const account of accounts || []) { - const category = account.group; - if (!groupedAccounts[category]) { - groupedAccounts[category] = []; + const groupedAccounts: Record = {}; + const ungroupedAccounts: AccountSummary[] = []; + + for (const accountSummary of accounts || []) { + const category = accountSummary.account.group; + if (category) { + if (!groupedAccounts[category]) { + groupedAccounts[category] = []; + } + groupedAccounts[category].push(accountSummary); + } else { + ungroupedAccounts.push(accountSummary); } - groupedAccounts[category].push(account); } - return groupedAccounts; + return { groupedAccounts, ungroupedAccounts }; }; const toggleCategory = (category: string) => { @@ -148,21 +171,10 @@ export function Accounts({ accountsInCategory, }: { category: string; - accountsInCategory: AccountTotal[]; + accountsInCategory: AccountSummary[]; }) => { - if (!category) { - return ( - - - - - - ); - } - const categorySummary = calculateCategorySummary(accountsInCategory); const isExpanded = expandedCategories[category]; - return ( @@ -179,9 +191,9 @@ export function Accounts({ {isExpanded && ( - {accountsInCategory.map((account) => ( -
- + {accountsInCategory.map((accountSummary) => ( +
+
))} @@ -192,19 +204,30 @@ export function Accounts({ const renderAccounts = () => { if (accountsGrouped) { - const groupedAccounts = groupAccountsByCategory(); - return Object.entries(groupedAccounts).map(([category, accountsInCategory]) => ( - - )); + const { groupedAccounts, ungroupedAccounts } = groupAccountsByCategory(); + return ( + <> + {Object.entries(groupedAccounts).map(([category, accountsInCategory]) => ( + + ))} + {ungroupedAccounts.map((accountSummary) => ( + + + + + + ))} + + ); } else { - return accounts?.map((account) => ( - + return accounts?.map((accountSummary) => ( + - + )); diff --git a/src/pages/dashboard/dashboard-page.tsx b/src/pages/dashboard/dashboard-page.tsx index a82d48a..eeaff3c 100644 --- a/src/pages/dashboard/dashboard-page.tsx +++ b/src/pages/dashboard/dashboard-page.tsx @@ -3,21 +3,21 @@ import { GainPercent } from '@/components/gain-percent'; import { HistoryChart } from '@/components/history-chart'; import Balance from './balance'; import { useQuery } from '@tanstack/react-query'; -import { FinancialHistory } from '@/lib/types'; -import { getHistorical } from '@/commands/portfolio'; +import { PortfolioHistory, AccountSummary } from '@/lib/types'; +import { getAccountHistory, getAccountsSummary } from '@/commands/portfolio'; import { Skeleton } from '@/components/ui/skeleton'; import { Accounts } from './accounts'; import SavingGoals from './goals'; -import { formatAccountsData } from '@/lib/portfolio-helper'; +import { QueryKeys } from '@/lib/query-keys'; // filter function DashboardSkeleton() { return ( -
+
-
+
@@ -27,19 +27,24 @@ function DashboardSkeleton() { } export default function DashboardPage() { - const { data: historyData, isLoading } = useQuery({ - queryKey: ['portfolio_history'], - queryFn: getHistorical, + const { data: portfolioHistory, isLoading: isPortfolioHistoryLoading } = useQuery< + PortfolioHistory[], + Error + >({ + queryKey: QueryKeys.accountHistory('TOTAL'), + queryFn: () => getAccountHistory('TOTAL'), }); - if (isLoading) { + const { data: accounts, isLoading: isAccountsLoading } = useQuery({ + queryKey: [QueryKeys.ACCOUNTS_SUMMARY], + queryFn: getAccountsSummary, + }); + + if (isPortfolioHistoryLoading || isAccountsLoading) { return ; } - const portfolio = historyData?.find((history) => history.account?.id === 'TOTAL'); - const todayValue = portfolio?.history[portfolio?.history.length - 1]; - - const accountsData = formatAccountsData(historyData || [], portfolio?.account.currency); + const todayValue = portfolioHistory?.[portfolioHistory.length - 1]; return (
@@ -53,38 +58,37 @@ export default function DashboardPage() { />
- +
{/* Responsive grid */}
{/* Column 1 */}
- +
{/* Column 2 */}
- +
{/* Column 3 */}
- {/* Grid container */}
); diff --git a/src/pages/dashboard/goals.tsx b/src/pages/dashboard/goals.tsx index 021eee1..aa52d7a 100644 --- a/src/pages/dashboard/goals.tsx +++ b/src/pages/dashboard/goals.tsx @@ -2,13 +2,13 @@ import { getGoals, getGoalsAllocation } from '@/commands/goal'; import { Card, CardContent, CardHeader, CardDescription, CardTitle } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; import { calculateGoalProgress } from '@/lib/portfolio-helper'; -import { AccountTotal, Goal, GoalAllocation, GoalProgress } from '@/lib/types'; +import { AccountSummary, Goal, GoalAllocation, GoalProgress } from '@/lib/types'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { useQuery } from '@tanstack/react-query'; import { formatAmount, formatPercent } from '@/lib/utils'; import { Icons } from '@/components/icons'; -export function SavingGoals({ accounts }: { accounts?: AccountTotal[] }) { +export function SavingGoals({ accounts }: { accounts?: AccountSummary[] }) { const { data: goals } = useQuery({ queryKey: ['goals'], queryFn: getGoals, @@ -21,7 +21,7 @@ export function SavingGoals({ accounts }: { accounts?: AccountTotal[] }) { if (accounts === undefined || goals === undefined || allocations === undefined) return null; - const goalsProgess = calculateGoalProgress(accounts, goals, allocations); + const goalsProgress = calculateGoalProgress(accounts, goals, allocations); return ( @@ -33,38 +33,48 @@ export function SavingGoals({ accounts }: { accounts?: AccountTotal[] }) { - {goalsProgess?.map((goal: GoalProgress, index) => ( - - -
- - {goal.name} - {goal.progress >= 100 ? ( - - ) : null} - + {goalsProgress && goalsProgress.length > 0 ? ( + goalsProgress.map((goal: GoalProgress, index) => ( + + +
+ + {goal.name} + {goal.progress >= 100 ? ( + + ) : null} + - -
-
- -

{goal.name}

-
    -
  • - Progress: {formatPercent(goal.progress)} -
  • -
  • - Current Value:{' '} - {formatAmount(goal.currentValue, goal.currency, false)} -
  • -
  • - Target Value:{' '} - {formatAmount(goal.targetValue, goal.currency, false)} -
  • -
-
-
- ))} + +
+
+ +

{goal.name}

+
    +
  • + Progress: {formatPercent(goal.progress)} +
  • +
  • + Current Value:{' '} + {formatAmount(goal.currentValue, goal.currency, false)} +
  • +
  • + Target Value:{' '} + {formatAmount(goal.targetValue, goal.currency, false)} +
  • +
+
+
+ )) + ) : ( +
+ +

No saving goals set

+

+ Create a goal to start tracking your progress +

+
+ )}
diff --git a/src/pages/holdings/components/holdings-table.tsx b/src/pages/holdings/components/holdings-table.tsx index 06174bb..41c574b 100644 --- a/src/pages/holdings/components/holdings-table.tsx +++ b/src/pages/holdings/components/holdings-table.tsx @@ -3,12 +3,14 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { DataTable } from '@/components/ui/data-table'; import { DataTableColumnHeader } from '@/components/ui/data-table/data-table-column-header'; -import { formatAmount, formatPercent } from '@/lib/utils'; +import { formatAmount } from '@/lib/utils'; import type { ColumnDef } from '@tanstack/react-table'; -import { useNavigate } from 'react-router-dom'; +import { GainAmount } from '@/components/gain-amount'; +import { GainPercent } from '@/components/gain-percent'; import { Skeleton } from '@/components/ui/skeleton'; import { Holding } from '@/lib/types'; +import { useNavigate } from 'react-router-dom'; export const HoldingsTable = ({ holdings, @@ -175,26 +177,13 @@ export const columns: ColumnDef[] = [ const currency = row.getValue('currency') as string; return ( -
0 - ? 'text-green-500' - : 'text-red-500' - } `} - > -
- {performance?.totalGainPercent > 0 ? ( - - ) : ( - - )} - {formatPercent(Math.abs(performance?.totalGainPercent))} -
- - {formatAmount(performance?.totalGainAmount, currency)} - +
+ +
); }, diff --git a/src/pages/holdings/components/income-dashboard.tsx b/src/pages/holdings/components/income-dashboard.tsx index 9d72e74..4ddda59 100644 --- a/src/pages/holdings/components/income-dashboard.tsx +++ b/src/pages/holdings/components/income-dashboard.tsx @@ -9,9 +9,12 @@ import { ChartTooltip, ChartTooltipContent, } from '@/components/ui/chart'; +import { Icons } from '@/components/icons'; import { getIncomeSummary } from '@/commands/portfolio'; import type { IncomeSummary } from '@/lib/types'; import { formatAmount } from '@/lib/utils'; +import { QueryKeys } from '@/lib/query-keys'; +import { Skeleton } from '@/components/ui/skeleton'; export function IncomeDashboard() { const { @@ -19,19 +22,18 @@ export function IncomeDashboard() { isLoading, error, } = useQuery({ - queryKey: ['incomeSummary'], + queryKey: [QueryKeys.INCOME_SUMMARY], queryFn: getIncomeSummary, }); if (isLoading) { - return
Loading...
; + return ; } if (error || !incomeSummary) { return
Failed to load income summary: {error?.message || 'Unknown error'}
; } - console.log(incomeSummary); const totalIncome = incomeSummary.total_income; const dividendIncome = incomeSummary.by_type['DIVIDEND'] || 0; const interestIncome = incomeSummary.by_type['INTEREST'] || 0; @@ -124,69 +126,132 @@ export function IncomeDashboard() { Last 12 months - - ({ - month, - income, - cumulative: monthlyIncomeData - .slice(0, index + 1) - .reduce((sum, [, value]) => sum + value, 0), - }))} + {monthlyIncomeData.length === 0 ? ( +
+ +

No income history available

+
+ ) : ( + - - value.slice(5)} // Show only MM part of YYYY-MM - /> - - - } /> - } /> - - -
-
+ ({ + month, + income, + cumulative: monthlyIncomeData + .slice(0, index + 1) + .reduce((sum, [, value]) => sum + value, 0), + }))} + > + + value.slice(5)} // Show only MM part of YYYY-MM + /> + + + } /> + } /> + + + + + )}
Top 10 Dividend Sources + + {topDividendStocks.length === 0 ? ( +
+ +

No dividend income recorded

+
+ ) : ( +
+ {topDividendStocks.map(([symbol, income], index) => ( +
+
{symbol}
+
+ {formatAmount(income, incomeSummary.currency)} +
+
+ ))} +
+ )} +
+
+
+ +
+ ); +} + +function IncomeDashboardSkeleton() { + return ( +
+
+
+ {[...Array(3)].map((_, index) => ( + + + + + + + + + + + ))} +
+
+ + + + + + + + + + + + +
- {topDividendStocks.map(([symbol, income], index) => ( + {[...Array(10)].map((_, index) => (
-
{symbol}
-
- {formatAmount(income, incomeSummary.currency)} -
+ +
))}
diff --git a/src/pages/holdings/holdings-page.tsx b/src/pages/holdings/holdings-page.tsx index 46bf1f2..d8cd1de 100644 --- a/src/pages/holdings/holdings-page.tsx +++ b/src/pages/holdings/holdings-page.tsx @@ -8,28 +8,30 @@ import { ClassesChart } from './components/classes-chart'; import { HoldingsTable } from './components/holdings-table'; import { PortfolioComposition } from './components/portfolio-composition'; import { SectorsChart } from './components/sectors-chart'; -import { computeHoldings, getHistorical } from '@/commands/portfolio'; +import { computeHoldings } from '@/commands/portfolio'; import { useQuery } from '@tanstack/react-query'; import { aggregateHoldingsBySymbol } from '@/lib/portfolio-helper'; -import { FinancialHistory, Holding } from '@/lib/types'; +import { Holding } from '@/lib/types'; import { HoldingCurrencyChart } from './components/currency-chart'; import { useSettingsContext } from '@/lib/settings-provider'; import { IncomeDashboard } from './components/income-dashboard'; +import { QueryKeys } from '@/lib/query-keys'; +import { PortfolioHistory } from '@/lib/types'; +import { getAccountHistory } from '@/commands/portfolio'; export const HoldingsPage = () => { const { settings } = useSettingsContext(); const { data, isLoading } = useQuery({ - queryKey: ['holdings'], + queryKey: [QueryKeys.HOLDINGS], queryFn: computeHoldings, }); - const { data: historyData } = useQuery({ - queryKey: ['portfolio_history'], - queryFn: getHistorical, + const { data: portfolioHistory } = useQuery({ + queryKey: QueryKeys.accountHistory('TOTAL'), + queryFn: () => getAccountHistory('TOTAL'), }); - const portfolio = historyData?.find((history) => history.account?.id === 'TOTAL'); - const todayValue = portfolio?.history[portfolio?.history.length - 1]; + const todayValue = portfolioHistory?.[portfolioHistory.length - 1]; const holdings = useMemo(() => { return aggregateHoldingsBySymbol(data || []); diff --git a/src/pages/onboarding/onboarding-page.tsx b/src/pages/onboarding/onboarding-page.tsx index 040a67b..13e95e7 100644 --- a/src/pages/onboarding/onboarding-page.tsx +++ b/src/pages/onboarding/onboarding-page.tsx @@ -61,14 +61,16 @@ export const OnboardingPage = () => {
-
- -
+ {currentStep === 0 && ( +
+ +
+ )}
diff --git a/src/pages/settings/accounts/accounts-page.tsx b/src/pages/settings/accounts/accounts-page.tsx index 3657c4c..4e49077 100644 --- a/src/pages/settings/accounts/accounts-page.tsx +++ b/src/pages/settings/accounts/accounts-page.tsx @@ -7,16 +7,15 @@ import { Button } from '@/components/ui/button'; import { Icons } from '@/components/icons'; import type { Account } from '@/lib/types'; import { SettingsHeader } from '../header'; -import { deleteAccount, getAccounts } from '@/commands/account'; +import { getAccounts } from '@/commands/account'; import { Skeleton } from '@/components/ui/skeleton'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { toast } from '@/components/ui/use-toast'; +import { useQuery } from '@tanstack/react-query'; +import { QueryKeys } from '@/lib/query-keys'; +import { useAccountMutations } from './components/useAccountMutations'; const SettingsAccountsPage = () => { - const queryClient = useQueryClient(); - const { data: accounts, isLoading } = useQuery({ - queryKey: ['accounts'], + queryKey: [QueryKeys.ACCOUNTS], queryFn: getAccounts, }); @@ -28,26 +27,7 @@ const SettingsAccountsPage = () => { setVisibleModal(true); }; - const deleteAccountMutation = useMutation({ - mutationFn: deleteAccount, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['accounts'] }); - queryClient.invalidateQueries({ queryKey: ['holdings'] }); - queryClient.invalidateQueries({ queryKey: ['portfolio_history'] }); - toast({ - title: 'Account deleted successfully.', - className: 'bg-green-500 text-white border-none', - }); - }, - onError: () => { - toast({ - title: 'Uh oh! Something went wrong.', - description: - 'There was a problem deleting this account. Please check if there is any data related to this account.', - className: 'bg-red-500 text-white border-none', - }); - }, - }); + const { deleteAccountMutation } = useAccountMutations({}); const handleEditAccount = (account: Account) => { setSelectedAccount(account); @@ -77,7 +57,7 @@ const SettingsAccountsPage = () => { -
+
{accounts?.length ? (
{accounts.map((account: Account) => ( diff --git a/src/pages/settings/accounts/components/account-form.tsx b/src/pages/settings/accounts/components/account-form.tsx index d17b5d8..d4bd8ef 100644 --- a/src/pages/settings/accounts/components/account-form.tsx +++ b/src/pages/settings/accounts/components/account-form.tsx @@ -1,6 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; import * as z from 'zod'; import { Button } from '@/components/ui/button'; @@ -39,7 +38,8 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { toast } from '@/components/ui/use-toast'; + +import { useAccountMutations } from './useAccountMutations'; const accountTypes = [ { label: 'Securities', value: 'SECURITIES' }, @@ -49,7 +49,6 @@ const accountTypes = [ import { worldCurrencies } from '@/lib/currencies'; import { newAccountSchema } from '@/lib/schemas'; -import { createAccount, updateAccount } from '@/commands/account'; import { ScrollArea } from '@/components/ui/scroll-area'; type NewAccount = z.infer; @@ -60,40 +59,7 @@ interface AccountFormlProps { } export function AccountForm({ defaultValues, onSuccess = () => {} }: AccountFormlProps) { - const queryClient = useQueryClient(); - - const addAccountMutation = useMutation({ - mutationFn: createAccount, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['accounts'] }); - toast({ - title: 'Account added successfully.', - description: 'Start adding or importing this account activities.', - className: 'bg-green-500 text-white border-none', - }); - onSuccess(); - }, - onError: () => { - toast({ - title: 'Uh oh! Something went wrong.', - description: 'There was a problem adding this account.', - className: 'bg-red-500 text-white border-none', - }); - }, - }); - const updateAccountMutation = useMutation({ - mutationFn: updateAccount, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['accounts'] }); - queryClient.invalidateQueries({ queryKey: ['holdings'] }); - queryClient.invalidateQueries({ queryKey: ['portfolio_history'] }); - toast({ - title: 'Account updated successfully.', - className: 'bg-green-500 text-white border-none', - }); - onSuccess(); - }, - }); + const { createAccountMutation, updateAccountMutation } = useAccountMutations({ onSuccess }); const form = useForm({ resolver: zodResolver(newAccountSchema), @@ -105,7 +71,7 @@ export function AccountForm({ defaultValues, onSuccess = () => {} }: AccountForm if (id) { return updateAccountMutation.mutate({ id, ...rest }); } - return addAccountMutation.mutate(data); + return createAccountMutation.mutate(rest); } return ( @@ -121,7 +87,6 @@ export function AccountForm({ defaultValues, onSuccess = () => {} }: AccountForm
- {/* add input hidden for id */} void; +} + +export function useAccountMutations({ onSuccess = () => {} }: UseAccountMutationsProps) { + const queryClient = useQueryClient(); + + const calculateHistoryMutation = useCalculateHistoryMutation({ + successTitle: 'Account updated successfully.', + }); + + const handleSuccess = (message?: string) => { + onSuccess(); + if (message) { + toast({ title: message, variant: 'success' }); + } + }; + + const handleError = (action: string) => { + toast({ + title: `Uh oh! Something went wrong ${action} this account.`, + description: 'Please try again or report an issue if the problem persists.', + variant: 'destructive', + }); + }; + + const createAccountMutation = useMutation({ + mutationFn: createAccount, + onSuccess: () => { + handleSuccess('Account created successfully.'); + queryClient.invalidateQueries({ queryKey: [QueryKeys.ACCOUNTS] }); + }, + onError: () => handleError('creating'), + }); + + const updateAccountMutation = useMutation({ + mutationFn: updateAccount, + onSuccess: (updatedAccount) => { + handleSuccess(); + calculateHistoryMutation.mutate({ + accountIds: [updatedAccount.id], + forceFullCalculation: true, + }); + }, + onError: () => handleError('updating'), + }); + + const deleteAccountMutation = useMutation({ + mutationFn: deleteAccount, + onSuccess: () => { + handleSuccess(); + calculateHistoryMutation.mutate({ + accountIds: undefined, + forceFullCalculation: true, + }); + }, + onError: () => handleError('deleting'), + }); + + return { createAccountMutation, updateAccountMutation, deleteAccountMutation }; +} diff --git a/src/pages/settings/appearance/appearance-form.tsx b/src/pages/settings/appearance/appearance-form.tsx index ec8f5af..826b532 100644 --- a/src/pages/settings/appearance/appearance-form.tsx +++ b/src/pages/settings/appearance/appearance-form.tsx @@ -42,7 +42,6 @@ export function AppearanceForm() { function onSubmit(data: AppearanceFormValues) { const updatedSettings = { - id: settings?.id || 1, baseCurrency: settings?.baseCurrency || 'USD', ...data, }; diff --git a/src/pages/settings/currencies/exchange-rates-page.tsx b/src/pages/settings/currencies/exchange-rates-page.tsx new file mode 100644 index 0000000..c9ccde5 --- /dev/null +++ b/src/pages/settings/currencies/exchange-rates-page.tsx @@ -0,0 +1,64 @@ +import { useExchangeRates } from './useExchangeRate'; +import { DataTable } from '@/components/ui/data-table'; +import { ColumnDef } from '@tanstack/react-table'; +import { ExchangeRate } from '@/lib/types'; +import { Skeleton } from '@/components/ui/skeleton'; +import { RateCell } from './rate-cell'; +import { Separator } from '@/components/ui/separator'; +import { SettingsHeader } from '../header'; + +export default function ExchangeRatesPage() { + const { exchangeRates, isLoadingRates, updateExchangeRate } = useExchangeRates(); + + const columns: ColumnDef[] = [ + { + accessorKey: 'fromCurrency', + header: 'From', + cell: ({ row }) => ( +
+
{row.original.fromCurrency}
+
{row.original.fromCurrencyName}
+
+ ), + }, + { + accessorKey: 'toCurrency', + header: 'To', + cell: ({ row }) => ( +
+
{row.original.toCurrency}
+
{row.original.toCurrencyName}
+
+ ), + }, + { + accessorKey: 'source', + header: 'Source', + }, + { + accessorKey: 'rate', + header: 'Rate', + cell: ({ row }) => , + size: 180, + }, + ]; + + return ( +
+ + + {isLoadingRates ? ( +
+ {[...Array(5)].map((_, index) => ( + + ))} +
+ ) : ( + + )} +
+ ); +} diff --git a/src/pages/settings/currencies/rate-cell.tsx b/src/pages/settings/currencies/rate-cell.tsx new file mode 100644 index 0000000..4a61e4e --- /dev/null +++ b/src/pages/settings/currencies/rate-cell.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react'; +import { ExchangeRate } from '@/lib/types'; +import { Icons } from '@/components/icons'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { toast } from '@/components/ui/use-toast'; + +interface RateCellProps { + rate: ExchangeRate; + onUpdate: (updatedRate: ExchangeRate) => void; +} + +export function RateCell({ rate, onUpdate }: RateCellProps) { + const [isEditing, setIsEditing] = useState(false); + const [editedRate, setEditedRate] = useState(rate.rate.toString()); + const isManual = rate.source === 'MANUAL'; + + const handleEdit = () => { + if (!isManual) { + toast({ + title: 'Cannot edit this rate', + description: 'Only manual rates can be edited.', + variant: 'destructive', + }); + return; + } + setIsEditing(true); + setEditedRate(rate.rate.toString()); + }; + + const handleCancelEdit = () => { + setIsEditing(false); + setEditedRate(rate.rate.toString()); + }; + + const handleSubmit = () => { + const newRate = parseFloat(editedRate); + if (isNaN(newRate) || newRate <= 0) { + toast({ + title: 'Invalid rate', + description: 'Please enter a valid positive number.', + variant: 'destructive', + }); + return; + } + const updatedRate = { ...rate, rate: newRate }; + onUpdate(updatedRate); + setIsEditing(false); + }; + + return ( +
+
+ {isManual && isEditing ? ( + setEditedRate(e.target.value)} + className="w-full" + /> + ) : ( + {rate.rate ? rate.rate.toFixed(4) : '-'} + )} +
+ {isManual && ( +
+ {isEditing ? ( + <> + + + + ) : ( + + )} +
+ )} +
+ ); +} diff --git a/src/pages/settings/currencies/useExchangeRate.ts b/src/pages/settings/currencies/useExchangeRate.ts new file mode 100644 index 0000000..5007f3f --- /dev/null +++ b/src/pages/settings/currencies/useExchangeRate.ts @@ -0,0 +1,82 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from '@/components/ui/use-toast'; +import { ExchangeRate } from '@/lib/types'; +import { + getExchangeRates, + updateExchangeRate as updateExchangeRateApi, +} from '@/commands/exchange-rates'; +import { QueryKeys } from '@/lib/query-keys'; +import { useCalculateHistoryMutation } from '@/hooks/useCalculateHistory'; +import { worldCurrencies } from '@/lib/currencies'; + +export function useExchangeRates() { + const queryClient = useQueryClient(); + const calculateHistoryMutation = useCalculateHistoryMutation({ + successTitle: 'Exchange rate updated and calculation triggered successfully.', + }); + + const getCurrencyName = (code: string) => { + const currency = worldCurrencies.find((c) => c.value === code); + return currency ? currency.label.split(' (')[0] : code; + }; + + const { data: exchangeRates, isLoading: isLoadingRates } = useQuery({ + queryKey: [QueryKeys.EXCHANGE_RATES], + queryFn: async () => { + const rates = await getExchangeRates(); + const processedRates = rates.map((rate) => ({ + ...rate, + fromCurrencyName: getCurrencyName(rate.fromCurrency), + toCurrencyName: getCurrencyName(rate.toCurrency), + })); + + // For manual rates, keep only from->to and filter out the reverse + return processedRates.filter((rate) => { + if (rate.source === 'MANUAL') { + const reverseManualRate = processedRates.find( + (r) => + r.fromCurrency === rate.toCurrency && + r.toCurrency === rate.fromCurrency && + r.source === 'MANUAL', + ); + return !reverseManualRate || rate.fromCurrency < rate.toCurrency; + } + return true; // Keep all non-manual rates + }); + }, + }); + + const updateExchangeRateMutation = useMutation({ + mutationFn: updateExchangeRateApi, + onSuccess: (updatedRate) => { + queryClient.invalidateQueries({ queryKey: [QueryKeys.EXCHANGE_RATES] }); + toast({ + title: 'Exchange rate updated successfully', + description: `${updatedRate.fromCurrency}/${updatedRate.toCurrency} rate updated to ${updatedRate.rate}`, + variant: 'success', + }); + + calculateHistoryMutation.mutate({ + accountIds: undefined, + forceFullCalculation: true, + }); + }, + onError: (error) => { + toast({ + title: 'Uh oh! Something went wrong.', + description: `There was a problem updating the exchange rate: ${error.message}`, + variant: 'destructive', + }); + }, + }); + + const updateExchangeRate = (rate: ExchangeRate) => { + updateExchangeRateMutation.mutate(rate); + }; + + return { + exchangeRates, + isLoadingRates, + updateExchangeRate, + }; +} diff --git a/src/pages/settings/general/general-form.tsx b/src/pages/settings/general/general-form.tsx index 0615671..2823d55 100644 --- a/src/pages/settings/general/general-form.tsx +++ b/src/pages/settings/general/general-form.tsx @@ -45,7 +45,6 @@ export function GeneralSettingForm() { function onSubmit(data: GeneralSettingFormValues) { const updatedSettings = { - id: settings?.id || 1, theme: settings?.theme || 'light', font: settings?.font || 'font-mono', ...data, diff --git a/src/pages/settings/goals/components/goal-allocations.tsx b/src/pages/settings/goals/components/goal-allocations.tsx index 70c410c..8184a0f 100644 --- a/src/pages/settings/goals/components/goal-allocations.tsx +++ b/src/pages/settings/goals/components/goal-allocations.tsx @@ -74,7 +74,7 @@ const GoalsAllocations: React.FC = ({ return ( <>
- +
@@ -93,8 +93,8 @@ const GoalsAllocations: React.FC = ({ {accounts.map((account) => ( 100 ? 'text-red-500' : '' + className={`border-l border-t px-4 py-2 text-right text-xs text-muted-foreground ${ + totalAllocations[account.id] > 100 ? 'text-red-400' : '' }`} > {totalAllocations[account.id]}% @@ -120,7 +120,7 @@ const GoalsAllocations: React.FC = ({ return ( handleAllocationChange(goal.id, account.id, Number(e.target.value)) diff --git a/src/pages/settings/goals/components/goal-form.tsx b/src/pages/settings/goals/components/goal-form.tsx index 6fe0893..0769bb7 100644 --- a/src/pages/settings/goals/components/goal-form.tsx +++ b/src/pages/settings/goals/components/goal-form.tsx @@ -1,6 +1,5 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; import * as z from 'zod'; import { Button } from '@/components/ui/button'; @@ -24,10 +23,9 @@ import { FormMessage, } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; -import { toast } from '@/components/ui/use-toast'; import { newGoalSchema } from '@/lib/schemas'; -import { createGoal, updateGoal } from '@/commands/goal'; +import { useGoalMutations } from '@/pages/settings/goals/useGoalMutations'; type NewGoal = z.infer; @@ -37,38 +35,7 @@ interface GoalFormlProps { } export function GoalForm({ defaultValues, onSuccess = () => {} }: GoalFormlProps) { - const queryClient = useQueryClient(); - - const addGoalMutation = useMutation({ - mutationFn: createGoal, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['goals'] }); - toast({ - title: 'Goal added successfully.', - description: 'Start adding or importing this goal activities.', - className: 'bg-green-500 text-white border-none', - }); - onSuccess(); - }, - onError: () => { - toast({ - title: 'Uh oh! Something went wrong.', - description: 'There was a problem adding this goal.', - className: 'bg-red-500 text-white border-none', - }); - }, - }); - const updateGoalMutation = useMutation({ - mutationFn: updateGoal, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['goals'] }); - toast({ - title: 'Goal updated successfully.', - className: 'bg-green-500 text-white border-none', - }); - onSuccess(); - }, - }); + const { addGoalMutation, updateGoalMutation } = useGoalMutations(); const form = useForm({ resolver: zodResolver(newGoalSchema), @@ -78,9 +45,9 @@ export function GoalForm({ defaultValues, onSuccess = () => {} }: GoalFormlProps function onSubmit(data: NewGoal) { const { id, ...rest } = data; if (id) { - return updateGoalMutation.mutate({ id, ...rest }); + return updateGoalMutation.mutate({ id, ...rest }, { onSuccess }); } - return addGoalMutation.mutate(data); + return addGoalMutation.mutate(data, { onSuccess }); } return ( @@ -93,7 +60,7 @@ export function GoalForm({ defaultValues, onSuccess = () => {} }: GoalFormlProps -
+
{/* add input hidden for id */} @@ -136,40 +103,6 @@ export function GoalForm({ defaultValues, onSuccess = () => {} }: GoalFormlProps )} /> - {/* ( - - Target Date - - - - - - - - date > new Date() || date < new Date('1900-01-01')} - initialFocus - /> - - - - )} - /> */} {defaultValues?.id ? ( { - const queryClient = useQueryClient(); - const { data: goals, isLoading } = useQuery({ - queryKey: ['goals'], + queryKey: [QueryKeys.GOALS], queryFn: getGoals, }); const { data: allocations } = useQuery({ - queryKey: ['goals_allocations'], + queryKey: [QueryKeys.GOALS_ALLOCATIONS], queryFn: getGoalsAllocation, }); @@ -32,24 +31,13 @@ const SettingsGoalsPage = () => { const [visibleModal, setVisibleModal] = useState(false); const [selectedGoal, setSelectedGoal] = useState(null); + const { deleteGoalMutation, saveAllocationsMutation } = useGoalMutations(); + const handleAddGoal = () => { setSelectedGoal(null); setVisibleModal(true); }; - const deleteGoalMutation = useMutation({ - mutationFn: deleteGoal, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['goals'] }); - queryClient.invalidateQueries({ queryKey: ['goals_allocations'] }); - setVisibleModal(false); - toast({ - title: 'Goal deleted successfully.', - className: 'bg-green-500 text-white border-none', - }); - }, - }); - const handleEditGoal = (goal: Goal) => { setSelectedGoal(goal); setVisibleModal(true); @@ -59,18 +47,6 @@ const SettingsGoalsPage = () => { deleteGoalMutation.mutate(goal.id); }; - const saveAllocationsMutation = useMutation({ - mutationFn: updateGoalsAllocations, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['goals'] }); - queryClient.invalidateQueries({ queryKey: ['goals_allocations'] }); - toast({ - title: 'Allocation saved successfully.', - className: 'bg-green-500 text-white border-none', - }); - }, - }); - const handleAddAllocation = (allocationData: GoalAllocation[]) => { saveAllocationsMutation.mutate(allocationData); }; @@ -94,7 +70,7 @@ const SettingsGoalsPage = () => { -
+
{goals?.length ? ( <>

Goals

diff --git a/src/pages/settings/goals/useGoalMutations.ts b/src/pages/settings/goals/useGoalMutations.ts new file mode 100644 index 0000000..fe97a02 --- /dev/null +++ b/src/pages/settings/goals/useGoalMutations.ts @@ -0,0 +1,63 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { deleteGoal, updateGoalsAllocations, createGoal, updateGoal } from '@/commands/goal'; +import { QueryKeys } from '@/lib/query-keys'; +import { toast } from '@/components/ui/use-toast'; + +export const useGoalMutations = () => { + const queryClient = useQueryClient(); + + const handleSuccess = (message: string, invalidateKeys: string[]) => { + invalidateKeys.forEach((key) => queryClient.invalidateQueries({ queryKey: [key] })); + toast({ + title: message, + variant: 'success', + }); + }; + + const handleError = (action: string) => { + toast({ + title: 'Uh oh! Something went wrong.', + description: `There was a problem ${action}.`, + variant: 'destructive', + }); + }; + + const addGoalMutation = useMutation({ + mutationFn: createGoal, + onSuccess: () => + handleSuccess('Goal added successfully. Start adding or importing this goal activities.', [ + QueryKeys.GOALS, + ]), + onError: () => handleError('adding this goal'), + }); + + const updateGoalMutation = useMutation({ + mutationFn: updateGoal, + onSuccess: () => handleSuccess('Goal updated successfully.', [QueryKeys.GOALS]), + onError: () => handleError('updating this goal'), + }); + + const deleteGoalMutation = useMutation({ + mutationFn: deleteGoal, + onSuccess: () => + handleSuccess('Goal deleted successfully.', [QueryKeys.GOALS, QueryKeys.GOALS_ALLOCATIONS]), + onError: () => handleError('deleting this goal'), + }); + + const saveAllocationsMutation = useMutation({ + mutationFn: updateGoalsAllocations, + onSuccess: () => + handleSuccess('Allocation saved successfully.', [ + QueryKeys.GOALS, + QueryKeys.GOALS_ALLOCATIONS, + ]), + onError: () => handleError('saving the allocations'), + }); + + return { + deleteGoalMutation, + saveAllocationsMutation, + addGoalMutation, + updateGoalMutation, + }; +}; diff --git a/src/pages/settings/layout.tsx b/src/pages/settings/layout.tsx index 287273b..9953b03 100644 --- a/src/pages/settings/layout.tsx +++ b/src/pages/settings/layout.tsx @@ -15,6 +15,10 @@ const sidebarNavItems = [ title: 'Goals', href: 'goals', }, + { + title: 'Exchange Rates', + href: 'exchange-rates', + }, { title: 'Appearance', href: 'appearance', diff --git a/src/routes.tsx b/src/routes.tsx index e2d43f0..7d45f14 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -15,6 +15,7 @@ import useGlobalEventListener from './useGlobalEventListener'; import GeneralSettingsPage from './pages/settings/general/general-page'; import OnboardingPage from './pages/onboarding/onboarding-page'; import SettingsGoalsPage from './pages/settings/goals/goals-page'; +import ExchangeRatesPage from './pages/settings/currencies/exchange-rates-page'; export function AppRoutes() { useGlobalEventListener(); @@ -36,6 +37,7 @@ export function AppRoutes() { } /> } /> } /> + } /> Not Found} /> diff --git a/src/styles.css b/src/styles.css index 750c0bd..3b45c9f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -41,6 +41,10 @@ --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; + --success: 122 57% 38%; + --success-foreground: var(--foreground); + --success-background: 67 43% 56%; + --border: 24 4.42% 84%; --input: 24 4.42% 84%; --ring: 222.2 84% 4.9%; @@ -57,31 +61,35 @@ .dark { --background: 10 0% 10%; - --foreground: 210 40% 98%; + --foreground: 141 7% 94%; --card: 10 0% 10%; - --card-foreground: 210 40% 98%; + --card-foreground: var(--foreground); --sidebar: 10 0% 10%; --popover: 10 0% 10%; - --popover-foreground: 210 40% 98%; + --popover-foreground: var(--foreground); - --primary: 210 40% 98%; + --primary: var(--foreground); --primary-foreground: 222.2 47.4% 11.2%; --secondary: 0 0% 30%; - --secondary-foreground: 210 40% 98%; + --secondary-foreground: var(--foreground); --muted: 0 0% 30%; --muted-foreground: 215 20.2% 65.1%; --accent: 0 0% 30%; - --accent-foreground: 210 40% 98%; + --accent-foreground: var(--foreground); - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; + + --destructuve: 0 84 64 + --destructive-foreground: var(--foreground); + --success: 68 43% 74%; + --success-foreground: var(--background); + --border: 0 0% 20%; --input: 0 0% 30%; --ring: 0 0% 20%; diff --git a/src/useGlobalEventListener.ts b/src/useGlobalEventListener.ts index ce4f94b..36965f5 100644 --- a/src/useGlobalEventListener.ts +++ b/src/useGlobalEventListener.ts @@ -10,16 +10,18 @@ const useGlobalEventListener = () => { useEffect(() => { const handleQuoteSyncStart = () => { toast({ - title: 'Syncing quotes...', - description: 'Please wait while we sync your quotes', + title: 'Updating Market Data', + description: 'Fetching the latest market prices. This may take a moment.', + duration: 5000, }); }; const handleQuotesSyncComplete = () => { - queryClient.invalidateQueries({ queryKey: ['portfolio_history'] }); - queryClient.invalidateQueries({ queryKey: ['holdings'] }); + queryClient.invalidateQueries(); toast({ - title: 'Quotes synced successfully', + title: 'Portfolio Update Complete', + description: 'Your portfolio has been refreshed with the latest market data.', + duration: 5000, }); }; const setupListeners = async () => { @@ -37,7 +39,7 @@ const useGlobalEventListener = () => { }); }, [queryClient]); - return null; // Assuming this hook doesn't need to return anything + return null; }; export default useGlobalEventListener; diff --git a/tailwind.config.js b/tailwind.config.js index 975b31b..793611e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -58,6 +58,11 @@ module.exports = { sidebar: { DEFAULT: 'hsl(var(--sidebar))', }, + success: { + DEFAULT: 'hsl(var(--success))', + foreground: 'hsl(var(--success-foreground))', + background: 'hsl(var(--success-background))', + }, 'custom-green': '#E7EBCC', 'custom-green-dark': '#606448', },