Merge pull request 'feat(prices): Rust Tauri command fetch_price + tests (#155)' (#165) from issue-155-rust-fetch-price into main

This commit is contained in:
maximus 2026-04-28 01:06:40 +00:00
commit edd1a5cbe4
8 changed files with 469 additions and 25 deletions

8
decisions-log.md Normal file
View file

@ -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<PriceResponse, String>` 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.

88
src-tauri/Cargo.lock generated
View file

@ -121,6 +121,16 @@ dependencies = [
"password-hash", "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]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@ -573,6 +583,15 @@ dependencies = [
"inout", "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]] [[package]]
name = "combine" name = "combine"
version = "4.6.7" version = "4.6.7"
@ -1970,6 +1989,12 @@ version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.8.1" version = "1.8.1"
@ -1984,6 +2009,7 @@ dependencies = [
"http", "http",
"http-body", "http-body",
"httparse", "httparse",
"httpdate",
"itoa", "itoa",
"pin-project-lite", "pin-project-lite",
"pin-utils", "pin-utils",
@ -2622,6 +2648,31 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "muda" name = "muda"
version = "0.17.1" version = "0.17.1"
@ -3644,6 +3695,16 @@ dependencies = [
"rand_core 0.6.4", "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]] [[package]]
name = "rand_chacha" name = "rand_chacha"
version = "0.2.2" version = "0.2.2"
@ -3664,6 +3725,16 @@ dependencies = [
"rand_core 0.6.4", "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]] [[package]]
name = "rand_core" name = "rand_core"
version = "0.5.1" version = "0.5.1"
@ -3682,6 +3753,15 @@ dependencies = [
"getrandom 0.2.17", "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]] [[package]]
name = "rand_hc" name = "rand_hc"
version = "0.2.0" version = "0.2.0"
@ -4421,6 +4501,12 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]] [[package]]
name = "simpl-result" name = "simpl-result"
version = "0.8.4" version = "0.8.4"
@ -4437,6 +4523,7 @@ dependencies = [
"keyring", "keyring",
"libsqlite3-sys", "libsqlite3-sys",
"machine-uid", "machine-uid",
"mockito",
"rand 0.8.5", "rand 0.8.5",
"reqwest 0.12.28", "reqwest 0.12.28",
"rusqlite", "rusqlite",
@ -5518,6 +5605,7 @@ dependencies = [
"bytes", "bytes",
"libc", "libc",
"mio", "mio",
"parking_lot",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"tokio-macros", "tokio-macros",

View file

@ -64,3 +64,5 @@ hmac = "0.12"
# of pkcs8/spki; building the PKCS#8 DER manually is stable and trivial # of pkcs8/spki; building the PKCS#8 DER manually is stable and trivial
# for Ed25519. # for Ed25519.
ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] } ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] }
# HTTP mock server for balance_commands fetch_price tests (Issue #155).
mockito = "1.6"

View file

@ -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 //! Commands:
//! Modified Dietz formula needs to read snapshot endpoints + linked transfer //! - `compute_account_return` (Issue #142): Modified Dietz return for one
//! amounts in a single Rust pass (the math itself lives in //! account over a period. Reads snapshot endpoints + linked transfer amounts
//! `return_calculator.rs`); doing it server-side avoids 3 round-trips from //! in a single Rust pass.
//! the renderer and keeps the calculation reproducible. //! - `fetch_price` (Issue #155): Fetch a price quote from maximus-api for
//! //! a given `(symbol, date)` pair. Privacy-strict: sends only
//! Future commands (`fetch_price`, etc.) ship in Issue #143 / Bilan #5. //! `Authorization`, `Accept`, and `User-Agent` headers.
//! //!
//! Database access pattern: //! Database access pattern:
//! - All reads use `rusqlite::Connection::open(app_data_dir / db_filename)`, //! - All reads use `rusqlite::Connection::open(app_data_dir / db_filename)`,
@ -21,10 +21,187 @@
use chrono::NaiveDate; use chrono::NaiveDate;
use rusqlite::Connection; use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use tauri::Manager; use tauri::Manager;
use crate::commands::return_calculator::{modified_dietz, AccountReturn}; 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<String>,
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<String, FetchPriceError> {
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<PriceResponse, FetchPriceError> {
fetch_price_inner(token, symbol, date, &base_url()).await
}
async fn fetch_price_inner(
token: &str,
symbol: &str,
date: &str,
api_base: &str,
) -> Result<PriceResponse, FetchPriceError> {
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<PriceResponse, String> {
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 /// Compute the Modified Dietz return for one account over the period
/// `[period_start, period_end]`. Reads: /// `[period_start, period_end]`. Reads:
/// - `value_start`: latest snapshot line for the account whose /// - `value_start`: latest snapshot line for the account whose
@ -181,3 +358,175 @@ fn read_cash_flows(
} }
Ok(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");
}
}

View file

@ -295,9 +295,9 @@ fn machine_id_internal() -> Result<String, String> {
machine_uid::get().map_err(|e| format!("Cannot read machine id: {}", e)) 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 { 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()) .unwrap_or_else(|_| "https://api.lacompagniemaximus.com".to_string())
} }

View file

@ -208,7 +208,7 @@ fn hex_encode(bytes: &[u8]) -> String {
} }
fn hex_decode(hex: &str) -> Result<Vec<u8>, String> { fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
if hex.len() % 2 != 0 { if !hex.len().is_multiple_of(2) {
return Err("Invalid hex string length".to_string()); return Err("Invalid hex string length".to_string());
} }
(0..hex.len()) (0..hex.len())

View file

@ -23,20 +23,16 @@
//! render an explicit warning instead of an opaque empty value): //! render an explicit warning instead of an opaque empty value):
//! - `value_start == None` → `is_partial = true`, `return_pct = None` //! - `value_start == None` → `is_partial = true`, `return_pct = None`
//! - `value_end == 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`, //! - `cash_flows.is_empty()` → `has_no_transfers_warning = true`,
//! return collapses to the simple //! return collapses to the simple `(V_end - V_start) / V_start`
//! `(V_end - V_start) / V_start` //! - `period_start == period_end` → no annualization (stays = return_pct)
//! - `period_start == period_end` → no annualization (stays = return_pct) //! - V_start = 0 and first flow > 0 → account created mid-period; the
//! - 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
//! denominator is `0 + W_first * CF_first`, //! the flow lands strictly before period_end
//! which is positive as long as the //! - account depleted then refilled → mathematically defined; the function
//! flow lands strictly before period_end //! does not panic but the magnitude can look extreme — that is the
//! - account depleted then refilled → mathematically defined; the //! inherent Modified Dietz behaviour on accounts with near-zero invested
//! function does not panic but the //! capital.
//! 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 //! Module is **private to the crate** (`pub(crate)`) and lives under
//! `commands/` per the spec — reused only by `balance_commands.rs`. //! `commands/` per the spec — reused only by `balance_commands.rs`.

View file

@ -217,6 +217,7 @@ pub fn run() {
commands::get_file_size, commands::get_file_size,
commands::file_exists, commands::file_exists,
commands::compute_account_return, commands::compute_account_return,
commands::fetch_price,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");