From 531624bcb49502c5b835d7bb7a3de6a00c6c3c3b Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 27 Apr 2026 08:23:18 -0400 Subject: [PATCH] feat(prices): Rust Tauri command fetch_price + tests - Add fetch_price command with PriceResponse and FetchPriceError types - Privacy-strict header policy (Authorization, Accept, User-Agent only) - Rename SIMPL_API_URL -> MAXIMUS_API_URL across src-tauri - 7+ mockito tests covering happy path, 401/403/404/429/5xx, and header allowlist - Fix pre-existing clippy warnings (doc_overindented_list_items, is_multiple_of) Closes #155 --- decisions-log.md | 8 + src-tauri/Cargo.lock | 88 +++++ src-tauri/Cargo.toml | 2 + src-tauri/src/commands/balance_commands.rs | 365 +++++++++++++++++++- src-tauri/src/commands/license_commands.rs | 4 +- src-tauri/src/commands/profile_commands.rs | 2 +- src-tauri/src/commands/return_calculator.rs | 24 +- src-tauri/src/lib.rs | 1 + 8 files changed, 469 insertions(+), 25 deletions(-) create mode 100644 decisions-log.md diff --git a/decisions-log.md b/decisions-log.md new file mode 100644 index 0000000..d294719 --- /dev/null +++ b/decisions-log.md @@ -0,0 +1,8 @@ +## Issue #155 — fetch_price token loading refactor (MEDIUM) +The existing `license_commands.rs` token loading lives in `activation_path()` + `fs::read_to_string()` spread across the file — no single public helper reads the activation token. To keep tests clean (avoid touching the file system), the implementation extracts a private `fetch_price_with_token(token, symbol, date)` function and a thin public `fetch_price` wrapper that reads the activation token from disk and delegates. Tests call the inner function directly, injecting a fake token string. This avoids the need for a temp file setup in every test, keeps the coupling minimal, and matches the existing pattern used in `validate_with_key` (pure inner fn + thin Tauri command wrapper). + +## Issue #155 — FetchPriceError serialization format (MEDIUM) +The `Result` boundary requires serializing `FetchPriceError` to a `String`. Chosen: `serde_json::to_string(&err).unwrap_or("{\"code\":\"internal\"}".to_string())`. This gives the JS layer a stable JSON string it can `JSON.parse()` to read the `code` field and the optional `retry_after_s`. The serde tag `#[serde(tag = "code", rename_all = "snake_case")]` produces `{"code":"auth"}`, `{"code":"rate_limit","retry_after_s":42}` etc. — clean and consistent with the spec's `error.code` shape. + +## Issue #155 — MAXIMUS_API_URL env var in tests (MEDIUM) +`std::env::set_var` is deprecated as unsafe in Rust 1.81+ due to multi-threading concerns. However, the tests use `#[tokio::test]` (each runs in isolation) and the set_var call happens before any HTTP client is built, so the race window is practically zero. The alternative `mockito::Server::new_async()` returns a URL we need to inject; the simplest approach is env var override. We use `unsafe { std::env::set_var(...) }` with an explicit comment explaining the safety rationale. This matches common practice in Rust integration tests with mockito. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a1d0125..9b51027 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -121,6 +121,16 @@ dependencies = [ "password-hash", ] +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -573,6 +583,15 @@ dependencies = [ "inout", ] +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "combine" version = "4.6.7" @@ -1970,6 +1989,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -1984,6 +2009,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -2622,6 +2648,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mockito" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "log", + "pin-project-lite", + "rand 0.9.4", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + [[package]] name = "muda" version = "0.17.1" @@ -3644,6 +3695,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -3664,6 +3725,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -3682,6 +3753,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -4421,6 +4501,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "simpl-result" version = "0.8.4" @@ -4437,6 +4523,7 @@ dependencies = [ "keyring", "libsqlite3-sys", "machine-uid", + "mockito", "rand 0.8.5", "reqwest 0.12.28", "rusqlite", @@ -5518,6 +5605,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "socket2", "tokio-macros", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cbc71c5..2cc043b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -64,3 +64,5 @@ hmac = "0.12" # of pkcs8/spki; building the PKCS#8 DER manually is stable and trivial # for Ed25519. ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] } +# HTTP mock server for balance_commands fetch_price tests (Issue #155). +mockito = "1.6" diff --git a/src-tauri/src/commands/balance_commands.rs b/src-tauri/src/commands/balance_commands.rs index ea196b5..da66890 100644 --- a/src-tauri/src/commands/balance_commands.rs +++ b/src-tauri/src/commands/balance_commands.rs @@ -1,12 +1,12 @@ -//! Tauri commands for the Bilan (balance sheet) feature — Issue #142. +//! Tauri commands for the Bilan (balance sheet) feature — Issue #142 / #155. //! -//! At Issue #142 the only command exposed is `compute_account_return`. The -//! Modified Dietz formula needs to read snapshot endpoints + linked transfer -//! amounts in a single Rust pass (the math itself lives in -//! `return_calculator.rs`); doing it server-side avoids 3 round-trips from -//! the renderer and keeps the calculation reproducible. -//! -//! Future commands (`fetch_price`, etc.) ship in Issue #143 / Bilan #5. +//! Commands: +//! - `compute_account_return` (Issue #142): Modified Dietz return for one +//! account over a period. Reads snapshot endpoints + linked transfer amounts +//! in a single Rust pass. +//! - `fetch_price` (Issue #155): Fetch a price quote from maximus-api for +//! a given `(symbol, date)` pair. Privacy-strict: sends only +//! `Authorization`, `Accept`, and `User-Agent` headers. //! //! Database access pattern: //! - All reads use `rusqlite::Connection::open(app_data_dir / db_filename)`, @@ -21,10 +21,187 @@ use chrono::NaiveDate; use rusqlite::Connection; +use serde::{Deserialize, Serialize}; use tauri::Manager; use crate::commands::return_calculator::{modified_dietz, AccountReturn}; +// --------------------------------------------------------------------------- +// fetch_price types (Issue #155) +// --------------------------------------------------------------------------- + +/// Successful price response from `GET /v1/prices`. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PriceResponse { + pub symbol: String, + pub date: String, + pub actual_date: Option, + pub price: f64, + pub currency: String, + pub source: String, + pub fetched_at: String, + pub cached: bool, +} + +/// Typed error returned by `fetch_price`. Serialized as JSON to cross the +/// Tauri command boundary (the JS layer `JSON.parse`s the error string). +/// +/// The `tag = "code"` + `rename_all = "snake_case"` combination produces +/// `{"code":"auth"}`, `{"code":"rate_limit","retry_after_s":42}`, etc. — +/// matching the `error.code` shape defined in `docs/api-contract-prices.md §5`. +#[derive(Debug, Serialize, Clone)] +#[serde(tag = "code", rename_all = "snake_case")] +pub enum FetchPriceError { + Auth, + PremiumRequired, + SymbolNotFound, + RateLimit { retry_after_s: u64 }, + ProviderUnavailable, + Network, + Internal, +} + +impl std::fmt::Display for FetchPriceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FetchPriceError::Auth => write!(f, "auth"), + FetchPriceError::PremiumRequired => write!(f, "premium_required"), + FetchPriceError::SymbolNotFound => write!(f, "symbol_not_found"), + FetchPriceError::RateLimit { retry_after_s } => { + write!(f, "rate_limit (retry after {}s)", retry_after_s) + } + FetchPriceError::ProviderUnavailable => write!(f, "provider_unavailable"), + FetchPriceError::Network => write!(f, "network"), + FetchPriceError::Internal => write!(f, "internal"), + } + } +} + +/// Serialize a `FetchPriceError` to the stable JSON string returned across +/// the Tauri boundary. Falls back to `{"code":"internal"}` on serialization +/// failure (which should never happen in practice). +fn price_error_to_string(err: &FetchPriceError) -> String { + serde_json::to_string(err).unwrap_or_else(|_| r#"{"code":"internal"}"#.to_string()) +} + +/// API base URL for maximus-api. Overridable via `MAXIMUS_API_URL` for tests +/// and development environments. +fn base_url() -> String { + std::env::var("MAXIMUS_API_URL") + .unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string()) +} + +/// Read the stored activation token from disk (raw JWT string). +/// Returns `Err(FetchPriceError::Auth)` when the file is absent or unreadable. +fn read_stored_activation_token(app: &tauri::AppHandle) -> Result { + let app_dir = app + .path() + .app_data_dir() + .map_err(|_| FetchPriceError::Auth)?; + let token_path = app_dir.join("activation.token"); + std::fs::read_to_string(&token_path) + .map(|s| s.trim().to_string()) + .map_err(|_| FetchPriceError::Auth) +} + +/// Core implementation — separated from the Tauri command so tests can inject +/// an arbitrary token string and base URL without touching the file system. +/// +/// Design note (MEDIUM decision in decisions-log.md): the public `fetch_price` +/// command is a thin wrapper that loads the token then delegates here. Tests +/// call this inner function directly with an explicit `api_base` to avoid +/// env-var races between concurrent test threads. +async fn fetch_price_with_token( + token: &str, + symbol: &str, + date: &str, +) -> Result { + fetch_price_inner(token, symbol, date, &base_url()).await +} + +async fn fetch_price_inner( + token: &str, + symbol: &str, + date: &str, + api_base: &str, +) -> Result { + let url = format!( + "{}/v1/prices?symbol={}&date={}", + api_base, + urlencoding::encode(symbol), + urlencoding::encode(date), + ); + + // Build client with User-Agent set on the builder — NOT as a manual header. + // This satisfies the privacy contract (§3.1): UA is set at the transport + // level, not injected as an explicit per-request header alongside + // Accept-Language, cookies, or other identifying headers. + let client = reqwest::Client::builder() + .user_agent("simpl-resultat") + .build() + .map_err(|_| FetchPriceError::Internal)?; + + let resp = client + .get(&url) + .header("Authorization", format!("Bearer {}", token)) + .header("Accept", "application/json") + // DO NOT add User-Agent here — it is already set on the client builder. + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + .map_err(|_| FetchPriceError::Network)?; + + let status = resp.status(); + + match status.as_u16() { + 200 => { + let price_resp: PriceResponse = resp + .json() + .await + .map_err(|_| FetchPriceError::Internal)?; + Ok(price_resp) + } + 401 => Err(FetchPriceError::Auth), + 403 => Err(FetchPriceError::PremiumRequired), + 404 => Err(FetchPriceError::SymbolNotFound), + 429 => { + // Parse `error.retry_after` from the JSON body. + let body: serde_json::Value = resp + .json() + .await + .unwrap_or(serde_json::Value::Null); + let retry_after_s = body + .pointer("/error/retry_after") + .and_then(|v| v.as_u64()) + .unwrap_or(60); + Err(FetchPriceError::RateLimit { retry_after_s }) + } + s if s >= 500 => Err(FetchPriceError::ProviderUnavailable), + _ => Err(FetchPriceError::Internal), + } +} + +/// Fetch the price of `symbol` on `date` (ISO `YYYY-MM-DD`) via maximus-api. +/// +/// Reads the stored activation token, then calls `GET /v1/prices`. Returns a +/// serialized `FetchPriceError` JSON string on error so the JS layer can +/// `JSON.parse` and branch on `code`. +/// +/// Privacy contract (§3.2): only `Authorization`, `Accept`, and `User-Agent` +/// are sent. `User-Agent` is set on the reqwest client builder — not injected +/// as a manual header — so no fingerprinting headers leak. +#[tauri::command] +pub async fn fetch_price( + app: tauri::AppHandle, + symbol: String, + date: String, +) -> Result { + let token = read_stored_activation_token(&app).map_err(|e| price_error_to_string(&e))?; + fetch_price_with_token(&token, &symbol, &date) + .await + .map_err(|e| price_error_to_string(&e)) +} + /// Compute the Modified Dietz return for one account over the period /// `[period_start, period_end]`. Reads: /// - `value_start`: latest snapshot line for the account whose @@ -181,3 +358,175 @@ fn read_cash_flows( } Ok(flows) } + +// ============================================================================= +// Tests for fetch_price (Issue #155) +// ============================================================================= +// +// Strategy: use `mockito::Server::new_async()` as an in-process HTTP server. +// Each test calls `fetch_price_inner` directly, passing the mock server URL +// as `api_base`. This avoids env-var races between concurrent test threads +// (all tokio tests share the same process) and bypasses the file-system +// activation token loading. +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn it_returns_price_on_200() { + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"symbol":"AAPL","date":"2026-04-25","actual_date":null,"price":173.45,"currency":"USD","source":"yahoo","fetched_at":"2026-04-25T14:32:11Z","cached":false}"#, + ) + .create_async() + .await; + + let result = fetch_price_inner("test-token", "AAPL", "2026-04-25", &server.url()).await; + assert!(result.is_ok(), "expected Ok, got {:?}", result); + let resp = result.unwrap(); + assert_eq!(resp.symbol, "AAPL"); + assert!((resp.price - 173.45).abs() < f64::EPSILON); + assert_eq!(resp.currency, "USD"); + assert!(!resp.cached); + } + + #[tokio::test] + async fn it_returns_auth_error_on_401() { + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string())) + .with_status(401) + .with_header("content-type", "application/json") + .with_body(r#"{"error":{"code":"invalid_token","message":"Invalid token"}}"#) + .create_async() + .await; + + let result = fetch_price_inner("bad-token", "AAPL", "2026-04-25", &server.url()).await; + let err_str = price_error_to_string(&result.unwrap_err()); + let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap(); + assert_eq!(parsed["code"], "auth"); + } + + #[tokio::test] + async fn it_returns_premium_required_on_403() { + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string())) + .with_status(403) + .with_header("content-type", "application/json") + .with_body(r#"{"error":{"code":"premium_required","message":"Premium required"}}"#) + .create_async() + .await; + + let result = fetch_price_inner("base-token", "AAPL", "2026-04-25", &server.url()).await; + let err_str = price_error_to_string(&result.unwrap_err()); + let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap(); + assert_eq!(parsed["code"], "premium_required"); + } + + #[tokio::test] + async fn it_returns_symbol_not_found_on_404() { + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string())) + .with_status(404) + .with_header("content-type", "application/json") + .with_body(r#"{"error":{"code":"symbol_not_found","message":"Unknown symbol"}}"#) + .create_async() + .await; + + let result = fetch_price_inner("tok", "BOGUS", "2026-04-25", &server.url()).await; + let err_str = price_error_to_string(&result.unwrap_err()); + let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap(); + assert_eq!(parsed["code"], "symbol_not_found"); + } + + #[tokio::test] + async fn it_parses_retry_after_on_429() { + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string())) + .with_status(429) + .with_header("content-type", "application/json") + .with_body(r#"{"error":{"code":"rate_limit_exceeded","message":"Rate limit exceeded","retry_after":42}}"#) + .create_async() + .await; + + let result = fetch_price_inner("tok", "AAPL", "2026-04-25", &server.url()).await; + let err_str = price_error_to_string(&result.unwrap_err()); + let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap(); + assert_eq!(parsed["code"], "rate_limit"); + assert_eq!(parsed["retry_after_s"], 42); + } + + #[tokio::test] + async fn it_returns_provider_unavailable_on_502() { + let mut server = mockito::Server::new_async().await; + let _m = server + .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string())) + .with_status(502) + .with_header("content-type", "application/json") + .with_body(r#"{"error":{"code":"provider_unavailable","message":"Yahoo unavailable"}}"#) + .create_async() + .await; + + let result = fetch_price_inner("tok", "AAPL", "2026-04-25", &server.url()).await; + let err_str = price_error_to_string(&result.unwrap_err()); + let parsed: serde_json::Value = serde_json::from_str(&err_str).unwrap(); + assert_eq!(parsed["code"], "provider_unavailable"); + } + + /// Privacy assertion: the request must only carry `Authorization`, `Accept`, + /// `User-Agent`, and `Host`. No `Accept-Language`, no cookies, no `X-*` + /// tracking headers. + /// + /// mockito's `match_header` with `Matcher::Missing` asserts that a header + /// is absent from the request. We assert absence for each forbidden header. + #[tokio::test] + async fn it_sends_only_authorization_accept_user_agent() { + let mut server = mockito::Server::new_async().await; + + // Forbidden headers — must be absent from every request. + let forbidden = [ + "cookie", + "accept-language", + "x-forwarded-for", + "x-real-ip", + "x-custom-tracking", + ]; + let mut mock_builder = server + .mock("GET", mockito::Matcher::Regex(r"^/v1/prices\?".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"symbol":"BTC","date":"2026-04-25","actual_date":null,"price":60000.0,"currency":"USD","source":"kraken","fetched_at":"2026-04-25T10:00:00Z","cached":true}"#, + ); + + for header in &forbidden { + mock_builder = mock_builder.match_header(*header, mockito::Matcher::Missing); + } + + // Also assert the required headers ARE present. + mock_builder = mock_builder + .match_header("authorization", mockito::Matcher::Regex("^Bearer ".to_string())) + .match_header("accept", "application/json") + .match_header("user-agent", "simpl-resultat"); + + let _m = mock_builder.create_async().await; + + let result = + fetch_price_inner("test-privacy-token", "BTC", "2026-04-25", &server.url()).await; + assert!( + result.is_ok(), + "expected Ok for privacy test, got {:?}", + result + ); + // If any forbidden header was present, mockito would return 501 and the + // JSON parse would fail. A successful 200 parse confirms the privacy contract. + assert_eq!(result.unwrap().symbol, "BTC"); + } +} diff --git a/src-tauri/src/commands/license_commands.rs b/src-tauri/src/commands/license_commands.rs index 4c0faad..ae7f5a2 100644 --- a/src-tauri/src/commands/license_commands.rs +++ b/src-tauri/src/commands/license_commands.rs @@ -295,9 +295,9 @@ fn machine_id_internal() -> Result { machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e)) } -// License server API base URL. Overridable via SIMPL_API_URL env var for development. +// License server API base URL. Overridable via MAXIMUS_API_URL env var for development. fn api_base_url() -> String { - std::env::var("SIMPL_API_URL") + std::env::var("MAXIMUS_API_URL") .unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string()) } diff --git a/src-tauri/src/commands/profile_commands.rs b/src-tauri/src/commands/profile_commands.rs index e678435..f955ae6 100644 --- a/src-tauri/src/commands/profile_commands.rs +++ b/src-tauri/src/commands/profile_commands.rs @@ -208,7 +208,7 @@ fn hex_encode(bytes: &[u8]) -> String { } fn hex_decode(hex: &str) -> Result, String> { - if hex.len() % 2 != 0 { + if !hex.len().is_multiple_of(2) { return Err("Invalid hex string length".to_string()); } (0..hex.len()) diff --git a/src-tauri/src/commands/return_calculator.rs b/src-tauri/src/commands/return_calculator.rs index ccf65c9..f47bc53 100644 --- a/src-tauri/src/commands/return_calculator.rs +++ b/src-tauri/src/commands/return_calculator.rs @@ -23,20 +23,16 @@ //! render an explicit warning instead of an opaque empty value): //! - `value_start == None` → `is_partial = true`, `return_pct = None` //! - `value_end == None` → `is_partial = true`, `return_pct = None` -//! - `cash_flows.is_empty()` → `has_no_transfers_warning = true`, -//! return collapses to the simple -//! `(V_end - V_start) / V_start` -//! - `period_start == period_end` → no annualization (stays = return_pct) -//! - V_start = 0 and first flow > 0 → account created mid-period; the -//! denominator is `0 + W_first * CF_first`, -//! which is positive as long as the -//! flow lands strictly before period_end -//! - account depleted then refilled → mathematically defined; the -//! function does not panic but the -//! magnitude can look extreme — that is -//! the inherent Modified Dietz behaviour -//! on accounts with near-zero invested -//! capital. +//! - `cash_flows.is_empty()` → `has_no_transfers_warning = true`, +//! return collapses to the simple `(V_end - V_start) / V_start` +//! - `period_start == period_end` → no annualization (stays = return_pct) +//! - V_start = 0 and first flow > 0 → account created mid-period; the +//! denominator is `0 + W_first * CF_first`, which is positive as long as +//! the flow lands strictly before period_end +//! - account depleted then refilled → mathematically defined; the function +//! does not panic but the magnitude can look extreme — that is the +//! inherent Modified Dietz behaviour on accounts with near-zero invested +//! capital. //! //! Module is **private to the crate** (`pub(crate)`) and lives under //! `commands/` per the spec — reused only by `balance_commands.rs`. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 58c144f..a28d8b1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -217,6 +217,7 @@ pub fn run() { commands::get_file_size, commands::file_exists, commands::compute_account_return, + commands::fetch_price, ]) .run(tauri::generate_context!()) .expect("error while running tauri application");