207 lines
6.8 KiB
Rust
207 lines
6.8 KiB
Rust
//! 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<Vec<Selectable>> = 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<C: sea_orm::ConnectionTrait>(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<serde_json::Value> = 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<i64> {
|
||
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}")
|
||
}
|