//! Tauri commands for the Bilan (balance sheet) feature — Issue #142 / #155. //! //! 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)`, //! matching the existing `repair_migrations` helper in `profile_commands.rs`. //! - The frontend passes `db_filename` (the active profile DB), exactly //! like it does for `repair_migrations` and `delete_profile_db`. Keeps //! the active-profile resolution where it already lives (in TS) and //! avoids re-reading `profiles.json` on every call. //! - Reads are short-lived: connection opens, runs ≤ 3 SQL statements, //! drops at end of function. No connection pooling needed (commands run //! on the Tauri async runtime, one at a time per invocation). 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 /// `snapshot_date <= period_start` (None if no prior snapshot). /// - `value_end`: latest snapshot line for the account whose /// `snapshot_date <= period_end` (None if no snapshot in range). /// - cash flows: every linked transfer in `[period_start, period_end]`, /// sign applied per direction (`in` → `+`, `out` → `−`). /// /// Both dates must be ISO `YYYY-MM-DD`. Returns a typed `AccountReturn` /// (Serialize) ready to ship across the Tauri boundary. #[tauri::command] pub fn compute_account_return( app: tauri::AppHandle, db_filename: String, account_id: i64, period_start: String, period_end: String, ) -> Result { let start_date = parse_iso_date(&period_start, "period_start")?; let end_date = parse_iso_date(&period_end, "period_end")?; let app_dir = app .path() .app_data_dir() .map_err(|e| format!("Cannot get app data dir: {}", e))?; let db_path = app_dir.join(&db_filename); if !db_path.exists() { return Err(format!( "Profile database not found: {}", db_path.display() )); } let conn = Connection::open(&db_path) .map_err(|e| format!("Cannot open database: {}", e))?; let value_start = read_value_at_or_before(&conn, account_id, &period_start)?; let value_end = read_value_at_or_before(&conn, account_id, &period_end)?; let cash_flows = read_cash_flows(&conn, account_id, &period_start, &period_end)?; Ok(modified_dietz( value_start, value_end, &cash_flows, start_date, end_date, )) } // ----------------------------------------------------------------------------- // Internal helpers // ----------------------------------------------------------------------------- fn parse_iso_date(input: &str, field: &str) -> Result { NaiveDate::parse_from_str(input, "%Y-%m-%d") .map_err(|e| format!("Invalid {} (expected YYYY-MM-DD): {}", field, e)) } /// Reads the value of the snapshot line for `account_id` at the most recent /// snapshot whose `snapshot_date <= as_of_date`. Returns `None` when no /// such snapshot exists for this account. fn read_value_at_or_before( conn: &Connection, account_id: i64, as_of_date: &str, ) -> Result, String> { // Single-row query: pick the latest snapshot date for this account that // is on or before `as_of_date`, then return that line's value. Indexed // on `balance_snapshots.snapshot_date` and `balance_snapshot_lines.account_id`. let mut stmt = conn .prepare( "SELECT l.value FROM balance_snapshot_lines l JOIN balance_snapshots s ON s.id = l.snapshot_id WHERE l.account_id = ?1 AND s.snapshot_date <= ?2 ORDER BY s.snapshot_date DESC LIMIT 1", ) .map_err(|e| format!("prepare value query: {}", e))?; let mut rows = stmt .query(rusqlite::params![account_id, as_of_date]) .map_err(|e| format!("execute value query: {}", e))?; match rows.next().map_err(|e| format!("read value row: {}", e))? { Some(row) => Ok(Some( row.get::<_, f64>(0).map_err(|e| format!("decode value: {}", e))?, )), None => Ok(None), } } /// Reads every linked transfer for `account_id` whose underlying /// transaction's `transaction_date` falls inside `[period_start, period_end]`. /// Returns `(NaiveDate, signed_amount)` — sign applied per `direction` /// (`in` → `+`, `out` → `−`). Amounts come from the linked transaction. fn read_cash_flows( conn: &Connection, account_id: i64, period_start: &str, period_end: &str, ) -> Result, String> { // NOTE: the transactions table column is `date` (not `transaction_date`). // See `src-tauri/src/database/schema.sql:67`. let mut stmt = conn .prepare( "SELECT t.date, ABS(t.amount) AS abs_amount, bat.direction FROM balance_account_transfers bat JOIN transactions t ON t.id = bat.transaction_id WHERE bat.account_id = ?1 AND t.date BETWEEN ?2 AND ?3 ORDER BY t.date", ) .map_err(|e| format!("prepare flows query: {}", e))?; let rows = stmt .query_map( rusqlite::params![account_id, period_start, period_end], |row| { // `transactions.date` may come back as String (TEXT) — keep // the decoder generic enough. let date_str: String = row.get(0)?; let amount: f64 = row.get(1)?; let direction: String = row.get(2)?; Ok((date_str, amount, direction)) }, ) .map_err(|e| format!("execute flows query: {}", e))?; let mut flows: Vec<(NaiveDate, f64)> = Vec::new(); for row_result in rows { let (date_str, amount, direction) = row_result.map_err(|e| format!("decode flow row: {}", e))?; // `transaction_date` is stored as `YYYY-MM-DD` (TEXT date column — // see consolidated_schema.sql). Defensive trim of any trailing // time component just in case. let iso = date_str.split('T').next().unwrap_or(&date_str).to_string(); let date = parse_iso_date(&iso, "transaction_date")?; let signed = match direction.as_str() { "in" => amount, "out" => -amount, other => { return Err(format!( "Invalid transfer direction stored in DB: {}", other )); } }; flows.push((date, signed)); } 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"); } }