diff --git a/package.json b/package.json index 59386112..9ca32a5f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wealthfolio-app", "private": true, - "version": "1.0.17", + "version": "1.0.18", "type": "module", "scripts": { "dev": "vite", diff --git a/src-core/Cargo.lock b/src-core/Cargo.lock index 3dbf704f..cac863bb 100644 --- a/src-core/Cargo.lock +++ b/src-core/Cargo.lock @@ -61,7 +61,7 @@ checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -111,21 +111,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - [[package]] name = "bitflags" version = "2.6.0" @@ -291,7 +276,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.76", + "syn", ] [[package]] @@ -302,7 +287,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -355,7 +340,7 @@ dependencies = [ "dsl_auto_type", "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -375,7 +360,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25" dependencies = [ - "syn 2.0.76", + "syn", ] [[package]] @@ -389,7 +374,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -471,16 +456,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures-channel" version = "0.3.30" @@ -586,20 +561,6 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" -[[package]] -name = "html5ever" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" -dependencies = [ - "log", - "mac", - "markup5ever", - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "http" version = "1.1.0" @@ -845,38 +806,6 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] -name = "markup5ever" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" -dependencies = [ - "log", - "phf", - "phf_codegen", - "string_cache", - "string_cache_codegen", - "tendril", -] - -[[package]] -name = "markup5ever_rcdom" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9521dd6750f8e80ee6c53d65e2e4656d7de37064f3a7a5d2d11d05df93839c2" -dependencies = [ - "html5ever", - "markup5ever", - "tendril", - "xml5ever", -] - [[package]] name = "memchr" version = "2.7.4" @@ -948,12 +877,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - [[package]] name = "num-bigint" version = "0.4.6" @@ -1026,7 +949,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -1076,44 +999,6 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_shared", -] - -[[package]] -name = "phf_codegen" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared", - "rand", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher", -] - [[package]] name = "pin-project" version = "1.1.5" @@ -1131,7 +1016,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -1167,12 +1052,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - [[package]] name = "proc-macro2" version = "1.0.86" @@ -1552,17 +1431,6 @@ dependencies = [ "libc", ] -[[package]] -name = "select" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f9da09dc3f4dfdb6374cbffff7a2cffcec316874d4429899eefdc97b3b94dcd" -dependencies = [ - "bit-set", - "html5ever", - "markup5ever_rcdom", -] - [[package]] name = "serde" version = "1.0.210" @@ -1580,7 +1448,7 @@ checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -1622,12 +1490,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "siphasher" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - [[package]] name = "slab" version = "0.4.9" @@ -1659,32 +1521,6 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -[[package]] -name = "string_cache" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b" -dependencies = [ - "new_debug_unreachable", - "once_cell", - "parking_lot", - "phf_shared", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", -] - [[package]] name = "strsim" version = "0.11.1" @@ -1697,17 +1533,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.76" @@ -1762,17 +1587,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - [[package]] name = "thiserror" version = "1.0.63" @@ -1790,7 +1604,7 @@ checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] @@ -2012,12 +1826,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "uuid" version = "1.10.0" @@ -2076,7 +1884,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.76", + "syn", "wasm-bindgen-shared", ] @@ -2110,7 +1918,7 @@ checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2294,25 +2102,13 @@ dependencies = [ "memchr", ] -[[package]] -name = "xml5ever" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4034e1d05af98b51ad7214527730626f019682d797ba38b51689212118d8e650" -dependencies = [ - "log", - "mac", - "markup5ever", -] - [[package]] name = "yahoo_finance_api" -version = "2.2.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a51e69ea2e11b657c7857c856b13be4ab64a8dcde9ea66093c5b61d7c6e12d" +checksum = "6a3281ec47078118649163dcdc751df4e788cc4ffa01baaab989f784523bbd62" dependencies = [ "reqwest", - "select", "serde", "serde_json", "thiserror", @@ -2337,7 +2133,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.76", + "syn", ] [[package]] diff --git a/src-core/Cargo.toml b/src-core/Cargo.toml index 09d75f1a..fd7f9395 100644 --- a/src-core/Cargo.toml +++ b/src-core/Cargo.toml @@ -17,7 +17,7 @@ chrono = { version = "0.4.38", features = ["serde"] } uuid = { version = "1.10.0", features = ["v4"] } rusqlite = { version = "0.32.1", features = ["bundled"] } csv = "1.3.0" -yahoo_finance_api = "2.2.1" +yahoo_finance_api = "2.3.0" regex = "1.10.6" reqwest = { version = "0.12.7", features = ["json", "cookies" ] } thiserror = "1.0.63" diff --git a/src-core/src/activity/activity_repository.rs b/src-core/src/activity/activity_repository.rs index bef5ff8e..27d5c2ea 100644 --- a/src-core/src/activity/activity_repository.rs +++ b/src-core/src/activity/activity_repository.rs @@ -23,7 +23,13 @@ impl ActivityRepository { 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!["BUY", "SELL", "SPLIT"])) + .filter(activities::activity_type.eq_any(vec![ + "BUY", + "SELL", + "SPLIT", + "TRANSFER_IN", + "TRANSFER_OUT", + ])) .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 194c083e..43fb8645 100644 --- a/src-core/src/activity/activity_service.rs +++ b/src-core/src/activity/activity_service.rs @@ -11,6 +11,7 @@ use crate::schema::activities; use csv::ReaderBuilder; use diesel::prelude::*; +use diesel::sql_types::{Double, Text}; use uuid::Uuid; @@ -89,11 +90,22 @@ impl ActivityService { 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.quantity = 1.0; + + // Handle different activity types + match activity.activity_type.as_str() { + "TRANSFER_OUT" => { + // Calculate the current average cost for the asset in this account + let current_avg_cost = self.calculate_average_cost( + conn, + &activity.account_id, + &activity.asset_id, + )?; + activity.unit_price = current_avg_cost; + } + "DEPOSIT" | "WITHDRAWAL" | "INTEREST" | "FEE" | "DIVIDEND" => { + activity.quantity = 1.0; + } + _ => {} } // Create exchange rate if asset currency is different from account currency @@ -132,11 +144,22 @@ impl ActivityService { 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.quantity = 1.0; + + // Handle different activity types + match activity.activity_type.as_str() { + "TRANSFER_OUT" => { + // Calculate the current average cost for the asset in this account + let current_avg_cost = self.calculate_average_cost( + conn, + &activity.account_id, + &activity.asset_id, + )?; + activity.unit_price = current_avg_cost; + } + "DEPOSIT" | "WITHDRAWAL" | "INTEREST" | "FEE" | "DIVIDEND" => { + activity.quantity = 1.0; + } + _ => {} } // Create exchange rate if asset currency is different from account currency @@ -281,4 +304,45 @@ impl ActivityService { ) -> Result, diesel::result::Error> { self.repo.get_activities_by_account_ids(conn, account_ids) } + + fn calculate_average_cost( + &self, + conn: &mut SqliteConnection, + account_id: &str, + asset_id: &str, + ) -> Result> { + #[derive(QueryableByName, Debug)] + struct AverageCost { + #[sql_type = "Double"] + average_cost: f64, + } + + let result: AverageCost = diesel::sql_query( + r#" + WITH running_totals AS ( + SELECT + quantity, + unit_price, + quantity AS quantity_change, + quantity * unit_price AS value_change, + SUM(quantity) OVER (ORDER BY activity_date, id) AS running_quantity, + SUM(quantity * unit_price) OVER (ORDER BY activity_date, id) AS running_value + FROM activities + WHERE account_id = ?1 AND asset_id = ?2 + AND activity_type IN ('BUY', 'TRANSFER_IN') + ) + SELECT + CASE + WHEN SUM(quantity_change) > 0 THEN SUM(value_change) / SUM(quantity_change) + ELSE 0 + END AS average_cost + FROM running_totals + "#, + ) + .bind::(account_id) + .bind::(asset_id) + .get_result(conn)?; + + Ok(result.average_cost) + } } diff --git a/src-core/src/portfolio/history_service.rs b/src-core/src/portfolio/history_service.rs index 69627d5a..272b4f2c 100644 --- a/src-core/src/portfolio/history_service.rs +++ b/src-core/src/portfolio/history_service.rs @@ -10,6 +10,7 @@ use diesel::prelude::*; use diesel::SqliteConnection; use rayon::prelude::*; use std::collections::HashMap; +use std::default::Default; use std::str::FromStr; use std::sync::Arc; @@ -19,6 +20,17 @@ pub struct HistoryService { fx_service: CurrencyExchangeService, } +impl Default for HistorySummary { + fn default() -> Self { + HistorySummary { + id: None, + start_date: String::new(), + end_date: String::new(), + entries_count: 0, + } + } +} + impl HistoryService { pub fn new(base_currency: String, market_data_service: Arc) -> Self { Self { @@ -28,20 +40,37 @@ impl HistoryService { } } - pub fn get_account_history( + pub fn get_all_accounts_history( &self, conn: &mut SqliteConnection, - input_account_id: &str, ) -> Result> { use crate::schema::portfolio_history::dsl::*; - use diesel::prelude::*; - let history_data: Vec = portfolio_history - .filter(account_id.eq(input_account_id)) - .order(date.asc()) - .load::(conn)?; + let result = portfolio_history + // .filter(account_id.ne("TOTAL")) + .load::(conn) + .map_err(PortfolioError::from)?; // Convert diesel::result::Error to PortfolioError - Ok(history_data) + Ok(result) + } + + pub fn get_portfolio_history( + &self, + conn: &mut SqliteConnection, + input_account_id: Option<&str>, + ) -> Result> { + use crate::schema::portfolio_history::dsl::*; + + let mut query = portfolio_history.into_boxed(); + + if let Some(other_id) = input_account_id { + query = query.filter(account_id.eq(other_id)); + } + + query + .order(date.asc()) + .load::(conn) + .map_err(PortfolioError::from) } pub fn get_latest_account_history( @@ -50,15 +79,12 @@ impl HistoryService { input_account_id: &str, ) -> Result { use crate::schema::portfolio_history::dsl::*; - use diesel::prelude::*; - let latest_history: PortfolioHistory = portfolio_history + portfolio_history .filter(account_id.eq(input_account_id)) .order(date.desc()) .first(conn) - .map_err(|e| PortfolioError::DatabaseError(e))?; - - Ok(latest_history) + .map_err(|e| PortfolioError::DatabaseError(e)) } pub fn calculate_historical_data( @@ -68,179 +94,260 @@ impl HistoryService { activities: &[Activity], force_full_calculation: bool, ) -> Result> { - self.fx_service - .initialize(conn) - .map_err(|e| PortfolioError::CurrencyConversionError(e.to_string()))?; + self.initialize_fx_service(conn)?; + // Load and prepare all required data let end_date = Utc::now().naive_utc().date(); let quotes = Arc::new(self.market_data_service.load_quotes(conn)); + let account_activities = self.group_activities_by_account(activities); + let account_currencies = self.get_account_currencies(conn, accounts)?; + let asset_currencies = self.get_asset_currencies(conn, activities); - // Preload all necessary data - let account_activities: HashMap> = - activities - .iter() - .cloned() - .fold(HashMap::new(), |mut acc, activity| { - acc.entry(activity.account_id.clone()) - .or_default() - .push(activity); - acc - }); + let all_last_histories = if !force_full_calculation { + self.get_all_last_portfolio_histories(conn, accounts)? + } else { + HashMap::new() + }; - let last_historical_dates: HashMap> = accounts - .iter() + let (start_dates, last_histories) = self.calculate_start_dates_and_last_histories( + accounts, + &account_activities, + &all_last_histories, + force_full_calculation, + ); + + // Parallel calculations without database access + let summaries_and_histories: Vec<(HistorySummary, Vec)> = accounts + .par_iter() .map(|account| { - let last_date = self - .get_last_historical_date(conn, &account.id) - .unwrap_or(None); - (account.id.clone(), last_date) + self.calculate_account_history( + account, + &account_activities, + "es, + &start_dates, + &last_histories, + end_date, + &account_currencies, + &asset_currencies, + ) }) .collect(); - let account_currencies: HashMap = accounts + // Process results + 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(); + + // If force_full_calculation is true, delete existing history for the accounts and TOTAL + if force_full_calculation { + println!( + "Deleting existing history for the accounts {:?} and TOTAL", + accounts.len() + ); + self.delete_existing_history(conn, accounts)?; + } + + // Save data + self.save_historical_data(&account_histories, conn)?; + + let total_history = self.calculate_total_portfolio_history(conn)?; + self.save_historical_data(&total_history, conn)?; + + let total_summary = self.create_total_summary(&total_history); + summaries.push(total_summary); + + Ok(summaries) + } + + fn initialize_fx_service(&self, conn: &mut SqliteConnection) -> Result<()> { + self.fx_service + .initialize(conn) + .map_err(|e| PortfolioError::CurrencyConversionError(e.to_string())) + } + + fn group_activities_by_account( + &self, + activities: &[Activity], + ) -> HashMap> { + activities.iter().fold(HashMap::new(), |mut acc, activity| { + acc.entry(activity.account_id.clone()) + .or_default() + .push(activity.clone()); + acc + }) + } + + fn get_account_currencies( + &self, + conn: &mut SqliteConnection, + accounts: &[Account], + ) -> Result> { + accounts .iter() .map(|account| { let currency = self .get_account_currency(conn, &account.id) .unwrap_or(self.base_currency.clone()); - (account.id.clone(), currency) + Ok((account.id.clone(), currency)) }) - .collect(); + .collect() + } - // Load asset currencies + fn get_asset_currencies( + &self, + conn: &mut SqliteConnection, + activities: &[Activity], + ) -> HashMap { let asset_ids: Vec = activities.iter().map(|a| a.asset_id.clone()).collect(); - let asset_currencies = self - .market_data_service - .get_asset_currencies(conn, asset_ids); + self.market_data_service + .get_asset_currencies(conn, asset_ids) + } - // Process accounts in parallel and collect results - let last_histories: HashMap> = accounts - .iter() - .map(|account| { - let last_history = if force_full_calculation { - None - } else { - self.get_last_portfolio_history(conn, &account.id) - .unwrap_or(None) - }; - (account.id.clone(), last_history) - }) - .collect(); + fn calculate_start_dates_and_last_histories( + &self, + accounts: &[Account], + account_activities: &HashMap>, + all_last_histories: &HashMap>, + force_full_calculation: bool, + ) -> ( + HashMap, + HashMap>, + ) { + let mut start_dates = HashMap::new(); + let mut last_histories = HashMap::new(); - let summaries_and_histories: Vec<(HistorySummary, Vec)> = accounts - .par_iter() - .map(|account| { - let account_activities = account_activities + for account in accounts { + let start_date = if force_full_calculation { + account_activities .get(&account.id) - .cloned() - .unwrap_or_default(); - - 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 { - last_historical_dates + .and_then(|activities| activities.iter().map(|a| a.activity_date.date()).min()) + .unwrap_or_else(|| Utc::now().naive_utc().date()) + } else { + match all_last_histories.get(&account.id) { + Some(Some(history)) => NaiveDate::parse_from_str(&history.date, "%Y-%m-%d") + .map(|date| date + Duration::days(1)) + .unwrap_or_else(|_| Utc::now().naive_utc().date()), + _ => account_activities .get(&account.id) - .cloned() - .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()) + .and_then(|activities| { + activities.iter().map(|a| a.activity_date.date()).min() }) - }; - - let account_currency = account_currencies - .get(&account.id) - .cloned() - .unwrap_or(self.base_currency.clone()); + .unwrap_or_else(|| Utc::now().naive_utc().date()), + } + }; - let last_history = last_histories.get(&account.id).cloned().unwrap_or(None); + start_dates.insert(account.id.clone(), start_date); - let new_history = self.calculate_historical_value( - &account.id, - &account_activities, - "es, - account_start_date, - end_date, - account_currency, - &asset_currencies, - last_history, - force_full_calculation, + if !force_full_calculation { + last_histories.insert( + account.id.clone(), + all_last_histories.get(&account.id).cloned().flatten(), ); + } + } - 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(), - }; + (start_dates, last_histories) + } - (summary, new_history) - }) - .collect(); + fn get_all_last_portfolio_histories( + &self, + conn: &mut SqliteConnection, + accounts: &[Account], + ) -> Result>> { + use crate::schema::portfolio_history::dsl::*; + use diesel::dsl::max; - // 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 + let account_ids: Vec = accounts.iter().map(|a| a.id.clone()).collect(); + + let last_histories: Vec<(String, Option)> = portfolio_history + .filter(account_id.eq_any(&account_ids)) + .group_by(account_id) + .select((account_id, max(date).nullable())) + .load::<(String, Option)>(conn)? .into_iter() - .flat_map(|(_, histories)| histories) + .map(|(acc_id, max_date)| { + let history = max_date.and_then(|other_date| { + portfolio_history + .filter(account_id.eq(&acc_id)) + .filter(date.eq(other_date)) + .first::(conn) + .ok() + }); + (acc_id, history) + }) .collect(); - // Save account histories - self.save_historical_data(&account_histories, conn)?; + Ok(last_histories.into_iter().collect()) + } - // Calculate total portfolio history - let total_history = self.calculate_total_portfolio_history_for_all_accounts(conn)?; + fn calculate_account_history( + &self, + account: &Account, + account_activities: &HashMap>, + quotes: &Arc>, + start_dates: &HashMap, + last_histories: &HashMap>, + end_date: NaiveDate, + account_currencies: &HashMap, + asset_currencies: &HashMap, + ) -> (HistorySummary, Vec) { + let activities = account_activities + .get(&account.id) + .cloned() + .unwrap_or_default(); - // Save total history separately - self.save_historical_data(&total_history, conn)?; + if activities.is_empty() { + return self.create_empty_summary_and_history(&account.id); + } - 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(), - }; + let start_date = *start_dates.get(&account.id).unwrap(); + let account_currency = account_currencies + .get(&account.id) + .cloned() + .unwrap_or(self.base_currency.clone()); + let last_history = last_histories.get(&account.id).cloned().unwrap_or(None); + + let new_history = self.calculate_historical_value( + &account.id, + &activities, + quotes, + start_date, + end_date, + account_currency, + asset_currencies, + last_history, + ); + + let summary = self.create_summary(&account.id, &new_history); + + (summary, new_history) + } - summaries.push(total_summary); - Ok(summaries) + fn create_empty_summary_and_history( + &self, + account_id: &str, + ) -> (HistorySummary, Vec) { + let mut summary = HistorySummary::default(); + summary.id = Some(account_id.to_string()); + (summary, Vec::new()) } - fn calculate_total_portfolio_history_for_all_accounts( + fn create_summary(&self, account_id: &str, history: &[PortfolioHistory]) -> HistorySummary { + HistorySummary { + id: Some(account_id.to_string()), + start_date: history.first().map(|h| h.date.clone()).unwrap_or_default(), + end_date: history.last().map(|h| h.date.clone()).unwrap_or_default(), + entries_count: history.len(), + } + } + + fn calculate_total_portfolio_history( &self, conn: &mut SqliteConnection, ) -> Result> { @@ -325,6 +432,28 @@ impl HistoryService { Ok(total_history) } + fn calculate_cumulative_split_factors( + &self, + activities: &[Activity], + end_date: NaiveDate, + ) -> HashMap { + let mut cumulative_split_factors: HashMap = HashMap::new(); + + for activity in activities + .iter() + .filter(|a| a.activity_date.date() <= end_date) + { + if activity.activity_type == "SPLIT" { + let split_ratio = BigDecimal::from_str(&activity.unit_price.to_string()).unwrap(); + *cumulative_split_factors + .entry(activity.asset_id.clone()) + .or_insert(BigDecimal::from(1)) *= split_ratio; + } + } + + cumulative_split_factors + } + fn calculate_historical_value( &self, account_id: &str, @@ -335,7 +464,6 @@ impl HistoryService { account_currency: String, asset_currencies: &HashMap, last_history: Option, - force_full_calculation: bool, ) -> Vec { let max_history_days = 36500; // For example, 100 years let today = Utc::now().naive_utc().date(); @@ -369,22 +497,13 @@ impl HistoryService { .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 all_dates = Self::get_days_between(start_date, end_date); let quote_cache: DashMap<(String, NaiveDate), Option<&Quote>> = DashMap::new(); + let cumulative_split_factors = + self.calculate_cumulative_split_factors(activities, end_date); + let results: Vec = all_dates .iter() .map(|&date| { @@ -397,6 +516,7 @@ impl HistoryService { &mut net_deposit, &mut book_cost, &account_currency, + &cumulative_split_factors, ); } @@ -473,6 +593,7 @@ impl HistoryService { net_deposit: &mut BigDecimal, book_cost: &mut BigDecimal, account_currency: &str, + cumulative_split_factors: &HashMap, ) { // Get exchange rate if activity currency is different from account currency let exchange_rate = BigDecimal::from_str( @@ -484,39 +605,98 @@ impl HistoryService { ) .unwrap(); + let activity_fee = + BigDecimal::from_str(&activity.fee.to_string()).unwrap() * &exchange_rate; + let activity_amount = BigDecimal::from_str(&activity.quantity.to_string()).unwrap() * BigDecimal::from_str(&activity.unit_price.to_string()).unwrap() * &exchange_rate; - let activity_fee = - BigDecimal::from_str(&activity.fee.to_string()).unwrap() * &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(BigDecimal::from(0)) += - BigDecimal::from_str(&activity.quantity.to_string()).unwrap(); + "BUY" | "SELL" => { + let quantity = BigDecimal::from_str(&activity.quantity.to_string()).unwrap(); + let price = BigDecimal::from_str(&activity.unit_price.to_string()).unwrap(); + let amount = &quantity * &price * &exchange_rate; + + let split_factor = cumulative_split_factors + .get(&activity.asset_id) + .cloned() + .unwrap_or_else(|| BigDecimal::from(1)); + + if activity.activity_type == "BUY" { + let buy_cost = &amount + &activity_fee; + *cumulative_cash -= &buy_cost; + *book_cost += &buy_cost; + *holdings + .entry(activity.asset_id.clone()) + .or_insert(BigDecimal::from(0)) += &quantity * &split_factor; + } else { + // SELL + let sell_profit = &amount - &activity_fee; + *cumulative_cash += &sell_profit; + let old_quantity = holdings + .get(&activity.asset_id) + .cloned() + .unwrap_or_default(); + if old_quantity != BigDecimal::from(0) { + let sell_ratio = (&quantity / &old_quantity).round(6); + let adjustment = sell_ratio * book_cost.clone(); + *book_cost -= adjustment; + } + *holdings + .entry(activity.asset_id.clone()) + .or_insert(BigDecimal::from(0)) -= &quantity * &split_factor; + } } - "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(BigDecimal::from(0)) -= - BigDecimal::from_str(&activity.quantity.to_string()).unwrap(); + "TRANSFER_IN" | "TRANSFER_OUT" => { + if activity.asset_id.starts_with("$CASH") { + // Treat as cash transfer + if activity.activity_type == "TRANSFER_IN" { + *cumulative_cash += &activity_amount - &activity_fee; + *net_deposit += &activity_amount; + } else { + // TRANSFER_OUT + *cumulative_cash -= &activity_amount + &activity_fee; + *net_deposit -= &activity_amount; + } + } else { + // Treat as asset transfer (existing code) + let quantity = BigDecimal::from_str(&activity.quantity.to_string()).unwrap(); + let split_factor = cumulative_split_factors + .get(&activity.asset_id) + .cloned() + .unwrap_or_else(|| BigDecimal::from(1)); + + if activity.activity_type == "TRANSFER_IN" { + *holdings + .entry(activity.asset_id.clone()) + .or_insert(BigDecimal::from(0)) += &quantity * &split_factor; + *book_cost += &activity_amount; + } else { + // TRANSFER_OUT + let old_quantity = holdings + .get(&activity.asset_id) + .cloned() + .unwrap_or_default(); + if old_quantity != BigDecimal::from(0) { + let transfer_ratio = (&quantity / &old_quantity).round(6); + let adjustment = transfer_ratio * book_cost.clone(); + *book_cost -= adjustment; + } + *holdings + .entry(activity.asset_id.clone()) + .or_insert(BigDecimal::from(0)) -= &quantity * &split_factor; + } + } } - "DEPOSIT" | "TRANSFER_IN" | "CONVERSION_IN" => { + "DEPOSIT" | "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" => { + "WITHDRAWAL" | "CONVERSION_OUT" => { *cumulative_cash -= &activity_amount + &activity_fee; *net_deposit -= &activity_amount; } @@ -612,6 +792,7 @@ impl HistoryService { ) .unwrap(); + // No need to adjust quantity here, as it's already adjusted in process_activity let holding_value = quantity * BigDecimal::from_str("e.close.to_string()).unwrap() * &exchange_rate; @@ -652,27 +833,6 @@ impl HistoryService { .clone() } - fn get_last_portfolio_history( - &self, - conn: &mut SqliteConnection, - some_account_id: &str, - ) -> Result> { - use crate::schema::portfolio_history::dsl::*; - - let last_history_opt = portfolio_history - .filter(account_id.eq(some_account_id)) - .order(date.desc()) - .first::(conn) - .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; @@ -685,30 +845,6 @@ impl HistoryService { days } - fn get_last_historical_date( - &self, - conn: &mut SqliteConnection, - some_account_id: &str, - ) -> Result> { - use crate::schema::portfolio_history::dsl::*; - - let last_date_opt = portfolio_history - .filter(account_id.eq(some_account_id)) - .select(date) - .order(date.desc()) - .first::(conn) - .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, @@ -723,4 +859,40 @@ impl HistoryService { .first::(conn) .map_err(PortfolioError::from) } + + // Add this method to the HistoryService impl block + + fn create_total_summary(&self, total_history: &[PortfolioHistory]) -> HistorySummary { + 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(), + } + } + + // Updated method to delete existing history including TOTAL + fn delete_existing_history( + &self, + conn: &mut SqliteConnection, + accounts: &[Account], + ) -> Result<()> { + use crate::schema::portfolio_history::dsl::*; + use diesel::delete; + + let mut account_ids: Vec = accounts.iter().map(|a| a.id.clone()).collect(); + account_ids.push("TOTAL".to_string()); + + delete(portfolio_history.filter(account_id.eq_any(account_ids))) + .execute(conn) + .map_err(PortfolioError::from)?; + + Ok(()) + } } diff --git a/src-core/src/portfolio/holdings_service.rs b/src-core/src/portfolio/holdings_service.rs index 2072428b..73384005 100644 --- a/src-core/src/portfolio/holdings_service.rs +++ b/src-core/src/portfolio/holdings_service.rs @@ -3,13 +3,18 @@ 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::{Account, Holding, Performance}; +use crate::models::{Account, Activity, Asset, Holding, Performance, Quote}; use bigdecimal::BigDecimal; -use bigdecimal::FromPrimitive; use diesel::SqliteConnection; use std::collections::{HashMap, HashSet}; use std::str::FromStr; +impl From for PortfolioError { + fn from(error: bigdecimal::ParseBigDecimalError) -> Self { + PortfolioError::InvalidDataError(error.to_string()) + } +} + pub struct HoldingsService { account_service: AccountService, activity_service: ActivityService, @@ -20,7 +25,7 @@ pub struct HoldingsService { impl HoldingsService { pub async fn new(base_currency: String) -> Self { - HoldingsService { + Self { account_service: AccountService::new(base_currency.clone()), activity_service: ActivityService::new(base_currency.clone()), asset_service: AssetService::new().await, @@ -30,7 +35,6 @@ impl HoldingsService { } pub fn compute_holdings(&self, conn: &mut SqliteConnection) -> Result> { - let mut holdings: HashMap = HashMap::new(); let accounts = self.account_service.get_active_accounts(conn)?; let activities = self.activity_service.get_trading_activities(conn)?; let assets = self.asset_service.get_assets(conn)?; @@ -38,6 +42,23 @@ impl HoldingsService { .initialize(conn) .map_err(|e| PortfolioError::CurrencyConversionError(e.to_string()))?; + let mut holdings = self.aggregate_holdings(&accounts, &activities, &assets)?; + let quotes = self.fetch_quotes(conn, &holdings)?; + + self.calculate_holding_metrics(&mut holdings, "es)?; + self.calculate_total_holdings(&mut holdings)?; + + Ok(self.filter_holdings(holdings)) + } + + fn aggregate_holdings( + &self, + accounts: &[Account], + activities: &[Activity], + assets: &[Asset], + ) -> Result> { + let mut holdings = HashMap::new(); + for activity in activities { let asset = assets .iter() @@ -50,320 +71,339 @@ impl HoldingsService { .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(|| self.create_holding(key, activity, asset, account)); + + self.update_holding(holding, activity)?; + } - 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: BigDecimal::from(0), - currency: activity.currency.clone(), - base_currency: self.base_currency.clone(), - market_price: None, - average_cost: None, - market_value: BigDecimal::from(0), - book_value: BigDecimal::from(0), - market_value_converted: BigDecimal::from(0), - book_value_converted: BigDecimal::from(0), - performance: Performance { - total_gain_percent: BigDecimal::from(0), - total_gain_amount: BigDecimal::from(0), - total_gain_amount_converted: BigDecimal::from(0), - day_gain_percent: Some(BigDecimal::from(0)), - day_gain_amount: Some(BigDecimal::from(0)), - day_gain_amount_converted: Some(BigDecimal::from(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()), - portfolio_percent: None, - }); - - let quantity = BigDecimal::from_str(&activity.quantity.to_string()) - .unwrap() - .round(6); - let unit_price = BigDecimal::from_str(&activity.unit_price.to_string()) - .unwrap() - .round(6); - let fee = BigDecimal::from_str(&activity.fee.to_string()) - .unwrap() - .round(6); - - let old_quantity = holding.quantity.clone(); - let old_book_value = holding.book_value.clone(); - - match activity.activity_type.as_str() { - "BUY" => { - holding.quantity = (&holding.quantity + &quantity).round(6); - holding.book_value = - (&holding.book_value + &quantity * &unit_price + &fee).round(6); + Ok(holdings) + } + + fn create_holding( + &self, + key: String, + activity: &Activity, + asset: &Asset, + account: &Account, + ) -> Holding { + Holding { + id: key, + symbol: activity.asset_id.clone(), + symbol_name: asset.name.clone(), + holding_type: asset.asset_type.clone().unwrap_or_default(), + quantity: BigDecimal::from(0), + currency: activity.currency.clone(), + base_currency: self.base_currency.clone(), + market_price: None, + average_cost: None, + market_value: BigDecimal::from(0), + book_value: BigDecimal::from(0), + market_value_converted: BigDecimal::from(0), + book_value_converted: BigDecimal::from(0), + performance: Performance::default(), + account: Some(account.clone()), + asset_class: asset.asset_class.clone(), + asset_sub_class: asset.asset_sub_class.clone(), + sectors: asset + .sectors + .clone() + .and_then(|s| serde_json::from_str(&s).ok()), + portfolio_percent: None, + } + } + + fn update_holding(&self, holding: &mut Holding, activity: &Activity) -> Result<()> { + let quantity = BigDecimal::from_str(&activity.quantity.to_string())?; + let unit_price = BigDecimal::from_str(&activity.unit_price.to_string())?; + let fee = BigDecimal::from_str(&activity.fee.to_string())?; + + let old_quantity = holding.quantity.clone(); + let old_book_value = holding.book_value.clone(); + + match activity.activity_type.as_str() { + "BUY" | "TRANSFER_IN" => { + holding.quantity += &quantity; + holding.book_value += &quantity * &unit_price + &fee; + } + "SELL" | "TRANSFER_OUT" => { + holding.quantity -= &quantity; + if old_quantity != BigDecimal::from(0) { + let sell_ratio = (&quantity / &old_quantity).round(6); + holding.book_value -= &sell_ratio * &old_book_value; } - "SELL" => { - holding.quantity = (&holding.quantity - &quantity).round(6); - // For sell transactions, we should reduce the book value proportionally - if old_quantity != BigDecimal::from(0) { - let sell_ratio = (&quantity / &old_quantity).round(6); - holding.book_value = - (&holding.book_value - &sell_ratio * &old_book_value).round(6); + } + "SPLIT" => { + let split_ratio = unit_price; + if split_ratio != BigDecimal::from(0) { + holding.quantity *= &split_ratio; + if let Some(avg_cost) = holding.average_cost.as_mut() { + *avg_cost = avg_cost.clone() / &split_ratio; } + } else { + return Err(PortfolioError::InvalidDataError( + "Invalid split ratio".to_string(), + )); } - _ => println!("Unhandled activity type: {}", activity.activity_type), } + _ => 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(); + holding.quantity = holding.quantity.round(6); + holding.book_value = holding.book_value.round(6); - let symbols: Vec = unique_symbols.into_iter().collect(); + Ok(()) + } - // Fetch quotes for each symbol asynchronously + fn fetch_quotes( + &self, + conn: &mut SqliteConnection, + holdings: &HashMap, + ) -> Result> { + let unique_symbols: HashSet = holdings.values().map(|h| h.symbol.clone()).collect(); let mut quotes = HashMap::new(); - for symbol in symbols { + + for symbol in unique_symbols { match self.asset_service.get_latest_quote(conn, &symbol) { Ok(quote) => { quotes.insert(symbol.clone(), quote); } - Err(e) => { - eprintln!("Error fetching quote for symbol {}: {}", symbol, e); - } + Err(e) => eprintln!("Error fetching quote for symbol {}: {}", symbol, e), } } - // Post-processing for each holding + Ok(quotes) + } + + fn calculate_holding_metrics( + &self, + holdings: &mut HashMap, + quotes: &HashMap, + ) -> Result<()> { for holding in holdings.values_mut() { if let Some(quote) = quotes.get(&holding.symbol) { - holding.market_price = Some(BigDecimal::from_f64(quote.close).unwrap().round(6)); - - // Calculate market_value in stock currency - holding.market_value = (&holding.quantity - * holding - .market_price - .clone() - .unwrap_or_else(|| BigDecimal::from(0))) - .round(6); - - // Calculate day gain using quote open and close prices - let opening_value = - (&holding.quantity * BigDecimal::from_f64(quote.open).unwrap()).round(6); - let closing_value = - (&holding.quantity * BigDecimal::from_f64(quote.close).unwrap()).round(6); - holding.performance.day_gain_amount = - Some((&closing_value - &opening_value).round(6)); - holding.performance.day_gain_percent = - Some(if opening_value != BigDecimal::from(0) { - ((&closing_value - &opening_value) / &opening_value * BigDecimal::from(100)) - .round(6) - } else { - BigDecimal::from(0) - }); + self.update_holding_with_quote(holding, quote)?; } - holding.average_cost = if holding.quantity != BigDecimal::from(0) { - Some((&holding.book_value / &holding.quantity).round(6)) - } else { - None - }; + self.calculate_average_cost(holding); + self.convert_to_base_currency(holding)?; + self.calculate_performance_metrics(holding); + } - let account_currency = holding - .account - .as_ref() - .map(|a| &a.currency) - .unwrap_or(&self.base_currency); - - // Get exchange rate from holding's currency to account currency, then to base currency - let exchange_rate = match self - .fx_service - .get_latest_exchange_rate(&holding.currency, &account_currency) - { - Ok(rate) => BigDecimal::from_f64(rate).unwrap(), - Err(e) => { - eprintln!( - "Error getting exchange rate for {} to {}: {}. Using 1 as default.", - holding.currency, account_currency, e - ); - BigDecimal::from_f64(1.0).unwrap() - } - }; + Ok(()) + } - // Calculate market_value_converted in base currency - holding.market_value_converted = (&holding.market_value * &exchange_rate).round(6); - holding.book_value_converted = (&holding.book_value * &exchange_rate).round(6); + fn update_holding_with_quote(&self, holding: &mut Holding, quote: &Quote) -> Result<()> { + holding.market_price = Some( + BigDecimal::from_str("e.close.to_string()) + .map_err(|_| PortfolioError::InvalidDataError("Invalid market price".to_string()))? + .round(6), + ); + holding.market_value = (&holding.quantity + * holding + .market_price + .clone() + .unwrap_or_else(|| BigDecimal::from(0))) + .round(6); + + let opening_value = (&holding.quantity + * BigDecimal::from_str("e.open.to_string()).map_err(|_| { + PortfolioError::InvalidDataError("Invalid opening price".to_string()) + })?) + .round(6); + let closing_value = (&holding.quantity + * BigDecimal::from_str("e.close.to_string()).map_err(|_| { + PortfolioError::InvalidDataError("Invalid closing price".to_string()) + })?) + .round(6); + holding.performance.day_gain_amount = Some((&closing_value - &opening_value).round(6)); + holding.performance.day_gain_percent = Some(if opening_value != BigDecimal::from(0) { + ((&closing_value - &opening_value) / &opening_value * BigDecimal::from(100)).round(6) + } else { + BigDecimal::from(0) + }); + + Ok(()) + } - // Calculate performance metrics - holding.performance.total_gain_amount = - (&holding.market_value - &holding.book_value).round(6); - holding.performance.total_gain_percent = if holding.book_value != BigDecimal::from(0) { - (&holding.performance.total_gain_amount / &holding.book_value - * BigDecimal::from(100)) - .round(6) - } else { - BigDecimal::from(0) - }; - holding.performance.total_gain_amount_converted = - (&holding.performance.total_gain_amount * &exchange_rate).round(6); + fn calculate_average_cost(&self, holding: &mut Holding) { + holding.average_cost = if holding.quantity != BigDecimal::from(0) { + Some((&holding.book_value / &holding.quantity).round(6)) + } else { + None + }; + } - // Convert day gain to base currency - if let Some(day_gain_amount) = holding.performance.day_gain_amount.as_ref() { - holding.performance.day_gain_amount_converted = - Some((day_gain_amount * &exchange_rate).round(6)); - } + fn convert_to_base_currency(&self, holding: &mut Holding) -> Result<()> { + let account_currency = holding + .account + .as_ref() + .map(|a| &a.currency) + .unwrap_or(&self.base_currency); + let exchange_rate = self + .fx_service + .get_latest_exchange_rate(&holding.currency, account_currency) + .map(|rate| { + BigDecimal::from_str(&rate.to_string()).map_err(|_| { + PortfolioError::InvalidDataError("Invalid exchange rate".to_string()) + }) + }) + .unwrap_or_else(|_| Ok(BigDecimal::from(1)))?; + + holding.market_value_converted = (&holding.market_value * &exchange_rate).round(6); + holding.book_value_converted = (&holding.book_value * &exchange_rate).round(6); + + if let Some(day_gain_amount) = holding.performance.day_gain_amount.as_ref() { + holding.performance.day_gain_amount_converted = + Some((day_gain_amount * &exchange_rate).round(6)); } - // Aggregate holdings for the TOTAL account - let mut total_holdings: HashMap = HashMap::new(); + Ok(()) + } + + fn calculate_performance_metrics(&self, holding: &mut Holding) { + holding.performance.total_gain_amount = + (&holding.market_value - &holding.book_value).round(6); + holding.performance.total_gain_percent = if holding.book_value != BigDecimal::from(0) { + (&holding.performance.total_gain_amount / &holding.book_value * BigDecimal::from(100)) + .round(6) + } else { + BigDecimal::from(0) + }; + holding.performance.total_gain_amount_converted = + (&holding.market_value_converted - &holding.book_value_converted).round(6); + } + + fn calculate_total_holdings(&self, holdings: &mut HashMap) -> Result<()> { + let mut total_holdings = HashMap::new(); for holding in holdings.values() { let total_key = holding.symbol.clone(); - let total_holding = total_holdings.entry(total_key).or_insert_with(|| Holding { - id: format!("TOTAL-{}", holding.symbol), - symbol: holding.symbol.clone(), - symbol_name: holding.symbol_name.clone(), - holding_type: holding.holding_type.clone(), - quantity: BigDecimal::from(0), - currency: holding.currency.clone(), - base_currency: self.base_currency.clone(), - market_price: None, - average_cost: None, - market_value: BigDecimal::from(0), - book_value: BigDecimal::from(0), - market_value_converted: BigDecimal::from(0), - book_value_converted: BigDecimal::from(0), - performance: Performance::default(), - account: Some(Account { - id: "TOTAL".to_string(), - name: "Total Portfolio".to_string(), - account_type: "Virtual".to_string(), - group: None, - currency: self.base_currency.clone(), - is_default: false, - is_active: true, - created_at: chrono::Utc::now().naive_utc(), - updated_at: chrono::Utc::now().naive_utc(), - platform_id: None, - }), - asset_class: holding.asset_class.clone(), - asset_sub_class: holding.asset_sub_class.clone(), - sectors: holding.sectors.clone(), - portfolio_percent: None, - }); - - // Aggregate quantities and values - total_holding.quantity += &holding.quantity; - total_holding.market_value += &holding.market_value; - total_holding.market_value_converted += &holding.market_value_converted; - total_holding.book_value += &holding.book_value; - total_holding.book_value_converted += &holding.book_value_converted; + let total_holding = total_holdings + .entry(total_key) + .or_insert_with(|| self.create_total_holding(holding)); + + self.aggregate_total_holding(total_holding, holding); } - // Calculate performance metrics for total holdings - for total_holding in total_holdings.values_mut() { - total_holding.market_price = Some(if total_holding.quantity != BigDecimal::from(0) { - (&total_holding.market_value / &total_holding.quantity).round(6) - } else { - BigDecimal::from(0) - }); + self.calculate_total_holding_metrics(&mut total_holdings)?; + holdings.extend(total_holdings); - total_holding.average_cost = Some(if total_holding.quantity != BigDecimal::from(0) { - (&total_holding.book_value / &total_holding.quantity).round(6) - } else { - BigDecimal::from(0) - }); - - total_holding.performance.total_gain_amount = - (&total_holding.market_value - &total_holding.book_value).round(6); - total_holding.performance.total_gain_amount_converted = - (&total_holding.market_value_converted - &total_holding.book_value_converted) - .round(6); - - total_holding.performance.total_gain_percent = - if total_holding.book_value != BigDecimal::from(0) { - (&total_holding.performance.total_gain_amount / &total_holding.book_value - * BigDecimal::from(100)) - .round(6) - } else { - BigDecimal::from(0) - }; - - // Calculate day gain for total holdings - if let Some(quote) = quotes.get(&total_holding.symbol) { - let opening_value = - (&total_holding.quantity * BigDecimal::from_f64(quote.open).unwrap()).round(6); - let closing_value = - (&total_holding.quantity * BigDecimal::from_f64(quote.close).unwrap()).round(6); - - total_holding.performance.day_gain_amount = - Some((&closing_value - &opening_value).round(6)); - - total_holding.performance.day_gain_percent = - Some(if opening_value != BigDecimal::from(0) { - ((&closing_value - &opening_value) / &opening_value * BigDecimal::from(100)) - .round(6) - } else { - BigDecimal::from(0) - }); - - // Convert day gain to base currency - let exchange_rate = match self - .fx_service - .get_latest_exchange_rate(&total_holding.currency, &self.base_currency.clone()) - { - Ok(rate) => BigDecimal::from_f64(rate).unwrap(), - Err(e) => { - eprintln!( - "Error getting exchange rate for {} to {}: {}. Using 1 as default.", - total_holding.currency, - self.base_currency.clone(), - e - ); - BigDecimal::from_f64(1.0).unwrap() - } - }; - total_holding.performance.day_gain_amount_converted = total_holding - .performance - .day_gain_amount - .as_ref() - .map(|amount| (amount * &exchange_rate).round(6)); - } + Ok(()) + } + + fn create_total_holding(&self, holding: &Holding) -> Holding { + Holding { + id: format!("TOTAL-{}", holding.symbol), + symbol: holding.symbol.clone(), + symbol_name: holding.symbol_name.clone(), + holding_type: holding.holding_type.clone(), + quantity: BigDecimal::from(0), + currency: holding.currency.clone(), + base_currency: self.base_currency.clone(), + market_price: None, + average_cost: None, + market_value: BigDecimal::from(0), + book_value: BigDecimal::from(0), + market_value_converted: BigDecimal::from(0), + book_value_converted: BigDecimal::from(0), + performance: Performance::default(), + account: Some(Account { + id: "TOTAL".to_string(), + name: "Total Portfolio".to_string(), + account_type: "Virtual".to_string(), + group: None, + currency: self.base_currency.clone(), + is_default: false, + is_active: true, + created_at: chrono::Utc::now().naive_utc(), + updated_at: chrono::Utc::now().naive_utc(), + platform_id: None, + }), + asset_class: holding.asset_class.clone(), + asset_sub_class: holding.asset_sub_class.clone(), + sectors: holding.sectors.clone(), + portfolio_percent: None, } + } - // Calculate total portfolio value + fn aggregate_total_holding(&self, total_holding: &mut Holding, holding: &Holding) { + total_holding.quantity += &holding.quantity; + total_holding.market_value += &holding.market_value; + total_holding.market_value_converted += &holding.market_value_converted; + total_holding.book_value += &holding.book_value; + total_holding.book_value_converted += &holding.book_value_converted; + } + + fn calculate_total_holding_metrics( + &self, + total_holdings: &mut HashMap, + ) -> Result<()> { let total_portfolio_value: BigDecimal = total_holdings .values() .map(|h| &h.market_value_converted) .sum(); - // Calculate portfolio percentage for each total holding for total_holding in total_holdings.values_mut() { - if total_portfolio_value != BigDecimal::from(0) { - total_holding.portfolio_percent = Some( - (&total_holding.market_value_converted / &total_portfolio_value - * BigDecimal::from(100)) - .round(2), - ); - } else { - total_holding.portfolio_percent = Some(BigDecimal::from(0)); - } + self.calculate_total_holding_performance(total_holding)?; + self.calculate_portfolio_percentage(total_holding, &total_portfolio_value); } - // Combine individual holdings with total holdings - let mut all_holdings: Vec = holdings.into_values().collect(); - all_holdings.extend(total_holdings.into_values()); + Ok(()) + } - // When filtering holdings, use a small threshold for comparison - let threshold = BigDecimal::from_str("0.000001").unwrap(); + fn calculate_total_holding_performance(&self, total_holding: &mut Holding) -> Result<()> { + total_holding.market_price = Some(if total_holding.quantity != BigDecimal::from(0) { + (&total_holding.market_value / &total_holding.quantity).round(6) + } else { + BigDecimal::from(0) + }); + + total_holding.average_cost = Some(if total_holding.quantity != BigDecimal::from(0) { + (&total_holding.book_value / &total_holding.quantity).round(6) + } else { + BigDecimal::from(0) + }); + + total_holding.performance.total_gain_amount = + (&total_holding.market_value - &total_holding.book_value).round(6); + total_holding.performance.total_gain_amount_converted = + (&total_holding.market_value_converted - &total_holding.book_value_converted).round(6); + + total_holding.performance.total_gain_percent = + if total_holding.book_value != BigDecimal::from(0) { + (&total_holding.performance.total_gain_amount / &total_holding.book_value + * BigDecimal::from(100)) + .round(6) + } else { + BigDecimal::from(0) + }; - let filtered_holdings: Vec<_> = all_holdings - .into_iter() - .filter(|holding| holding.quantity.abs() > threshold) - .collect(); + Ok(()) + } - Ok(filtered_holdings) + fn calculate_portfolio_percentage( + &self, + total_holding: &mut Holding, + total_portfolio_value: &BigDecimal, + ) { + if total_portfolio_value != &BigDecimal::from(0) { + total_holding.portfolio_percent = Some( + (&total_holding.market_value_converted / total_portfolio_value + * BigDecimal::from(100)) + .round(2), + ); + } else { + total_holding.portfolio_percent = Some(BigDecimal::from(0)); + } + } + + fn filter_holdings(&self, holdings: HashMap) -> Vec { + let threshold = BigDecimal::from_str("0.000001").unwrap(); + holdings + .into_values() + .filter(|holding| holding.quantity.abs() > threshold) + .collect() } } diff --git a/src-core/src/portfolio/income_service.rs b/src-core/src/portfolio/income_service.rs index 4d6af895..42f54b9f 100644 --- a/src-core/src/portfolio/income_service.rs +++ b/src-core/src/portfolio/income_service.rs @@ -92,7 +92,6 @@ impl IncomeService { return Err(e); } }; - println!("Income computed since {}", oldest_date); let mut months_since_first_transaction: i32 = (current_date.year() - oldest_date.year()) * 12; months_since_first_transaction = months_since_first_transaction diff --git a/src-core/src/portfolio/portfolio_service.rs b/src-core/src/portfolio/portfolio_service.rs index 4b367744..0cf0457e 100644 --- a/src-core/src/portfolio/portfolio_service.rs +++ b/src-core/src/portfolio/portfolio_service.rs @@ -103,18 +103,30 @@ impl PortfolioService { let result = self.calculate_historical_data(conn, None, false).await; let duration = start.elapsed(); - println!("update_portfolio completed in: {:?}", duration); + println!( + "update_portfolio completed in: {:?} seconds", + duration.as_secs_f64() + ); result } - pub fn get_account_history( + pub fn get_all_accounts_history( &self, conn: &mut SqliteConnection, - account_id: &str, ) -> Result, Box> { self.history_service - .get_account_history(conn, account_id) + .get_all_accounts_history(conn) + .map_err(|e| Box::new(e) as Box) + } + + pub fn get_portfolio_history( + &self, + conn: &mut SqliteConnection, + account_id: Option<&str>, + ) -> Result, Box> { + self.history_service + .get_portfolio_history(conn, account_id) .map_err(|e| Box::new(e) as Box) // Convert PortfolioError to Box } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 139fe279..62f7dcb8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -174,21 +174,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - [[package]] name = "bitflags" version = "1.3.2" @@ -1984,18 +1969,6 @@ dependencies = [ "tendril", ] -[[package]] -name = "markup5ever_rcdom" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9521dd6750f8e80ee6c53d65e2e4656d7de37064f3a7a5d2d11d05df93839c2" -dependencies = [ - "html5ever", - "markup5ever", - "tendril", - "xml5ever", -] - [[package]] name = "matchers" version = "0.1.0" @@ -3221,17 +3194,6 @@ dependencies = [ "libc", ] -[[package]] -name = "select" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f9da09dc3f4dfdb6374cbffff7a2cffcec316874d4429899eefdc97b3b94dcd" -dependencies = [ - "bit-set", - "html5ever", - "markup5ever_rcdom", -] - [[package]] name = "selectors" version = "0.22.0" @@ -4461,8 +4423,9 @@ dependencies = [ [[package]] name = "wealthfolio-app" -version = "1.0.17" +version = "1.0.18" dependencies = [ + "chrono", "diesel", "dotenvy", "tauri", @@ -5118,25 +5081,13 @@ dependencies = [ "rustix", ] -[[package]] -name = "xml5ever" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4034e1d05af98b51ad7214527730626f019682d797ba38b51689212118d8e650" -dependencies = [ - "log", - "mac", - "markup5ever", -] - [[package]] name = "yahoo_finance_api" -version = "2.2.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a51e69ea2e11b657c7857c856b13be4ab64a8dcde9ea66093c5b61d7c6e12d" +checksum = "6a3281ec47078118649163dcdc751df4e788cc4ffa01baaab989f784523bbd62" dependencies = [ "reqwest 0.12.7", - "select", "serde", "serde_json", "thiserror", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index edfc9d67..6956bbc3 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wealthfolio-app" -version = "1.0.17" +version = "1.0.18" description = "Portfolio tracker" authors = ["Aziz Fadil"] license = "LGPL-3.0" @@ -14,9 +14,10 @@ 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"] } +tauri = { version = "1.7.2", features = [ "updater", "dialog-save", "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" +chrono = { version = "0.4.38", features = ["serde"] } [features] # this feature is used for production builds or when `devPath` points to the filesystem diff --git a/src-tauri/src/commands/activity.rs b/src-tauri/src/commands/activity.rs index 190134b3..ef30cafc 100644 --- a/src-tauri/src/commands/activity.rs +++ b/src-tauri/src/commands/activity.rs @@ -5,6 +5,22 @@ use crate::models::{ use crate::AppState; use tauri::State; +#[tauri::command] +pub async fn get_activities(state: State<'_, AppState>) -> Result, String> { + println!("Fetching all activities..."); + let mut conn = state + .pool + .get() + .map_err(|e| format!("Failed to get connection: {}", e))?; + let base_currency = state.base_currency.read().unwrap().clone(); + let service = activity_service::ActivityService::new(base_currency); + + service + .get_activities(&mut conn) + .map_err(|e| format!("Failed to fetch activities: {}", e)) +} + + #[tauri::command] pub async fn search_activities( page: i64, // Page number, 1-based diff --git a/src-tauri/src/commands/portfolio.rs b/src-tauri/src/commands/portfolio.rs index 041b818f..1a905468 100644 --- a/src-tauri/src/commands/portfolio.rs +++ b/src-tauri/src/commands/portfolio.rs @@ -29,10 +29,6 @@ pub async fn calculate_historical_data( #[tauri::command] pub async fn compute_holdings(state: State<'_, AppState>) -> Result, String> { - use std::time::Instant; - println!("Compute holdings..."); - let start = Instant::now(); - let service = create_portfolio_service(&state).await?; let mut conn = state.pool.get().map_err(|e| e.to_string())?; @@ -42,23 +38,19 @@ pub async fn compute_holdings(state: State<'_, AppState>) -> Result .map_err(|e| e.to_string()) .map(|vec| Ok(vec))?; - let duration = start.elapsed(); - println!("Compute holdings completed in: {:?}", duration); - result } #[tauri::command] -pub async fn get_account_history( +pub async fn get_portfolio_history( state: State<'_, AppState>, - account_id: String, + account_id: Option<&str>, ) -> Result, String> { - println!("Fetching account history for account ID: {}", account_id); let service = create_portfolio_service(&state).await?; let mut conn = state.pool.get().map_err(|e| e.to_string())?; service - .get_account_history(&mut conn, &account_id) + .get_portfolio_history(&mut conn, account_id) .map_err(|e| format!("Failed to fetch account history: {}", e)) } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 84504c16..cbaecf8f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -3,9 +3,10 @@ mod commands; +use chrono::Local; use commands::account::{create_account, delete_account, get_accounts, update_account}; use commands::activity::{ - check_activities_import, create_activities, create_activity, delete_activity, + check_activities_import, create_activities, create_activity, delete_activity, get_activities, search_activities, update_activity, }; use commands::goal::{ @@ -15,8 +16,8 @@ use commands::goal::{ use commands::market_data::{get_asset_data, search_symbol, synch_quotes, update_asset_profile}; use commands::portfolio::{ - calculate_historical_data, compute_holdings, get_account_history, get_accounts_summary, - get_income_summary, recalculate_portfolio, + calculate_historical_data, compute_holdings, get_accounts_summary, get_income_summary, + get_portfolio_history, recalculate_portfolio, }; use commands::settings::{ add_exchange_rate, delete_exchange_rate, get_exchange_rates, get_settings, @@ -38,6 +39,8 @@ use wealthfolio_core::settings; use dotenvy::dotenv; use std::env; +use std::fs::{self, File}; +use std::io::Read; use std::path::Path; use std::sync::{Arc, RwLock}; @@ -101,6 +104,7 @@ fn main() { update_account, delete_account, search_activities, + get_activities, create_activity, update_activity, delete_activity, @@ -125,9 +129,10 @@ fn main() { update_goal_allocations, load_goals_allocations, get_income_summary, - get_account_history, + get_portfolio_history, get_accounts_summary, recalculate_portfolio, + backup_database, ]) .build(context) .expect("error while running wealthfolio application"); @@ -218,3 +223,44 @@ fn get_db_path(app_handle: &tauri::AppHandle) -> String { } } } + +#[tauri::command] +async fn backup_database(app_handle: tauri::AppHandle) -> Result<(String, Vec), String> { + let db_path = get_db_path(&app_handle); + let backup_path = create_backup_path(&app_handle)?; + + fs::copy(&db_path, &backup_path).map_err(|e| format!("Failed to create backup: {}", e))?; + + // Read the backup file + let mut file = + File::open(&backup_path).map_err(|e| format!("Failed to open backup file: {}", e))?; + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer) + .map_err(|e| format!("Failed to read backup file: {}", e))?; + + // Get the filename + let filename = Path::new(&backup_path) + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| "Failed to get backup filename".to_string())? + .to_string(); + + Ok((filename, buffer)) +} + +fn create_backup_path(app_handle: &tauri::AppHandle) -> Result { + let app_data_dir = app_handle + .path_resolver() + .app_data_dir() + .ok_or_else(|| "Failed to get app data directory".to_string())?; + + let backup_dir = app_data_dir.join("backups"); + fs::create_dir_all(&backup_dir) + .map_err(|e| format!("Failed to create backup directory: {}", e))?; + + let timestamp = Local::now().format("%Y%m%d_%H%M%S"); + let backup_file = format!("wealthfolio_backup_{}.db", timestamp); + let backup_path = backup_dir.join(backup_file); + + Ok(backup_path.to_str().unwrap().to_string()) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6fcea5c0..3716f197 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -8,13 +8,14 @@ }, "package": { "productName": "Wealthfolio", - "version": "1.0.17" + "version": "1.0.18" }, "tauri": { "allowlist": { "all": false, "dialog": { - "open": true + "open": true, + "save": true }, "fs": { "all": true, diff --git a/src/adapters/index.ts b/src/adapters/index.ts index 892afdea..59b4f70d 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -26,4 +26,5 @@ export { listenQuotesSyncStartTauri, listenQuotesSyncCompleteTauri, listenQuotesSyncErrorTauri, + openFileSaveDialogTauri, } from './tauri'; diff --git a/src/adapters/tauri.ts b/src/adapters/tauri.ts index a86a19ce..a18235c8 100644 --- a/src/adapters/tauri.ts +++ b/src/adapters/tauri.ts @@ -1,6 +1,7 @@ import { invoke } from '@tauri-apps/api'; -import { open } from '@tauri-apps/api/dialog'; +import { open, save } from '@tauri-apps/api/dialog'; import { listen } from '@tauri-apps/api/event'; +import { writeBinaryFile, BaseDirectory } from '@tauri-apps/api/fs'; import type { EventCallback, UnlistenFn } from '@tauri-apps/api/event'; export type { EventCallback, UnlistenFn }; @@ -46,3 +47,39 @@ export const listenQuotesSyncErrorTauri = async ( ): Promise => { return listen('PORTFOLIO_UPDATE_ERROR', handler); }; + +export const openFileSaveDialogTauri = async ( + fileContent: string | Blob | Uint8Array, + fileName: string, +) => { + const filePath = await save({ + defaultPath: fileName, + filters: [ + { + name: fileName, + extensions: [fileName.split('.').pop() ?? ''], + }, + ], + }); + + if (filePath === null) { + return false; + } + + let contentToSave: Uint8Array; + if (typeof fileContent === 'string') { + contentToSave = new TextEncoder().encode(fileContent); + } else if (fileContent instanceof Blob) { + const arrayBuffer = await fileContent.arrayBuffer(); + contentToSave = new Uint8Array(arrayBuffer); + } else { + contentToSave = fileContent; + } + + await writeBinaryFile( + { path: filePath, contents: contentToSave }, + { dir: BaseDirectory.Document }, + ); + + return true; +}; diff --git a/src/commands/activity.ts b/src/commands/activity.ts index bef80669..4280699f 100644 --- a/src/commands/activity.ts +++ b/src/commands/activity.ts @@ -18,14 +18,14 @@ interface Sort { export const getActivities = async (): Promise => { try { - switch (getRunEnv()) { - case RUN_ENV.DESKTOP: - return invokeTauri('get_activities'); - default: - throw new Error(`Unsupported`); - } + const response = await searchActivities(0, Number.MAX_SAFE_INTEGER, {}, '', { + id: 'date', + desc: true, + }); + console.log('getActivities', response); + return response.data; } catch (error) { - console.error('Error fetching activities:', error); + console.error('Error fetching all activities:', error); throw error; } }; diff --git a/src/commands/file.ts b/src/commands/file.ts index 7868642d..6233b8bb 100644 --- a/src/commands/file.ts +++ b/src/commands/file.ts @@ -1,4 +1,4 @@ -import { getRunEnv, openCsvFileDialogTauri, RUN_ENV } from '@/adapters'; +import { getRunEnv, openCsvFileDialogTauri, openFileSaveDialogTauri, RUN_ENV } from '@/adapters'; // openCsvFileDialog export const openCsvFileDialog = async (): Promise => { @@ -14,3 +14,16 @@ export const openCsvFileDialog = async (): Promise => throw error; } }; + +// Function for downloading file content +export async function openFileSaveDialog( + fileContent: Uint8Array | Blob | string, + fileName: string, +) { + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + return openFileSaveDialogTauri(fileContent, fileName); + default: + throw new Error(`Unsupported environment for file download`); + } +} diff --git a/src/commands/portfolio.ts b/src/commands/portfolio.ts index b95d4ce1..95092c22 100644 --- a/src/commands/portfolio.ts +++ b/src/commands/portfolio.ts @@ -66,16 +66,16 @@ export const getIncomeSummary = async (): Promise => { } }; -export const getAccountHistory = async (accountId: string): Promise => { +export const getHistory = async (accountId?: string): Promise => { try { switch (getRunEnv()) { case RUN_ENV.DESKTOP: - return invokeTauri('get_account_history', { accountId }); + return invokeTauri('get_portfolio_history', accountId ? { accountId } : undefined); default: throw new Error(`Unsupported`); } } catch (error) { - console.error('Error fetching account history:', error); + console.error('Error fetching portfolio history:', error); throw error; } }; diff --git a/src/commands/settings.ts b/src/commands/settings.ts index 249d0d73..88c7356f 100644 --- a/src/commands/settings.ts +++ b/src/commands/settings.ts @@ -28,3 +28,19 @@ export const saveSettings = async (settings: Settings): Promise => { throw error; } }; + +export const backupDatabase = async (): Promise<{ filename: string; data: Uint8Array }> => { + try { + switch (getRunEnv()) { + case RUN_ENV.DESKTOP: + const result = await invokeTauri<[string, number[]]>('backup_database'); + const [filename, data] = result; + return { filename, data: new Uint8Array(data) }; + default: + throw new Error(`Unsupported environment for database backup`); + } + } catch (error) { + console.error('Error backing up database:', error); + throw error; + } +}; diff --git a/src/components/icons.tsx b/src/components/icons.tsx index dd44f04f..39d4c3a2 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -127,6 +127,98 @@ export const Icons = { ), + Goals: ({ ...props }: LucideProps) => ( + + + + + ), + + Database: ({ ...props }: LucideProps) => ( + + + + + ), + + FileCsv: ({ ...props }: LucideProps) => ( + + + + + + + + + + ), + + FileJson: ({ ...props }: LucideProps) => ( + + + + + + + + ), + + Files: ({ ...props }: LucideProps) => ( + + + + + ), + Holdings: ({ ...props }: LucideProps) => ( { + return Object.values(row) + .map((value) => { + return typeof value === 'string' ? JSON.stringify(value) : value; + }) + .toString(); + }) + .join('\n'); +} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index a0b3b8b0..5debba03 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -2,13 +2,13 @@ export const QueryKeys = { // Account related keys ACCOUNTS: 'accounts', ACCOUNTS_SUMMARY: 'accounts_summary', - ACCOUNTS_HISTORY: 'accounts_history', // Activity related keys ACTIVITY_DATA: 'activity-data', + ACTIVITIES: 'activities', // Portfolio related keys - PORTFOLIO_HISTORY: 'portfolio_history', + HISTORY: 'history', HOLDINGS: 'holdings', INCOME_SUMMARY: 'incomeSummary', @@ -25,5 +25,5 @@ export const QueryKeys = { QUOTE: 'quote', // Helper function to create account-specific keys - accountHistory: (id: string) => ['account_history', id], + accountHistory: (id: string) => ['history', id], } as const; diff --git a/src/lib/types.ts b/src/lib/types.ts index af023f5a..141191e0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -317,3 +317,7 @@ export interface ExchangeRate { source: string; isLoading?: boolean; } + +export type ExportDataType = 'accounts' | 'activities' | 'goals' | 'portfolio-history'; + +export type ExportedFileFormat = 'CSV' | 'JSON' | 'SQLite'; diff --git a/src/pages/account/account-page.tsx b/src/pages/account/account-page.tsx index 8dc561a5..dc1b94d6 100644 --- a/src/pages/account/account-page.tsx +++ b/src/pages/account/account-page.tsx @@ -14,7 +14,7 @@ import AccountDetail from './account-detail'; import AccountHoldings from './account-holdings'; import { useQuery } from '@tanstack/react-query'; import { Holding, PortfolioHistory, AccountSummary } from '@/lib/types'; -import { computeHoldings, getAccountHistory, getAccountsSummary } from '@/commands/portfolio'; +import { computeHoldings, getHistory, getAccountsSummary } from '@/commands/portfolio'; import { QueryKeys } from '@/lib/query-keys'; import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; import { Icons } from '@/components/icons'; @@ -37,7 +37,7 @@ const AccountPage = () => { Error >({ queryKey: QueryKeys.accountHistory(id), - queryFn: () => getAccountHistory(id), + queryFn: () => getHistory(id), enabled: !!id, }); diff --git a/src/pages/activity/components/activity-form.tsx b/src/pages/activity/components/activity-form.tsx index 6eb5dca2..eca1f706 100644 --- a/src/pages/activity/components/activity-form.tsx +++ b/src/pages/activity/components/activity-form.tsx @@ -44,6 +44,9 @@ const activityTypes = [ { label: 'Dividend', value: 'DIVIDEND' }, { label: 'Interest', value: 'INTEREST' }, { label: 'Fee', value: 'FEE' }, + { label: 'Split', value: 'SPLIT' }, + { label: 'Transfer In', value: 'TRANSFER_IN' }, + { label: 'Transfer Out', value: 'TRANSFER_OUT' }, ] as const; const CASH_ACTIVITY_TYPES = ['DEPOSIT', 'WITHDRAWAL', 'FEE', 'INTEREST']; @@ -212,6 +215,7 @@ export function ActivityForm({ accounts, defaultValues, onSuccess = () => {} }: ); } + interface CashActivityFieldsProps { currentAccountCurrency: string; } @@ -264,11 +268,17 @@ const CashActivityFields = ({ currentAccountCurrency }: CashActivityFieldsProps) ); }; +interface AssetActivityFieldsProps { + defaultAssetId?: string; +} + const AssetActivityFields = ({ defaultAssetId }: AssetActivityFieldsProps) => { const { control, watch } = useFormContext(); const watchedType = watch('activityType'); const isSplitType = watchedType === 'SPLIT'; + const isTransferType = watchedType === 'TRANSFER_IN' || watchedType === 'TRANSFER_OUT'; + const isTransferOut = watchedType === 'TRANSFER_OUT'; return ( <> @@ -291,15 +301,15 @@ const AssetActivityFields = ({ defaultAssetId }: AssetActivityFieldsProps) => { {isSplitType ? ( ( - Split Multiple + Split Ratio @@ -307,6 +317,37 @@ const AssetActivityFields = ({ defaultAssetId }: AssetActivityFieldsProps) => { )} /> + ) : isTransferType ? ( +
+ ( + + Shares + + + + + + )} + /> + {!isTransferOut && ( + ( + + Average Cost + + + + + + )} + /> + )} +
) : (
{ ); }; +interface DividendActivityFieldsProps { + defaultAssetId?: string; +} + const DividendActivityFields = ({ defaultAssetId }: DividendActivityFieldsProps) => { const { control } = useFormContext(); @@ -391,10 +436,3 @@ const DividendActivityFields = ({ defaultAssetId }: DividendActivityFieldsProps) ); }; - -interface AssetActivityFieldsProps { - defaultAssetId?: string; -} -interface DividendActivityFieldsProps { - defaultAssetId?: string; -} diff --git a/src/pages/activity/components/activity-table.tsx b/src/pages/activity/components/activity-table.tsx index 97aec222..4010287a 100644 --- a/src/pages/activity/components/activity-table.tsx +++ b/src/pages/activity/components/activity-table.tsx @@ -33,10 +33,10 @@ const fetchSize = 25; const activityTypeOptions = [ { label: 'Buy', value: 'BUY' }, - { label: 'Deposit', value: 'DEPOSIT' }, - { label: 'Dividend', value: 'DIVIDEND' }, { label: 'Sell', value: 'SELL' }, + { label: 'Deposit', value: 'DEPOSIT' }, { label: 'Withdrawal', value: 'WITHDRAWAL' }, + { label: 'Dividend', value: 'DIVIDEND' }, { label: 'Transfer In', value: 'TRANSFER_IN' }, { label: 'Transfer Out', value: 'TRANSFER_OUT' }, { label: 'Conversion In', value: 'CONVERSION_IN' }, @@ -92,7 +92,9 @@ export const ActivityTable = ({ activityType === 'CONVERSION_IN' || activityType === 'TRANSFER_IN' ? 'success' - : 'error'; + : activityType === 'SPLIT' + ? 'secondary' + : 'error'; return (
@@ -142,7 +144,11 @@ export const ActivityTable = ({ title="Shares" /> ), - cell: ({ row }) =>
{row.getValue('quantity')}
, + cell: ({ row }) => { + const activityType = row.getValue('activityType') as string; + const quantity = row.getValue('quantity') as number; + return
{activityType === 'SPLIT' ? '-' : quantity}
; + }, }, { id: 'unitPrice', @@ -157,8 +163,14 @@ export const ActivityTable = ({ /> ), cell: ({ row }) => { + const activityType = row.getValue('activityType') as string; const unitPrice = row.getValue('unitPrice') as number; const currency = (row.getValue('currency') as string) || 'USD'; + + if (activityType === 'SPLIT') { + return
{unitPrice.toFixed(0)} : 1
; + } + return
{formatAmount(unitPrice, currency)}
; }, }, @@ -171,9 +183,15 @@ export const ActivityTable = ({ ), cell: ({ row }) => { + const activityType = row.getValue('activityType') as string; const fee = row.getValue('fee') as number; const currency = (row.getValue('currency') as string) || 'USD'; - return
{formatAmount(fee, currency)}
; + + return ( +
+ {activityType === 'SPLIT' ? '-' : formatAmount(fee, currency)} +
+ ); }, }, { @@ -184,12 +202,15 @@ export const ActivityTable = ({ ), cell: ({ row }) => { + const activityType = row.getValue('activityType') as string; const unitPrice = row.getValue('unitPrice') as number; const quantity = row.getValue('quantity') as number; const currency = (row.getValue('currency') as string) || 'USD'; return ( -
{formatAmount(unitPrice * quantity, currency)}
+
+ {activityType === 'SPLIT' ? '-' : formatAmount(unitPrice * quantity, currency)} +
); }, }, diff --git a/src/pages/activity/import/imported-activity-table.tsx b/src/pages/activity/import/imported-activity-table.tsx index 16532e95..596ef475 100644 --- a/src/pages/activity/import/imported-activity-table.tsx +++ b/src/pages/activity/import/imported-activity-table.tsx @@ -201,9 +201,13 @@ export const columns: ColumnDef[] = [ accessorKey: 'quantity', enableHiding: false, header: ({ column }) => ( - + ), - cell: ({ row }) =>
{row.getValue('quantity')}
, + cell: ({ row }) => { + const activityType = row.getValue('activityType') as string; + const quantity = row.getValue('quantity') as number; + return
{activityType === 'SPLIT' ? '-' : quantity}
; + }, }, { id: 'unitPrice', @@ -214,12 +218,18 @@ export const columns: ColumnDef[] = [ ), cell: ({ row }) => { + const activityType = row.getValue('activityType') as string; const unitPrice = row.getValue('unitPrice') as number; const currency = (row.getValue('currency') as string) || 'USD'; + + if (activityType === 'SPLIT') { + return
{unitPrice.toFixed(0)} : 1
; + } + return
{formatAmount(unitPrice, currency)}
; }, }, @@ -232,9 +242,15 @@ export const columns: ColumnDef[] = [ ), cell: ({ row }) => { + const activityType = row.getValue('activityType') as string; const fee = row.getValue('fee') as number; const currency = (row.getValue('currency') as string) || 'USD'; - return
{formatAmount(fee, currency)}
; + + return ( +
+ {activityType === 'SPLIT' ? '-' : formatAmount(fee, currency)} +
+ ); }, }, { @@ -244,11 +260,16 @@ export const columns: ColumnDef[] = [ ), cell: ({ row }) => { + const activityType = row.getValue('activityType') as string; const unitPrice = row.getValue('unitPrice') as number; const quantity = row.getValue('quantity') as number; const currency = (row.getValue('currency') as string) || 'USD'; - return
{formatAmount(unitPrice * quantity, currency)}
; + return ( +
+ {activityType === 'SPLIT' ? '-' : formatAmount(unitPrice * quantity, currency)} +
+ ); }, }, { diff --git a/src/pages/dashboard/dashboard-page.tsx b/src/pages/dashboard/dashboard-page.tsx index 7b33a1f3..f0784a43 100644 --- a/src/pages/dashboard/dashboard-page.tsx +++ b/src/pages/dashboard/dashboard-page.tsx @@ -4,7 +4,7 @@ import { HistoryChart } from '@/components/history-chart'; import Balance from './balance'; import { useQuery } from '@tanstack/react-query'; import { PortfolioHistory, AccountSummary } from '@/lib/types'; -import { getAccountHistory, getAccountsSummary } from '@/commands/portfolio'; +import { getHistory, getAccountsSummary } from '@/commands/portfolio'; import { Skeleton } from '@/components/ui/skeleton'; import { Accounts } from './accounts'; import SavingGoals from './goals'; @@ -48,7 +48,7 @@ export default function DashboardPage() { Error >({ queryKey: QueryKeys.accountHistory('TOTAL'), - queryFn: () => getAccountHistory('TOTAL'), + queryFn: () => getHistory('TOTAL'), }); if (isPortfolioHistoryLoading || isAccountsLoading) { diff --git a/src/pages/holdings/components/portfolio-composition.tsx b/src/pages/holdings/components/portfolio-composition.tsx index 6d02a6fe..0d58af48 100644 --- a/src/pages/holdings/components/portfolio-composition.tsx +++ b/src/pages/holdings/components/portfolio-composition.tsx @@ -3,6 +3,7 @@ import { Holding } from '@/lib/types'; import { cn, formatPercent } from '@/lib/utils'; import { useMemo } from 'react'; import { ResponsiveContainer, Treemap } from 'recharts'; +import { Link } from 'react-router-dom'; function opacity(value: number) { const gain = Math.abs(value * 100); @@ -48,18 +49,20 @@ const CustomizedContent = (props: any) => { /> {depth === 1 ? ( <> - - {name} - + + + {name} + + { @@ -33,7 +33,7 @@ export const HoldingsPage = () => { const { data: portfolioHistory } = useQuery({ queryKey: QueryKeys.accountHistory('TOTAL'), - queryFn: () => getAccountHistory('TOTAL'), + queryFn: () => getHistory('TOTAL'), }); const todayValue = portfolioHistory?.[portfolioHistory.length - 1]; @@ -59,7 +59,10 @@ export const HoldingsPage = () => { Holdings - + !holding.symbol.startsWith('$CASH'))} + isLoading={isLoading} + />
diff --git a/src/pages/settings/exports/exports-form.tsx b/src/pages/settings/exports/exports-form.tsx new file mode 100644 index 00000000..e8586df3 --- /dev/null +++ b/src/pages/settings/exports/exports-form.tsx @@ -0,0 +1,168 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Label } from '@/components/ui/label'; +import { Icons } from '@/components/icons'; +import { ExportDataType, ExportedFileFormat } from '@/lib/types'; +import { useExportData } from './useExportData'; + +const dataFormats = [ + { + name: 'CSV', + icon: Icons.FileCsv, + description: 'Simple, widely compatible spreadsheet format', + }, + { + name: 'JSON', + icon: Icons.FileJson, + description: 'Structured data for easy programmatic access', + }, + { + name: 'SQLite', + icon: Icons.Database, + description: 'Compact, self-contained database file', + }, +]; + +const dataTypes = { + CSV: [ + { + key: 'accounts', + name: 'Accounts', + icon: Icons.Holdings, + description: 'Your financial accounts', + }, + { + key: 'activities', + name: 'Activities', + icon: Icons.Activity, + description: 'Detailed transaction history and logs', + }, + { + key: 'goals', + name: 'Goals', + icon: Icons.Goals, + description: 'Financial objectives and progress tracking', + }, + { + key: 'portfolio-history', + name: 'Portfolio History', + icon: Icons.Files, + description: + "Your portfolio's performance over time, including valuations, gains, and cash flow activities.", + }, + ], + JSON: [ + { + key: 'accounts', + name: 'Accounts', + icon: Icons.Holdings, + description: 'Your financial accounts', + }, + { + key: 'activities', + name: 'Activities', + icon: Icons.Activity, + description: 'Detailed transaction history and logs', + }, + { + key: 'goals', + name: 'Goals', + icon: Icons.Goal, + description: 'Financial objectives and progress tracking', + }, + { + key: 'portfolio-history', + name: 'Portfolio History', + icon: Icons.ScrollText, + description: + "Your portfolio's performance over time, including valuations, gains, and cash flow activities.", + }, + ], + SQLite: [ + { + key: 'full', + name: 'Full Database Backup', + icon: Icons.Database, + description: 'Complete, queryable SQLite database backup of all your information', + }, + ], +}; + +export const ExportForm = () => { + const [selectedFormat, setSelectedFormat] = useState(); + + const { exportData, isExporting, exportingFormat, exportingData } = useExportData(); + + const handleExport = (item: (typeof dataTypes)[ExportedFileFormat][number]) => { + if (!selectedFormat) return; + + exportData({ + data: item.key as ExportDataType, + format: selectedFormat as ExportedFileFormat, + }); + }; + + return ( + <> +
+

Choose Your Preferred Format

+ + {dataFormats.map((format) => ( +
+ + +
+ ))} +
+
+ + {selectedFormat && ( +
+

Customize Your Export

+ {dataTypes[selectedFormat as keyof typeof dataTypes].map((item) => ( + + +
+ +
+ {item.name} +

{item.description}

+
+
+ +
+
+ ))} +
+ )} + + ); +}; diff --git a/src/pages/settings/exports/exports-page.tsx b/src/pages/settings/exports/exports-page.tsx new file mode 100644 index 00000000..f6043930 --- /dev/null +++ b/src/pages/settings/exports/exports-page.tsx @@ -0,0 +1,18 @@ +import { ExportForm } from './exports-form'; +import { SettingsHeader } from '../header'; +import { Separator } from '@/components/ui/separator'; + +const ExportSettingsPage = () => { + return ( +
+ + + +
+ ); +}; + +export default ExportSettingsPage; diff --git a/src/pages/settings/exports/useExportData.ts b/src/pages/settings/exports/useExportData.ts new file mode 100644 index 00000000..f2ec1329 --- /dev/null +++ b/src/pages/settings/exports/useExportData.ts @@ -0,0 +1,122 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { + ExportDataType, + ExportedFileFormat, + Account, + ActivityDetails, + Goal, + PortfolioHistory, +} from '@/lib/types'; +import { toast } from '@/components/ui/use-toast'; +import { backupDatabase } from '@/commands/settings'; +import { openFileSaveDialog } from '@/commands/file'; +import { formatData } from '@/lib/export-utils'; +import { QueryKeys } from '@/lib/query-keys'; +import { getAccounts } from '@/commands/account'; +import { getActivities } from '@/commands/activity'; +import { getGoals } from '@/commands/goal'; +import { getHistory } from '@/commands/portfolio'; + +interface ExportParams { + format: ExportedFileFormat; + data: ExportDataType; +} + +export function useExportData() { + const { refetch: fetchAccounts } = useQuery({ + queryKey: [QueryKeys.ACCOUNTS], + queryFn: getAccounts, + enabled: false, + }); + const { refetch: fetchActivities } = useQuery({ + queryKey: [QueryKeys.ACTIVITIES], + queryFn: getActivities, + enabled: false, + }); + const { refetch: fetchGoals } = useQuery({ + queryKey: [QueryKeys.GOALS], + queryFn: getGoals, + enabled: false, + }); + const { refetch: fetchPortfolioHistory } = useQuery({ + queryKey: [QueryKeys.HISTORY], + queryFn: () => getHistory(), + enabled: false, + }); + + const { + mutateAsync: exportDataMutation, + isPending: isExporting, + variables: mutationVariables, + } = useMutation({ + mutationFn: async (params: ExportParams) => { + const { format, data: desiredData } = params; + if (format === 'SQLite') { + const sqliteFile = await backupDatabase(); + return openFileSaveDialog(sqliteFile.data, sqliteFile.filename); + } else { + let exportedData: string | undefined; + let fileName: string; + + const currentDate = new Date().toISOString().split('T')[0]; + switch (desiredData) { + case 'accounts': + exportedData = await fetchAndFormatData(fetchAccounts, format); + fileName = `accounts_${currentDate}.${format.toLowerCase()}`; + break; + case 'activities': + exportedData = await fetchAndFormatData(fetchActivities, format); + fileName = `activities_${currentDate}.${format.toLowerCase()}`; + break; + case 'goals': + exportedData = await fetchAndFormatData(fetchGoals, format); + fileName = `goals_${currentDate}.${format.toLowerCase()}`; + break; + case 'portfolio-history': + exportedData = await fetchAndFormatData(fetchPortfolioHistory, format); + fileName = `portfolio-history_${currentDate}.${format.toLowerCase()}`; + break; + } + + if (exportedData) { + return openFileSaveDialog(exportedData, fileName); + } + } + }, + onSuccess: () => { + toast({ + title: 'File saved successfully.', + variant: 'success', + }); + }, + onError: () => { + toast({ + title: 'Something went wrong.', + variant: 'destructive', + }); + }, + }); + + const exportData = async (params: ExportParams) => { + try { + await exportDataMutation(params); + } catch (error) { + console.log('Error while exporting', error); + } + }; + + return { + exportData, + isExporting, + exportingFormat: isExporting ? mutationVariables?.format : null, + exportingData: isExporting ? mutationVariables?.data : null, + }; +} + +async function fetchAndFormatData( + queryFn: () => Promise, + format: ExportedFileFormat, +): Promise { + const response = await queryFn(); + return formatData(response.data, format); +} diff --git a/src/pages/settings/layout.tsx b/src/pages/settings/layout.tsx index 9953b035..39122b4e 100644 --- a/src/pages/settings/layout.tsx +++ b/src/pages/settings/layout.tsx @@ -23,6 +23,10 @@ const sidebarNavItems = [ title: 'Appearance', href: 'appearance', }, + { + title: 'Exports', + href: 'exports', + }, ]; export default function SettingsLayout() { diff --git a/src/routes.tsx b/src/routes.tsx index ddeeeb3a..c3761ff5 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -17,6 +17,7 @@ import OnboardingPage from './pages/onboarding/onboarding-page'; import SettingsGoalsPage from './pages/settings/goals/goals-page'; import ExchangeRatesPage from './pages/settings/currencies/exchange-rates-page'; import IncomePage from '@/pages/income/income-page'; +import ExportSettingsPage from './pages/settings/exports/exports-page'; export function AppRoutes() { useGlobalEventListener(); @@ -40,6 +41,7 @@ export function AppRoutes() { } /> } /> } /> + } /> Not Found} />