Simpl-Resultat/src-tauri/src/commands/balance_commands.rs
le king fu 531624bcb4
All checks were successful
PR Check / rust (push) Successful in 25m28s
PR Check / frontend (push) Successful in 2m33s
PR Check / rust (pull_request) Successful in 25m38s
PR Check / frontend (pull_request) Successful in 2m44s
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
2026-04-27 08:23:18 -04:00

532 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<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
/// `[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<AccountReturn, String> {
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, String> {
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<Option<f64>, 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<Vec<(NaiveDate, f64)>, 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");
}
}