From c409e85995929fd6a4b03c0a540f6de7eae073f9 Mon Sep 17 00:00:00 2001 From: Priec Date: Tue, 23 Jun 2026 12:54:11 +0200 Subject: [PATCH] CZK implemented --- assets/i18n/en/main.ftl | 8 ++ assets/i18n/sk/main.ftl | 8 ++ assets/views/admin/base.html | 4 + assets/views/admin/currencies/index.html | 44 ++++++ assets/views/partials/settings_dropdown.html | 21 +++ assets/views/shop/_card.html | 6 +- assets/views/shop/_cart_body.html | 8 +- assets/views/shop/_cart_preview.html | 6 +- assets/views/shop/_search.html | 2 +- assets/views/shop/show.html | 6 +- migration/src/lib.rs | 2 + migration/src/m20260623_000004_currencies.rs | 31 ++++ src/app.rs | 9 +- src/controllers/admin_currencies.rs | 92 ++++++++++++ src/controllers/cart.rs | 27 ++-- src/controllers/checkout.rs | 8 +- src/controllers/currency.rs | 39 +++++ src/controllers/home.rs | 8 +- src/controllers/i18n.rs | 2 +- src/controllers/mod.rs | 2 + src/controllers/shop.rs | 46 ++++-- src/initializers/currency_seeder.rs | 50 +++++++ src/initializers/mod.rs | 1 + src/models/_entities/currencies.rs | 22 +++ src/models/_entities/mod.rs | 1 + src/models/_entities/prelude.rs | 1 + src/models/currencies.rs | 40 ++++++ src/models/mod.rs | 1 + src/shared/currency.rs | 144 +++++++++++++++++++ src/shared/mod.rs | 1 + src/views/shop.rs | 17 ++- 31 files changed, 606 insertions(+), 51 deletions(-) create mode 100644 assets/views/admin/currencies/index.html create mode 100644 migration/src/m20260623_000004_currencies.rs create mode 100644 src/controllers/admin_currencies.rs create mode 100644 src/controllers/currency.rs create mode 100644 src/initializers/currency_seeder.rs create mode 100644 src/models/_entities/currencies.rs create mode 100644 src/models/currencies.rs create mode 100644 src/shared/currency.rs diff --git a/assets/i18n/en/main.ftl b/assets/i18n/en/main.ftl index 9051f51..2fc1023 100644 --- a/assets/i18n/en/main.ftl +++ b/assets/i18n/en/main.ftl @@ -20,6 +20,7 @@ admin-audio-desc = upload songs, then group them into albums. logout = Log out settings = Settings settings-language = Language +settings-currency = Currency settings-theme = Theme language-en = English language-sk = Slovak @@ -475,6 +476,13 @@ bank-amount = Amount admin-shipping = Shipping admin-shipping-desc = set the price and availability of each delivery option. shipping-enabled = Active +admin-currency = Currency +admin-currency-desc = set the exchange rate for the currencies customers can switch between. You always enter prices in EUR. +exchange-rate = Exchange rate +exchange-rate-hint = { $code } prices are the { $base } price recalculated at this rate. +currency-enabled = Available to customers +currency-base = Base currency +currency-base-hint = the currency you enter prices in and settle payment in. Cannot be changed. shipping-new = Add delivery option shipping-add = Add shipping-requires-pickup = Requires pickup point diff --git a/assets/i18n/sk/main.ftl b/assets/i18n/sk/main.ftl index bab5e8e..1e03de6 100644 --- a/assets/i18n/sk/main.ftl +++ b/assets/i18n/sk/main.ftl @@ -20,6 +20,7 @@ admin-audio-desc = nahrať skladby a potom ich zoskupiť do albumov. logout = Odhlásiť sa settings = Nastavenia settings-language = Jazyk +settings-currency = Mena settings-theme = Téma language-en = Angličtina language-sk = Slovenčina @@ -475,6 +476,13 @@ bank-amount = Suma admin-shipping = Doprava admin-shipping-desc = nastaviť cenu a dostupnosť jednotlivých možností dopravy. shipping-enabled = Aktívne +admin-currency = Mena +admin-currency-desc = nastaviť výmenný kurz pre meny, medzi ktorými môžu zákazníci prepínať. Ceny zadávate vždy v EUR. +exchange-rate = Výmenný kurz +exchange-rate-hint = ceny v { $code } sa prepočítajú z ceny v { $base } týmto kurzom. +currency-enabled = Dostupná pre zákazníkov +currency-base = Základná mena +currency-base-hint = mena, v ktorej zadávate ceny a prebieha platba. Nedá sa zmeniť. shipping-new = Pridať možnosť dopravy shipping-add = Pridať shipping-requires-pickup = Vyžaduje výdajné miesto diff --git a/assets/views/admin/base.html b/assets/views/admin/base.html index aff0fc5..895f86e 100644 --- a/assets/views/admin/base.html +++ b/assets/views/admin/base.html @@ -105,6 +105,10 @@ class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong"> {{ t(key="admin-shipping", lang=lang | default(value='sk')) }} + + {{ t(key="admin-currency", lang=lang | default(value='sk')) }} +
diff --git a/assets/views/admin/currencies/index.html b/assets/views/admin/currencies/index.html new file mode 100644 index 0000000..244d0f5 --- /dev/null +++ b/assets/views/admin/currencies/index.html @@ -0,0 +1,44 @@ +{% extends "admin/base.html" %} +{% import "macros/ui.html" as ui %} + +{% block title %}{{ t(key="admin-currency", lang=lang | default(value='sk')) }}{% endblock title %} +{% block crumb %}{{ t(key="admin-currency", lang=lang | default(value='sk')) }}{% endblock crumb %} + +{% block content %} +
+

{{ t(key="admin-currency", lang=lang | default(value='sk')) }}

+

{{ t(key="admin-currency-desc", lang=lang | default(value='sk')) }}

+
+ +
+ +
+
+

{{ base_code }} ({{ base_symbol }})

+

{{ t(key="currency-base-hint", lang=lang | default(value='sk')) }}

+
+ {{ ui::badge(label=t(key="currency-base", lang=lang | default(value='sk')), variant="neutral") }} +
+ + {% for c in currencies %} +
+ {{ ui::csrf_field() }} +
+

{{ c.code }} ({{ c.symbol }})

+

{{ t(key="exchange-rate-hint", code=c.code, base=base_code, lang=lang | default(value='sk')) }}

+
+
+ + + 1 {{ base_code }} = + {{ ui::input(name="rate", id="rate-" ~ c.id, value=c.rate, width="w-28", attrs='inputmode="decimal"') }} + {{ c.code }} + +
+
{{ ui::checkbox(name="enabled", label=t(key="currency-enabled", lang=lang | default(value='sk')), checked=c.enabled) }}
+ {{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", extra="ml-auto") }} +
+ {% endfor %} +
+{% endblock content %} diff --git a/assets/views/partials/settings_dropdown.html b/assets/views/partials/settings_dropdown.html index e3c3561..788a7f0 100644 --- a/assets/views/partials/settings_dropdown.html +++ b/assets/views/partials/settings_dropdown.html @@ -35,6 +35,27 @@ {% if lang | default(value='sk') == "sk" %}{% endif %} + {# Currency switcher. The active code is read from the `currency` cookie + client-side (Alpine), so this partial needs no per-page server data; posting + to /currency sets the cookie and reloads. EUR is the base; CZK prices are the + EUR price recalculated at the admin-set rate. #} +

+ {{ t(key="settings-currency", lang=lang | default(value='sk')) }} +

+
+ + + +

{{ t(key="settings-theme", lang=lang | default(value='sk')) }}

diff --git a/assets/views/shop/_card.html b/assets/views/shop/_card.html index 8dcb4b2..134e08e 100644 --- a/assets/views/shop/_card.html +++ b/assets/views/shop/_card.html @@ -38,11 +38,11 @@ {% endif %} {% if product.on_sale %}
- Price{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} € - {{ product.regular_price }} € + Price{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ currency_symbol }} + {{ product.regular_price }} {{ currency_symbol }}
{% else %} - Price{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} € + Price{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ currency_symbol }} {% endif %}
diff --git a/assets/views/shop/_cart_body.html b/assets/views/shop/_cart_body.html index cc4b352..3262f10 100644 --- a/assets/views/shop/_cart_body.html +++ b/assets/views/shop/_cart_body.html @@ -23,10 +23,10 @@ {% if item.on_sale %} - {{ item.price }} € + {{ item.price }} {{ currency_symbol }} {{ item.regular_price }} {% else %} - {{ item.price }} € + {{ item.price }} {{ currency_symbol }} {% endif %} @@ -48,7 +48,7 @@ class="w-20 rounded-radius border border-outline bg-surface-alt px-2 py-1 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark"> - {{ item.line_total }} € + {{ item.line_total }} {{ currency_symbol }}
@@ -63,7 +63,7 @@ {{ t(key="cart-total", lang=lang | default(value='sk')) }} - {{ total }} € + {{ total }} {{ currency_symbol }} diff --git a/assets/views/shop/_cart_preview.html b/assets/views/shop/_cart_preview.html index 6075694..4549de2 100644 --- a/assets/views/shop/_cart_preview.html +++ b/assets/views/shop/_cart_preview.html @@ -9,16 +9,16 @@
{{ item.name }} {% if item.variant_label %}{{ item.variant_label }}{% endif %} -

{{ item.quantity }} × {{ item.price }} €

+

{{ item.quantity }} × {{ item.price }} {{ currency_symbol }}

- {{ item.line_total }} € + {{ item.line_total }} {{ currency_symbol }} {% endfor %}
{{ t(key="cart-total", lang=lang | default(value='sk')) }} - {{ total }} € + {{ total }} {{ currency_symbol }}
{{ ui::button(href="/cart", variant="outline-primary", label=t(key="cart-title", lang=lang | default(value='sk')), extra="flex-1", attrs='hx-boost="false"') }} diff --git a/assets/views/shop/_search.html b/assets/views/shop/_search.html index ec8d86e..a05aa20 100644 --- a/assets/views/shop/_search.html +++ b/assets/views/shop/_search.html @@ -65,7 +65,7 @@
@@ -75,10 +75,10 @@

- € + {{ currency_symbol }}

diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 9de9c52..16a7a74 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -48,6 +48,7 @@ mod m20260622_000006_order_search_indexes; mod m20260623_000001_add_short_description_to_products; mod m20260623_000002_strip_html_from_product_search; mod m20260623_000003_drop_currency; +mod m20260623_000004_currencies; pub struct Migrator; #[async_trait::async_trait] @@ -100,6 +101,7 @@ impl MigratorTrait for Migrator { Box::new(m20260623_000001_add_short_description_to_products::Migration), Box::new(m20260623_000002_strip_html_from_product_search::Migration), Box::new(m20260623_000003_drop_currency::Migration), + Box::new(m20260623_000004_currencies::Migration), // inject-above (do not remove this comment) ] } diff --git a/migration/src/m20260623_000004_currencies.rs b/migration/src/m20260623_000004_currencies.rs new file mode 100644 index 0000000..bf4d0c1 --- /dev/null +++ b/migration/src/m20260623_000004_currencies.rs @@ -0,0 +1,31 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> { + // Buyer-selectable display currencies. EUR is the base/transaction + // currency and is NOT stored here; each row is an alternative the buyer + // can switch to, whose prices are the EUR price recalculated at + // `rate_e4` (units of this currency per 1 EUR, scaled ×10000). For now + // the only row is CZK, seeded by `initializers::currency_seeder`. + create_table(m, "currencies", + &[ + ("id", ColType::PkAuto), + ("code", ColType::StringUniq), + ("symbol", ColType::String), + ("rate_e4", ColType::BigIntegerWithDefault(10_000)), + ("enabled", ColType::BooleanWithDefault(true)), + ], + &[ + ] + ).await + } + + async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { + drop_table(m, "currencies").await + } +} diff --git a/src/app.rs b/src/app.rs index dfa4791..2fc1bb0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,9 +17,9 @@ use std::{path::Path, sync::Arc}; #[allow(unused_imports)] use crate::{ controllers::{ - account, admin_categories, admin_customers, admin_dashboard, admin_discount_profiles, - admin_form, admin_orders, admin_products, admin_shipping, auth, auth_pages, - cart, checkout, home, i18n, media, oauth2, + account, admin_categories, admin_currencies, admin_customers, admin_dashboard, + admin_discount_profiles, admin_form, admin_orders, admin_products, admin_shipping, + auth, auth_pages, cart, checkout, currency, home, i18n, media, oauth2, shop, }, initializers, @@ -83,6 +83,7 @@ impl Hooks for App { Box::new(initializers::view_engine::ViewEngineInitializer), Box::new(initializers::admin_seeder::AdminSeeder), Box::new(initializers::shipping_seeder::ShippingSeeder), + Box::new(initializers::currency_seeder::CurrencySeeder), Box::new(initializers::oauth2::OAuth2StoreInitializer), Box::new(initializers::oauth2_session::OAuth2SessionInitializer), ]) @@ -95,6 +96,7 @@ impl Hooks for App { .add_route(shop::routes()) .add_route(cart::routes()) .add_route(checkout::routes()) + .add_route(currency::routes()) // cross-cutting .add_route(auth::routes()) .add_route(auth_pages::routes()) @@ -110,6 +112,7 @@ impl Hooks for App { .add_route(admin_orders::routes()) .add_route(admin_customers::routes()) .add_route(admin_shipping::routes()) + .add_route(admin_currencies::routes()) } async fn after_context(ctx: AppContext) -> Result { diff --git a/src/controllers/admin_currencies.rs b/src/controllers/admin_currencies.rs new file mode 100644 index 0000000..e881236 --- /dev/null +++ b/src/controllers/admin_currencies.rs @@ -0,0 +1,92 @@ +//! Admin management of the alternative display currencies. +//! +//! EUR is the base/transaction currency and is shown read-only for context. The +//! admin sets each alternative currency's exchange rate (units per 1 EUR) and +//! toggles whether buyers may switch to it. The currencies themselves are fixed +//! and seeded by `initializers::currency_seeder`. + +use axum_extra::extract::cookie::CookieJar; +use loco_rs::prelude::*; +use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set}; +use serde::Deserialize; +use serde_json::json; + +use crate::{ + controllers::i18n::current_lang, + models::currencies, + shared::{ + currency::{self, BASE_CODE, BASE_SYMBOL}, + guard, + }, +}; + +#[derive(Debug, Deserialize)] +struct CurrencyForm { + rate: String, + enabled: Option, +} + +fn is_checked(value: &Option) -> bool { + matches!(value.as_deref(), Some("on" | "true" | "1")) +} + +#[debug_handler] +async fn index( + auth: auth::JWT, + jar: CookieJar, + ViewEngine(v): ViewEngine, + State(ctx): State, +) -> Result { + guard::current_admin(auth, &ctx).await?; + let rows = currencies::Entity::find() + .order_by_asc(currencies::Column::Code) + .all(&ctx.db) + .await?; + let currencies_json: Vec = rows + .iter() + .map(|c| { + json!({ + "id": c.id, + "code": c.code, + "symbol": c.symbol, + "rate": currency::format_rate(c.rate_e4), + "enabled": c.enabled, + }) + }) + .collect(); + format::view( + &v, + "admin/currencies/index.html", + json!({ + "base_code": BASE_CODE, + "base_symbol": BASE_SYMBOL, + "currencies": currencies_json, + "lang": current_lang(&jar), + }), + ) +} + +#[debug_handler] +async fn update( + auth: auth::JWT, + Path(id): Path, + State(ctx): State, + Form(form): Form, +) -> Result { + guard::current_admin(auth, &ctx).await?; + let row = currencies::Entity::find_by_id(id) + .one(&ctx.db) + .await? + .ok_or_else(|| Error::NotFound)?; + let mut active = row.into_active_model(); + active.rate_e4 = Set(currency::parse_rate(&form.rate)?); + active.enabled = Set(is_checked(&form.enabled)); + active.update(&ctx.db).await?; + format::redirect("/admin/currencies") +} + +pub fn routes() -> Routes { + Routes::new() + .add("/admin/currencies", get(index)) + .add("/admin/currencies/{id}", post(update)) +} diff --git a/src/controllers/cart.rs b/src/controllers/cart.rs index a07c86b..5ae8c80 100644 --- a/src/controllers/cart.rs +++ b/src/controllers/cart.rs @@ -1,4 +1,4 @@ -use crate::{controllers::i18n::current_lang, shared::{guard, money::format_price, pricing}, models::{product_variants, products}}; +use crate::{controllers::i18n::current_lang, shared::{currency::{self, Currency}, guard, pricing}, models::{product_variants, products}}; use axum::{ http::{HeaderMap, StatusCode}, response::Redirect, @@ -173,7 +173,8 @@ async fn cart_response( return Ok((jar, Redirect::to("/cart")).into_response()); } - let (lines, valid, total) = resolve_cart(ctx, &jar).await?; + let cur = currency::resolve(ctx, &jar).await; + let (lines, valid, total) = resolve_cart(ctx, &jar, &cur).await?; // Persist the re-validated cookie (drops now-invalid lines). let jar = jar.add(cart_cookie(serialize_cart(&valid))); let response = format::view( @@ -181,7 +182,8 @@ async fn cart_response( "shop/_cart_body.html", json!({ "items": lines, - "total": format_price(total), + "total": cur.format(total), + "currency_symbol": cur.symbol, "lang": current_lang(&jar), }), )?; @@ -194,6 +196,7 @@ async fn cart_response( pub(crate) async fn resolve_cart( ctx: &AppContext, jar: &CookieJar, + cur: &Currency, ) -> Result<(Vec, Vec<(i32, i32)>, i64)> { // Resolve the cart entries to in-stock products first, then price them all // for the current viewer in one batch (the price depends on who's logged in). @@ -226,12 +229,12 @@ pub(crate) async fn resolve_cart( "name": product.name, "variant_label": variant.label, "slug": product.slug, - "price": format_price(unit_price), - "regular_price": format_price(priced.regular_cents), + "price": cur.format(unit_price), + "regular_price": cur.format(priced.regular_cents), "on_sale": priced.is_reduced(), "quantity": qty, "stock": variant.stock, - "line_total": format_price(line_total), + "line_total": cur.format(line_total), })); } @@ -244,7 +247,8 @@ async fn show( ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { - let (lines, valid, total) = resolve_cart(&ctx, &jar).await?; + let cur = currency::resolve(&ctx, &jar).await; + let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?; // Drop any now-invalid lines from the cookie so the badge stays accurate. let rebuilt = serialize_cart(&valid); @@ -254,7 +258,8 @@ async fn show( "shop/cart.html", json!({ "items": lines, - "total": format_price(total), + "total": cur.format(total), + "currency_symbol": cur.symbol, "logged_in_admin": c.logged_in_admin, "logged_in_customer": c.logged_in_customer, "customer_name": c.customer_name, @@ -274,14 +279,16 @@ async fn preview( ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { - let (lines, valid, total) = resolve_cart(&ctx, &jar).await?; + let cur = currency::resolve(&ctx, &jar).await; + let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?; let rebuilt = serialize_cart(&valid); let response = format::view( &v, "shop/_cart_preview.html", json!({ "items": lines, - "total": format_price(total), + "total": cur.format(total), + "currency_symbol": cur.symbol, "lang": current_lang(&jar), }), )?; diff --git a/src/controllers/checkout.rs b/src/controllers/checkout.rs index d9ef45d..0d991ae 100644 --- a/src/controllers/checkout.rs +++ b/src/controllers/checkout.rs @@ -18,7 +18,7 @@ use crate::{ users::{self, normalize_account_type}, }, controllers::i18n::current_lang, - shared::{guard, money::format_price, settings}, + shared::{currency::Currency, guard, money::format_price, settings}, views::checkout as view, }; @@ -77,7 +77,9 @@ async fn checkout_page( ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { - let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar).await?; + // Checkout and everything past it (orders, confirmation) stay in the EUR + // base — the settlement currency — even when the buyer browsed in another. + let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar, &Currency::eur()).await?; if lines.is_empty() { return format::redirect("/cart"); } @@ -159,7 +161,7 @@ async fn place_order( State(ctx): State, Form(form): Form, ) -> Result { - let (_lines, valid, _total) = resolve_cart(&ctx, &jar).await?; + let (_lines, valid, _total) = resolve_cart(&ctx, &jar, &Currency::eur()).await?; if valid.is_empty() { return format::redirect("/cart"); } diff --git a/src/controllers/currency.rs b/src/controllers/currency.rs new file mode 100644 index 0000000..2556302 --- /dev/null +++ b/src/controllers/currency.rs @@ -0,0 +1,39 @@ +//! Storefront display-currency switcher. +//! +//! Sets the `currency` cookie to the buyer's chosen display currency, then sends +//! them back where they were. EUR is the base; any other code must name an +//! enabled row in `currencies` or it falls back to EUR on the next render. + +use axum::{ + http::{header, HeaderMap}, + response::Redirect, +}; +use loco_rs::prelude::*; +use serde::Deserialize; + +use crate::controllers::i18n::back_path; +use crate::shared::currency::{BASE_CODE, COOKIE}; + +#[derive(Debug, Deserialize)] +pub struct CurrencyForm { + pub currency: String, +} + +#[debug_handler] +async fn set_currency(headers: HeaderMap, Form(form): Form) -> Result { + // Store the code uppercased; validation against the enabled set happens at + // render time (shared::currency::resolve), which falls back to EUR. + let code = form.currency.trim().to_uppercase(); + let code = if code.is_empty() { BASE_CODE.to_string() } else { code }; + let cookie = format!("{COOKIE}={code}; Path=/; Max-Age=31536000; SameSite=Lax"); + + Ok(( + [(header::SET_COOKIE, cookie)], + Redirect::to(&back_path(&headers)), + ) + .into_response()) +} + +pub fn routes() -> Routes { + Routes::new().add("/currency", post(set_currency)) +} diff --git a/src/controllers/home.rs b/src/controllers/home.rs index 08752f9..c32314b 100644 --- a/src/controllers/home.rs +++ b/src/controllers/home.rs @@ -4,7 +4,9 @@ use axum_extra::extract::cookie::CookieJar; use loco_rs::prelude::*; use serde_json::json; -use crate::{controllers::i18n::current_lang, shared::guard, controllers::shop}; +use crate::{ + controllers::i18n::current_lang, controllers::shop, shared::currency, shared::guard, +}; #[debug_handler] async fn index( @@ -13,7 +15,8 @@ async fn index( State(ctx): State, ) -> Result { let user = guard::current_user(&ctx, &jar).await; - let products = shop::featured_products(&ctx, user.as_ref(), 8).await?; + let cur = currency::resolve(&ctx, &jar).await; + let products = shop::featured_products(&ctx, user.as_ref(), 8, &cur).await?; let c = guard::chrome_from(&ctx, user.as_ref()); format::view( @@ -25,6 +28,7 @@ async fn index( "logged_in_customer": c.logged_in_customer, "customer_name": c.customer_name, "customer_account_type": c.customer_account_type, + "currency_symbol": cur.symbol, "lang": current_lang(&jar), }), ) diff --git a/src/controllers/i18n.rs b/src/controllers/i18n.rs index e6bd529..1fba941 100644 --- a/src/controllers/i18n.rs +++ b/src/controllers/i18n.rs @@ -34,7 +34,7 @@ async fn set_lang(headers: HeaderMap, Form(form): Form) -> Result String { +pub(crate) fn back_path(headers: &HeaderMap) -> String { let raw = headers .get(header::REFERER) .and_then(|value| value.to_str().ok()) diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 327246d..e1b5ffc 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -3,6 +3,7 @@ pub mod auth; pub mod auth_pages; pub mod oauth2; pub mod admin_categories; +pub mod admin_currencies; pub mod admin_customers; pub mod admin_dashboard; pub mod admin_discount_profiles; @@ -12,6 +13,7 @@ pub mod admin_products; pub mod admin_shipping; pub mod cart; pub mod checkout; +pub mod currency; pub mod home; pub mod i18n; pub mod media; diff --git a/src/controllers/shop.rs b/src/controllers/shop.rs index 4bc8e2d..8d7efc5 100644 --- a/src/controllers/shop.rs +++ b/src/controllers/shop.rs @@ -13,8 +13,9 @@ use serde_json::json; use crate::{ controllers::i18n::current_lang, shared::{ + currency::{self, Currency}, guard, - money::{format_price, parse_price_to_cents}, + money::parse_price_to_cents, pricing, }, models::{categories, product_images, product_variants, products, users}, @@ -90,6 +91,7 @@ async fn run_search( ctx: &AppContext, user: Option<&users::Model>, params: &SearchParams, + cur: &Currency, ) -> Result { let q = params.q.clone().unwrap_or_default(); let q_trim = q.trim().to_string(); @@ -136,9 +138,19 @@ async fn run_search( let price_floor = items.iter().map(|i| i.priced.price_cents).min().unwrap_or(0); let price_ceil = items.iter().map(|i| i.priced.price_cents).max().unwrap_or(0); - // 3. Non-category filters: price band + in-stock. - let min_c = params.min_price.as_deref().and_then(|s| parse_price_to_cents(s).ok()); - let max_c = params.max_price.as_deref().and_then(|s| parse_price_to_cents(s).ok()); + // 3. Non-category filters: price band + in-stock. The typed bounds are in + // the buyer's display currency; convert them back to EUR cents to compare + // against the (EUR) resolved prices. + let min_c = params + .min_price + .as_deref() + .and_then(|s| parse_price_to_cents(s).ok()) + .map(|c| cur.to_eur_cents(c)); + let max_c = params + .max_price + .as_deref() + .and_then(|s| parse_price_to_cents(s).ok()) + .map(|c| cur.to_eur_cents(c)); let in_stock_only = is_on(¶ms.in_stock); items.retain(|i| { min_c.is_none_or(|m| i.priced.price_cents >= m) @@ -203,6 +215,7 @@ async fn run_search( item.count, image, cat_name, + cur, )); } @@ -219,8 +232,9 @@ async fn run_search( "in_stock": in_stock_only, "min_price": params.min_price.clone().unwrap_or_default(), "max_price": params.max_price.clone().unwrap_or_default(), - "price_floor": format_price(price_floor), - "price_ceil": format_price(price_ceil), + "price_floor": cur.format(price_floor), + "price_ceil": cur.format(price_ceil), + "currency_symbol": cur.symbol, "total": total, "page": page, "pages": pages, @@ -240,6 +254,7 @@ async fn product_rows( ctx: &AppContext, user: Option<&users::Model>, list: Vec, + cur: &Currency, ) -> Result> { let ids: Vec = list.iter().map(|p| p.id).collect(); let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?; @@ -261,7 +276,7 @@ async fn product_rows( let mut rows = Vec::with_capacity(entries.len()); for ((product, rep, count), priced) in entries.iter().zip(priced.iter()) { let image = product_images::first_for(ctx, product.id).await?; - rows.push(view::product_card(product, rep, priced, *count, image, None)); + rows.push(view::product_card(product, rep, priced, *count, image, None, cur)); } Ok(rows) } @@ -272,6 +287,7 @@ pub(crate) async fn featured_products( ctx: &AppContext, user: Option<&users::Model>, limit: u64, + cur: &Currency, ) -> Result> { let list = products::Entity::find() .filter(products::Column::Published.eq(true)) @@ -279,7 +295,7 @@ pub(crate) async fn featured_products( .limit(limit) .all(&ctx.db) .await?; - product_rows(ctx, user, list).await + product_rows(ctx, user, list, cur).await } /// The site-wide category sidebar, loaded lazily via htmx by the base layout so @@ -320,7 +336,8 @@ async fn index( State(ctx): State, ) -> Result { let user = guard::current_user(&ctx, &jar).await; - let mut context = run_search(&ctx, user.as_ref(), &SearchParams::default()).await?; + let cur = currency::resolve(&ctx, &jar).await; + let mut context = run_search(&ctx, user.as_ref(), &SearchParams::default(), &cur).await?; let c = guard::chrome_from(&ctx, user.as_ref()); add_chrome(&mut context, &c, ¤t_lang(&jar)); format::view(&v, "shop/index.html", context) @@ -341,7 +358,8 @@ async fn search( State(ctx): State, ) -> Result { let user = guard::current_user(&ctx, &jar).await; - let mut context = run_search(&ctx, user.as_ref(), ¶ms).await?; + let cur = currency::resolve(&ctx, &jar).await; + let mut context = run_search(&ctx, user.as_ref(), ¶ms, &cur).await?; let lang = current_lang(&jar); if headers.contains_key("HX-Request") { @@ -385,12 +403,13 @@ async fn show( }; let user = guard::current_user(&ctx, &jar).await; + let cur = currency::resolve(&ctx, &jar).await; let variants = product_variants::Entity::for_product(&ctx.db, product.id).await?; let variant_prices = pricing::price_variants(&ctx, &variants, user.as_ref()).await?; let options: Vec = variants .iter() .zip(variant_prices.iter()) - .map(|(variant, priced)| view::variant_option(variant, priced)) + .map(|(variant, priced)| view::variant_option(variant, priced, &cur)) .collect(); // The card header uses the representative (first) variant for its headline // price; the picker below lets the customer switch. @@ -404,6 +423,7 @@ async fn show( variants.len(), None, category.as_ref().map(|c| c.name.clone()), + &cur, ), // A product with no variants isn't purchasable; show it without a price. _ => serde_json::json!({ @@ -428,6 +448,7 @@ async fn show( "logged_in_customer": c.logged_in_customer, "customer_name": c.customer_name, "customer_account_type": c.customer_account_type, + "currency_symbol": cur.symbol, "lang": current_lang(&jar), }), ) @@ -463,7 +484,8 @@ async fn category( }; let user = guard::current_user(&ctx, &jar).await; - let mut context = run_search(&ctx, user.as_ref(), ¶ms).await?; + let cur = currency::resolve(&ctx, &jar).await; + let mut context = run_search(&ctx, user.as_ref(), ¶ms, &cur).await?; if let Some(map) = context.as_object_mut() { map.insert("category".into(), serde_json::to_value(&category)?); map.insert("breadcrumbs".into(), serde_json::to_value(&breadcrumbs)?); diff --git a/src/initializers/currency_seeder.rs b/src/initializers/currency_seeder.rs new file mode 100644 index 0000000..bd6c868 --- /dev/null +++ b/src/initializers/currency_seeder.rs @@ -0,0 +1,50 @@ +//! Ensures the built-in alternative display currencies always exist. +//! +//! EUR is the base currency and is never stored. For now the only alternative is +//! the Czech koruna (CZK); the admin sets its exchange rate and can disable it. +//! We insert each one only when its `code` is missing, so an admin's rate/enabled +//! changes are never overwritten on the next boot. + +use async_trait::async_trait; +use loco_rs::prelude::*; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set}; + +use crate::models::currencies; +use crate::shared::currency::SCALE; + +/// `(code, symbol, default_rate_e4)` — default rate is a placeholder the admin +/// is expected to update from the live FX rate. +const BUILTINS: [(&str, &str, i64); 1] = [("CZK", "Kč", 25 * SCALE)]; + +pub struct CurrencySeeder; + +#[async_trait] +impl Initializer for CurrencySeeder { + fn name(&self) -> String { + "currency-seeder".to_string() + } + + async fn before_run(&self, ctx: &AppContext) -> Result<()> { + for (code, symbol, rate_e4) in BUILTINS { + let exists = currencies::Entity::find() + .filter(currencies::Column::Code.eq(code)) + .count(&ctx.db) + .await? + > 0; + if exists { + continue; + } + currencies::ActiveModel { + code: Set(code.to_string()), + symbol: Set(symbol.to_string()), + rate_e4: Set(rate_e4), + enabled: Set(true), + ..Default::default() + } + .insert(&ctx.db) + .await?; + tracing::info!(currency = code, "seeded display currency"); + } + Ok(()) + } +} diff --git a/src/initializers/mod.rs b/src/initializers/mod.rs index d5c0301..4163c46 100644 --- a/src/initializers/mod.rs +++ b/src/initializers/mod.rs @@ -1,4 +1,5 @@ pub mod admin_seeder; +pub mod currency_seeder; pub mod oauth2; pub mod oauth2_session; pub mod shipping_seeder; diff --git a/src/models/_entities/currencies.rs b/src/models/_entities/currencies.rs new file mode 100644 index 0000000..246b9f3 --- /dev/null +++ b/src/models/_entities/currencies.rs @@ -0,0 +1,22 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "currencies")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(unique)] + pub code: String, + pub symbol: String, + /// Units of this currency per 1 EUR, scaled ×10000 (e.g. 25.30 → 253000). + pub rate_e4: i64, + pub enabled: bool, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} diff --git a/src/models/_entities/mod.rs b/src/models/_entities/mod.rs index 8e70f89..ba3e0b9 100644 --- a/src/models/_entities/mod.rs +++ b/src/models/_entities/mod.rs @@ -8,6 +8,7 @@ pub mod account_product_resolutions; pub mod audience_discount_profiles; pub mod audit_logs; pub mod categories; +pub mod currencies; pub mod customer_profiles; pub mod discount_profile_products; pub mod discount_profiles; diff --git a/src/models/_entities/prelude.rs b/src/models/_entities/prelude.rs index e50216e..ee1e63a 100644 --- a/src/models/_entities/prelude.rs +++ b/src/models/_entities/prelude.rs @@ -6,6 +6,7 @@ pub use super::account_product_resolutions::Entity as AccountProductResolutions; pub use super::audience_discount_profiles::Entity as AudienceDiscountProfiles; pub use super::audit_logs::Entity as AuditLogs; pub use super::categories::Entity as Categories; +pub use super::currencies::Entity as Currencies; pub use super::customer_profiles::Entity as CustomerProfiles; pub use super::discount_profile_products::Entity as DiscountProfileProducts; pub use super::discount_profiles::Entity as DiscountProfiles; diff --git a/src/models/currencies.rs b/src/models/currencies.rs new file mode 100644 index 0000000..e26fc7c --- /dev/null +++ b/src/models/currencies.rs @@ -0,0 +1,40 @@ +use sea_orm::entity::prelude::*; +pub use crate::models::_entities::currencies::{ActiveModel, Column, Entity, Model}; +pub type Currencies = Entity; + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel { + async fn before_save(self, _db: &C, insert: bool) -> std::result::Result + where + C: ConnectionTrait, + { + if !insert && self.updated_at.is_unchanged() { + let mut this = self; + this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into()); + Ok(this) + } else { + Ok(self) + } + } +} + +// implement your read-oriented logic here +impl Model {} + +// implement your write-oriented logic here +impl ActiveModel {} + +// implement your custom finders, selectors oriented logic here +impl Entity { + /// An enabled currency by its ISO code (case-insensitive), or `None`. + pub async fn find_enabled_by_code( + db: &C, + code: &str, + ) -> Result, DbErr> { + Entity::find() + .filter(Column::Code.eq(code.to_uppercase())) + .filter(Column::Enabled.eq(true)) + .one(db) + .await + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 1b165df..e9b6721 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -12,6 +12,7 @@ pub mod account_product_resolutions; pub mod audience_discount_profiles; pub mod audit_logs; pub mod categories; +pub mod currencies; pub mod discount_profile_products; pub mod discount_profiles; pub mod customer_profiles; diff --git a/src/shared/currency.rs b/src/shared/currency.rs new file mode 100644 index 0000000..0d110af --- /dev/null +++ b/src/shared/currency.rs @@ -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 { + 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}") +} diff --git a/src/shared/mod.rs b/src/shared/mod.rs index 7bc2493..addac79 100644 --- a/src/shared/mod.rs +++ b/src/shared/mod.rs @@ -1,6 +1,7 @@ //! Cross-cutting helpers used across feature slices. pub mod csrf; +pub mod currency; pub mod guard; pub mod money; pub mod pricing; diff --git a/src/views/shop.rs b/src/views/shop.rs index df30c51..f52821d 100644 --- a/src/views/shop.rs +++ b/src/views/shop.rs @@ -3,7 +3,7 @@ use serde_json::{json, Value}; use crate::models::_entities::{categories, product_images, product_variants, products}; -use crate::shared::money::format_price; +use crate::shared::currency::Currency; use crate::shared::pricing::PricedProduct; /// Card/list shape for a product: model fields plus the viewer's resolved price @@ -20,6 +20,7 @@ pub fn product_card( variant_count: usize, image: Option, category_name: Option, + cur: &Currency, ) -> Value { json!({ "id": product.id, @@ -28,10 +29,10 @@ pub fn product_card( "slug": product.slug, "description": product.description, "short_description": product.short_description, - "price": format_price(priced.price_cents), + "price": cur.format(priced.price_cents), "on_sale": priced.is_reduced(), "is_business": priced.is_business, - "regular_price": format_price(priced.regular_cents), + "regular_price": cur.format(priced.regular_cents), "sku": representative.sku, "stock": representative.stock, "tracked": representative.tracked(), @@ -45,7 +46,11 @@ pub fn product_card( } /// One priced variant row for the product detail page's option picker. -pub fn variant_option(variant: &product_variants::Model, priced: &PricedProduct) -> Value { +pub fn variant_option( + variant: &product_variants::Model, + priced: &PricedProduct, + cur: &Currency, +) -> Value { json!({ "id": variant.id, "label": variant.label, @@ -53,9 +58,9 @@ pub fn variant_option(variant: &product_variants::Model, priced: &PricedProduct) "stock": variant.stock, "tracked": variant.tracked(), "in_stock": variant.in_stock(), - "price": format_price(priced.price_cents), + "price": cur.format(priced.price_cents), "on_sale": priced.is_reduced(), - "regular_price": format_price(priced.regular_cents), + "regular_price": cur.format(priced.regular_cents), "is_business": priced.is_business, }) }