146 lines
3.7 KiB
Rust
146 lines
3.7 KiB
Rust
use crate::extra_units;
|
|
use crate::units::UnitType;
|
|
use fastnum::decimal::Context;
|
|
use fastnum::D128;
|
|
use serde_json::Value;
|
|
|
|
const EXCHANGE_RATE_API_USD_LATEST: &str = "https://api.exchangerate-api.com/v4/latest/USD";
|
|
|
|
fn parse_d128_from_json_number(v: &Value) -> Result<D128, String> {
|
|
let s = match v {
|
|
Value::Number(n) => n.to_string(),
|
|
_ => return Err("Expected JSON number".to_string()),
|
|
};
|
|
D128::from_str(&s, Context::default())
|
|
.map_err(|_| format!("Invalid decimal number: {s}"))
|
|
}
|
|
|
|
/// Registers/updates currencies from the ExchangeRate API JSON response (base USD).
|
|
///
|
|
/// The JSON is expected to look like `{"base":"USD","rates":{"EUR":0.852,...}}`.
|
|
///
|
|
/// Internally we store a "weight" as USD-per-unit, so conversions use the existing
|
|
/// `from.weight()/to.weight()` logic.
|
|
pub fn register_currencies_from_exchange_rate_api_json(json: &str) -> Result<usize, String> {
|
|
let v: Value = serde_json::from_str(json).map_err(|e| format!("Invalid JSON: {e}"))?;
|
|
|
|
let base = v
|
|
.get("base")
|
|
.and_then(|b| b.as_str())
|
|
.ok_or("Missing 'base'")?;
|
|
if base != "USD" {
|
|
return Err(format!("Expected base USD but got {base}"));
|
|
}
|
|
|
|
let rates = v
|
|
.get("rates")
|
|
.and_then(|r| r.as_object())
|
|
.ok_or("Missing 'rates' object")?;
|
|
|
|
let mut count = 0usize;
|
|
for (code, rate_value) in rates {
|
|
// Skip weird/empty keys defensively
|
|
if code.len() != 3 {
|
|
continue;
|
|
}
|
|
let code_upper = code.to_ascii_uppercase();
|
|
let code_lower = code.to_ascii_lowercase();
|
|
|
|
let rate = parse_d128_from_json_number(rate_value)?;
|
|
if rate.is_zero() {
|
|
continue;
|
|
}
|
|
// weight = USD per 1 {CODE} = 1 / (CODE per 1 USD)
|
|
let weight = D128::ONE / rate;
|
|
|
|
extra_units::upsert_unit(
|
|
&code_lower,
|
|
&code_upper,
|
|
&code_upper,
|
|
UnitType::Currency,
|
|
weight,
|
|
);
|
|
count += 1;
|
|
}
|
|
|
|
Ok(count)
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn cache_path() -> std::path::PathBuf {
|
|
let mut p = std::env::temp_dir();
|
|
p.push("cpc_currency_rates_usd.json");
|
|
p
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub fn init_currencies_blocking() -> Result<usize, String> {
|
|
// 1) Load cached (if any) so currencies work offline.
|
|
let mut cached_count = 0usize;
|
|
let cache_path = cache_path();
|
|
if let Ok(s) = std::fs::read_to_string(&cache_path) {
|
|
if let Ok(n) = register_currencies_from_exchange_rate_api_json(&s) {
|
|
cached_count = n;
|
|
}
|
|
}
|
|
|
|
// 2) Try refresh from network.
|
|
let resp = ureq::get(EXCHANGE_RATE_API_USD_LATEST)
|
|
.call()
|
|
.map_err(|e| format!("Currency fetch failed: {e}"))?;
|
|
let body = resp
|
|
.into_string()
|
|
.map_err(|e| format!("Currency fetch read failed: {e}"))?;
|
|
|
|
let n = register_currencies_from_exchange_rate_api_json(&body)?;
|
|
// Best-effort cache write
|
|
let _ = std::fs::write(&cache_path, body);
|
|
|
|
Ok(std::cmp::max(cached_count, n))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::extra_units;
|
|
use crate::units::{convert, Unit};
|
|
use crate::Number;
|
|
use fastnum::dec128 as d;
|
|
|
|
#[test]
|
|
fn register_and_convert_currency() {
|
|
extra_units::clear_for_tests();
|
|
|
|
let json = r#"{
|
|
"base": "USD",
|
|
"rates": {
|
|
"USD": 1,
|
|
"EUR": 0.5
|
|
}
|
|
}"#;
|
|
|
|
let n = register_currencies_from_exchange_rate_api_json(json).unwrap();
|
|
assert_eq!(n, 2);
|
|
|
|
let usd = extra_units::lookup_unit("usd").unwrap();
|
|
let eur = extra_units::lookup_unit("eur").unwrap();
|
|
|
|
let one_usd = Number::new(d!(1), usd);
|
|
let eur_amount = convert(one_usd, eur).unwrap();
|
|
assert_eq!(eur_amount.value, d!(0.5));
|
|
|
|
let one_eur = Number::new(d!(1), eur);
|
|
let usd_amount = convert(one_eur, usd).unwrap();
|
|
assert_eq!(usd_amount.value, d!(2));
|
|
|
|
// Display name should be currency code
|
|
assert_eq!(eur_amount.unit.singular(), "EUR");
|
|
assert_eq!(eur_amount.unit.plural(), "EUR");
|
|
|
|
// Sanity: it's still a Unit
|
|
let _ = Unit::NoUnit;
|
|
}
|
|
}
|
|
|
|
|