CZK implemented

This commit is contained in:
Priec
2026-06-23 12:54:11 +02:00
parent 6b7422806f
commit c409e85995
31 changed files with 606 additions and 51 deletions

144
src/shared/currency.rs Normal file
View 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}")
}