CZK implemented
This commit is contained in:
144
src/shared/currency.rs
Normal file
144
src/shared/currency.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
//! 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 axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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}")
|
||||
}
|
||||
Reference in New Issue
Block a user