app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 98e714b166a6ac74e7ca862fb0d1601ee8be5154
parent 6d7f01c392d295fb610840982e09b53d0d075284
Author: triesap <triesap@radroots.dev>
Date:   Mon, 19 Jan 2026 07:21:02 +0000

app-utils: add currency helpers

- add fiat currency enum and list
- add price formatting helpers
- add currency marker parsing helper
- add unit tests for currency helpers

Diffstat:
MCargo.lock | 1+
Mcrates/utils/Cargo.toml | 1+
Acrates/utils/src/currency/mod.rs | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/utils/src/lib.rs | 5+++++
4 files changed, 133 insertions(+), 0 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1585,6 +1585,7 @@ dependencies = [ "regex", "serde_json", "uuid", + "wasm-bindgen", "web-sys", ] diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml @@ -22,6 +22,7 @@ once_cell = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] gloo-timers = { workspace = true } +wasm-bindgen = { workspace = true } [dev-dependencies] futures = { workspace = true } diff --git a/crates/utils/src/currency/mod.rs b/crates/utils/src/currency/mod.rs @@ -0,0 +1,126 @@ +#![forbid(unsafe_code)] + +use crate::numbers::parse_float; +use crate::validation::regex::UtilRegex; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FiatCurrency { + Usd, + Eur, +} + +impl FiatCurrency { + pub const fn as_str(self) -> &'static str { + match self { + FiatCurrency::Usd => "usd", + FiatCurrency::Eur => "eur", + } + } + + pub const fn as_upper(self) -> &'static str { + match self { + FiatCurrency::Usd => "USD", + FiatCurrency::Eur => "EUR", + } + } +} + +pub const FIAT_CURRENCIES: [FiatCurrency; 2] = [FiatCurrency::Usd, FiatCurrency::Eur]; + +pub fn price_to_formatted(value: f64) -> f64 { + (value * 100.0).round() / 100.0 +} + +pub fn parse_currency(val: Option<&str>) -> FiatCurrency { + match val.map(|value| value.trim().to_lowercase()) { + Some(value) if value == "eur" => FiatCurrency::Eur, + _ => FiatCurrency::Usd, + } +} + +pub fn fmt_price(locale: &str, value: &str, currency: &str) -> String { + let value = parse_float(value, 0.0); + let currency = parse_currency(Some(currency)); + fmt_price_value(locale, value, currency) +} + +pub fn parse_currency_marker(locale: &str, currency: &str) -> String { + let currency = parse_currency(Some(currency)); + let formatted = fmt_price_value(locale, 1.0, currency); + if let Some(match_value) = UtilRegex::currency_marker().find(&formatted) { + return match_value.as_str().to_string(); + } + if let Some(match_value) = UtilRegex::currency_symbol().find(&formatted) { + return match_value.as_str().to_string(); + } + if let Some(match_value) = formatted + .find(currency.as_upper()) + .map(|start| &formatted[start..start + currency.as_upper().len()]) + { + return match_value.to_string(); + } + currency.as_upper().to_string() +} + +#[cfg(target_arch = "wasm32")] +fn fmt_price_value(locale: &str, value: f64, currency: FiatCurrency) -> String { + use js_sys::{Array, Object, Reflect}; + use wasm_bindgen::JsValue; + + let locales = Array::new(); + locales.push(&JsValue::from_str(locale)); + let options = Object::new(); + let currency_upper = currency.as_upper(); + let _ = Reflect::set(&options, &JsValue::from_str("style"), &JsValue::from_str("currency")); + let _ = Reflect::set( + &options, + &JsValue::from_str("currency"), + &JsValue::from_str(currency_upper), + ); + let _ = Reflect::set( + &options, + &JsValue::from_str("minimumFractionDigits"), + &JsValue::from_f64(2.0), + ); + let _ = Reflect::set( + &options, + &JsValue::from_str("maximumFractionDigits"), + &JsValue::from_f64(2.0), + ); + let formatter = js_sys::Intl::NumberFormat::new(&locales, &options); + formatter.format(value).into() +} + +#[cfg(not(target_arch = "wasm32"))] +fn fmt_price_value(_locale: &str, value: f64, currency: FiatCurrency) -> String { + format!("{} {:.2}", currency.as_upper(), value) +} + +#[cfg(test)] +mod tests { + use super::{fmt_price, parse_currency, parse_currency_marker, price_to_formatted, FiatCurrency}; + + #[test] + fn price_to_formatted_rounds() { + assert_eq!(price_to_formatted(1.234), 1.23); + } + + #[test] + fn parse_currency_defaults() { + assert_eq!(parse_currency(Some("usd")), FiatCurrency::Usd); + assert_eq!(parse_currency(Some("eur")), FiatCurrency::Eur); + assert_eq!(parse_currency(None), FiatCurrency::Usd); + } + + #[test] + fn fmt_price_formats_value() { + let formatted = fmt_price("en-US", "1.25", "usd"); + assert!(formatted.contains("USD")); + } + + #[test] + fn parse_currency_marker_returns_token() { + let marker = parse_currency_marker("en-US", "usd"); + assert!(!marker.is_empty()); + } +} diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs @@ -4,6 +4,7 @@ pub mod error; pub mod errors; pub mod r#async; pub mod binary; +pub mod currency; pub mod id; pub mod numbers; pub mod object; @@ -16,6 +17,10 @@ pub mod validation; pub use r#async::exe_iter; pub use binary::{as_array_buffer, RadrootsAppArrayBuffer}; +pub use currency::{ + fmt_price, parse_currency, parse_currency_marker, price_to_formatted, FiatCurrency, + FIAT_CURRENCIES, +}; pub use id::{d_tag_create, uuidv4, uuidv4_b64url, uuidv7, uuidv7_b64url}; pub use errors::{err_msg, handle_err, throw_err, ERR_PREFIX_APP, ERR_PREFIX_UTILS}; pub use numbers::{num_interval_range, num_str, parse_float, parse_int};