//! Buyer-selectable display currency. //! //! EUR is the base/transaction currency: every price is stored and reasoned //! about in EUR minor units (cents). A buyer may switch their *display* currency //! (cookie [`COOKIE`]); non-base currencies live in the `currencies` table with //! an admin-set exchange `rate_e4` (units per 1 EUR, scaled ×10000). The //! [`Currency`] resolved per request converts EUR cents into the chosen currency //! for display only — the cart logic, orders and admin stay in EUR. use std::sync::RwLock; use axum_extra::extract::cookie::CookieJar; use loco_rs::prelude::*; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; use crate::models::currencies; use crate::shared::money::format_price; /// Cookie holding the buyer's chosen display-currency code. pub const COOKIE: &str = "currency"; /// The base/transaction currency code. pub const BASE_CODE: &str = "EUR"; /// The base currency symbol. pub const BASE_SYMBOL: &str = "€"; /// Fixed-point scale for exchange rates (`rate_e4` = rate × 10000). pub const SCALE: i64 = 10_000; /// A resolved display currency: how to label prices and how to convert them /// from the EUR base. #[derive(Debug, Clone)] pub struct Currency { pub code: String, pub symbol: String, /// Units of this currency per 1 EUR, scaled ×10000. `SCALE` for the base. pub rate_e4: i64, } impl Currency { /// The base currency (EUR), the identity conversion. #[must_use] pub fn eur() -> Self { Self { code: BASE_CODE.to_string(), symbol: BASE_SYMBOL.to_string(), rate_e4: SCALE, } } #[must_use] pub fn is_base(&self) -> bool { self.code == BASE_CODE } /// Convert EUR minor units into this currency's minor units (half-up). #[must_use] pub fn convert_cents(&self, eur_cents: i64) -> i64 { if self.is_base() { return eur_cents; } let scale = i128::from(SCALE); ((i128::from(eur_cents) * i128::from(self.rate_e4) + scale / 2) / scale) as i64 } /// Inverse of [`convert_cents`]: this currency's minor units back to EUR /// minor units (half-up). Used to interpret price-filter bounds typed in the /// display currency. #[must_use] pub fn to_eur_cents(&self, cents: i64) -> i64 { if self.is_base() || self.rate_e4 == 0 { return cents; } let rate = i128::from(self.rate_e4); ((i128::from(cents) * i128::from(SCALE) + rate / 2) / rate) as i64 } /// Render EUR minor units as a plain decimal string in this currency (no /// symbol). The symbol is appended by templates via `currency_symbol`. #[must_use] pub fn format(&self, eur_cents: i64) -> String { format_price(self.convert_cents(eur_cents)) } } /// Resolve the buyer's display currency from the `currency` cookie, falling back /// to EUR when the cookie is absent, names the base, or names a currency that is /// missing or disabled. pub async fn resolve(ctx: &AppContext, jar: &CookieJar) -> Currency { let code = jar .get(COOKIE) .map(|c| c.value().to_string()) .unwrap_or_default(); if code.is_empty() || code.eq_ignore_ascii_case(BASE_CODE) { return Currency::eur(); } match currencies::Entity::find_enabled_by_code(&ctx.db, &code).await { Ok(Some(m)) => Currency { code: m.code, symbol: m.symbol, rate_e4: m.rate_e4, }, _ => Currency::eur(), } } /// One enabled, buyer-selectable alternative currency in the process-wide /// snapshot below. #[derive(Clone)] struct Selectable { code: String, symbol: String, rate_e4: i64, } /// Process-wide snapshot of the enabled alternative currencies, so the global /// chrome (the settings-menu switcher and the navbar rate) can be rendered via a /// Tera function without a per-request DB hit. Loaded at boot by /// `initializers::currency_seeder` and refreshed by the admin on every edit (see /// [`refresh_snapshot`]). EUR (the base) is implicit and never listed here. static ENABLED: RwLock> = RwLock::new(Vec::new()); /// Reload the [`ENABLED`] snapshot from the database. Call at boot and after any /// admin change to a currency's rate/enabled state. pub async fn refresh_snapshot(db: &C) -> Result<()> { let rows = currencies::Entity::find() .filter(currencies::Column::Enabled.eq(true)) .order_by_asc(currencies::Column::Code) .all(db) .await?; let list = rows .into_iter() .map(|m| Selectable { code: m.code, symbol: m.symbol, rate_e4: m.rate_e4, }) .collect(); *ENABLED.write().unwrap() = list; Ok(()) } /// The selectable currencies for templates (the Tera `currencies()` function): /// the EUR base plus every enabled alternative, each with a human rate string. /// `alts` is empty when the store is effectively EUR-only. #[must_use] pub fn selectable_json() -> serde_json::Value { let alts: Vec = ENABLED .read() .unwrap() .iter() .map(|s| { serde_json::json!({ "code": s.code, "symbol": s.symbol, "rate": format_rate(s.rate_e4), }) }) .collect(); serde_json::json!({ "base": { "code": BASE_CODE, "symbol": BASE_SYMBOL }, "alts": alts, }) } /// Parse an exchange rate typed in major units ("25", "25.3", "25,30", /// "25.3045") into `rate_e4` (×10000). Rejects negatives and >4 decimals. pub fn parse_rate(value: &str) -> Result { let value = value.trim().replace(',', "."); let invalid = || Error::BadRequest("invalid exchange rate".to_string()); let (whole, frac) = match value.split_once('.') { Some((w, f)) => (w, f), None => (value.as_str(), ""), }; if frac.len() > 4 || whole.is_empty() || !whole.chars().all(|c| c.is_ascii_digit()) { return Err(invalid()); } if !frac.chars().all(|c| c.is_ascii_digit()) { return Err(invalid()); } let whole: i64 = whole.parse().map_err(|_| invalid())?; // Right-pad the fractional part to exactly 4 digits. let padded = format!("{frac:0<4}"); let frac: i64 = if padded.is_empty() { 0 } else { padded.parse().map_err(|_| invalid())? }; let rate = whole * SCALE + frac; if rate <= 0 { return Err(invalid()); } Ok(rate) } /// Render `rate_e4` as a human string, trimming trailing zeros (253000 → "25.3", /// 250000 → "25"). #[must_use] pub fn format_rate(rate_e4: i64) -> String { let whole = rate_e4 / SCALE; let frac = (rate_e4 % SCALE).abs(); if frac == 0 { return whole.to_string(); } let frac = format!("{frac:04}"); let trimmed = frac.trim_end_matches('0'); format!("{whole}.{trimmed}") }