From d2b463135b6c9eb209dd5c2092c574f78f88f41d Mon Sep 17 00:00:00 2001 From: Priec Date: Mon, 22 Jun 2026 00:04:01 +0200 Subject: [PATCH] discount for business and personall in discount page --- assets/i18n/en/main.ftl | 3 + assets/i18n/sk/main.ftl | 3 + assets/views/admin/catalog/discount_form.html | 15 ++- assets/views/admin/catalog/discounts.html | 35 ++++-- migration/src/lib.rs | 2 + ...004_add_business_sale_price_to_products.rs | 20 ++++ src/controllers/admin_discounts.rs | 108 +++++++++++++----- src/models/_entities/products.rs | 1 + src/models/products.rs | 7 ++ src/shared/pricing.rs | 8 +- 10 files changed, 158 insertions(+), 44 deletions(-) create mode 100644 migration/src/m20260621_000004_add_business_sale_price_to_products.rs diff --git a/assets/i18n/en/main.ftl b/assets/i18n/en/main.ftl index f64a9f1..b7ec7e0 100644 --- a/assets/i18n/en/main.ftl +++ b/assets/i18n/en/main.ftl @@ -211,6 +211,9 @@ price = Price sale-price = Sale price admin-discounts = Discounts admin-discounts-desc = Set discounted product prices. A discount shows up as a sale in the shop. +business-discount-desc = A baseline discount for all business accounts (off the regular price). Profiles and negotiated prices apply on top (lowest price wins). +audience-personal = Personal +audience-business = Business on-sale = On sale no-discount = No discount discount = Discount diff --git a/assets/i18n/sk/main.ftl b/assets/i18n/sk/main.ftl index e5c054e..2a96641 100644 --- a/assets/i18n/sk/main.ftl +++ b/assets/i18n/sk/main.ftl @@ -211,6 +211,9 @@ price = Cena sale-price = Zľavnená cena admin-discounts = Zľavy admin-discounts-desc = Nastavte zľavnené ceny produktov. Zľava sa v obchode zobrazí ako akcia. +business-discount-desc = Základná zľava pre všetky firemné účty (z bežnej ceny). Profily a dohodnuté ceny sa uplatnia navyše (platí najnižšia cena). +audience-personal = Osobné +audience-business = Firemné on-sale = V akcii no-discount = Bez zľavy discount = Zľava diff --git a/assets/views/admin/catalog/discount_form.html b/assets/views/admin/catalog/discount_form.html index d87f748..ae0b8aa 100644 --- a/assets/views/admin/catalog/discount_form.html +++ b/assets/views/admin/catalog/discount_form.html @@ -6,11 +6,16 @@ {% block content %}
-

{{ product.name }}

- {{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/discounts", size="px-3 py-2 text-sm") }} +
+

{{ product.name }}

+

+ {% if audience == "business" %}{{ t(key="audience-business", lang=lang | default(value='sk')) }}{% else %}{{ t(key="audience-personal", lang=lang | default(value='sk')) }}{% endif %} +

+
+ {{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/discounts?audience=" ~ audience, size="px-3 py-2 text-sm") }}
-
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit") }} - {% if product.on_sale %} - {{ ui::button(variant="outline-danger", label=t(key="remove-discount", lang=lang | default(value='sk')), type="submit", attrs='formaction="/admin/catalog/discounts/' ~ product.id ~ '/remove"') }} + {% if has_discount %} + {{ ui::button(variant="outline-danger", label=t(key="remove-discount", lang=lang | default(value='sk')), type="submit", attrs='formaction="/admin/catalog/discounts/' ~ product.id ~ '/remove?audience=' ~ audience ~ '"') }} {% endif %}
diff --git a/assets/views/admin/catalog/discounts.html b/assets/views/admin/catalog/discounts.html index 884763f..17f233f 100644 --- a/assets/views/admin/catalog/discounts.html +++ b/assets/views/admin/catalog/discounts.html @@ -5,14 +5,29 @@ {% block crumb %}{{ t(key="admin-discounts", lang=lang | default(value='sk')) }}{% endblock crumb %} {% block content %} +{% set business = audience == "business" %}

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

-

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

+

+ {% if business %}{{ t(key="business-discount-desc", lang=lang | default(value='sk')) }}{% else %}{{ t(key="admin-discounts-desc", lang=lang | default(value='sk')) }}{% endif %} +

-
+ + + +
{% if products | length > 0 %} @@ -26,21 +41,23 @@ {% for product in products %} + {% if business %}{% set on_sale = product.business_on_sale %}{% set sale_price = product.business_sale_price %}{% set pct = product.business_percent_off %} + {% else %}{% set on_sale = product.on_sale %}{% set sale_price = product.sale_price %}{% set pct = product.percent_off %}{% endif %}
{{ product.name }}
{{ product.regular_price }} {{ product.currency }} - {% if product.on_sale %} - {{ product.sale_price }} {{ product.currency }} - (−{{ product.percent_off }}%) + {% if on_sale %} + {{ sale_price }} {{ product.currency }} + (−{{ pct }}%) {% else %} {% endif %} - {% if product.on_sale %} + {% if on_sale %} {{ ui::badge(label=t(key="on-sale", lang=lang | default(value='sk')), variant="danger") }} {% else %} {{ ui::badge(label=t(key="no-discount", lang=lang | default(value='sk')), variant="neutral") }} @@ -48,9 +65,9 @@
- {{ ui::button(variant="outline-secondary", label=t(key="set-discount", lang=lang | default(value='sk')), href="/admin/catalog/discounts/" ~ product.id ~ "/edit", size="px-3 py-1.5 text-xs") }} - {% if product.on_sale %} -
+ {{ ui::button(variant="outline-secondary", label=t(key="set-discount", lang=lang | default(value='sk')), href="/admin/catalog/discounts/" ~ product.id ~ "/edit?audience=" ~ audience, size="px-3 py-1.5 text-xs") }} + {% if on_sale %} + {{ ui::csrf_field() }} {{ ui::button(variant="outline-danger", label=t(key="remove-discount", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
diff --git a/migration/src/lib.rs b/migration/src/lib.rs index e4ef5da..a7557f0 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -38,6 +38,7 @@ mod m20260620_000001_add_totp_to_users; mod m20260621_000001_add_sale_price_to_products; mod m20260621_000002_account_product_prices; mod m20260621_000003_discount_profiles; +mod m20260621_000004_add_business_sale_price_to_products; pub struct Migrator; #[async_trait::async_trait] @@ -80,6 +81,7 @@ impl MigratorTrait for Migrator { Box::new(m20260621_000001_add_sale_price_to_products::Migration), Box::new(m20260621_000002_account_product_prices::Migration), Box::new(m20260621_000003_discount_profiles::Migration), + Box::new(m20260621_000004_add_business_sale_price_to_products::Migration), // inject-above (do not remove this comment) ] } diff --git a/migration/src/m20260621_000004_add_business_sale_price_to_products.rs b/migration/src/m20260621_000004_add_business_sale_price_to_products.rs new file mode 100644 index 0000000..885e611 --- /dev/null +++ b/migration/src/m20260621_000004_add_business_sale_price_to_products.rs @@ -0,0 +1,20 @@ +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> { + // Optional per-product discounted price (minor units) shown to ALL + // business (company) accounts as a baseline, computed off the regular + // price like the personal sale. Per-company profiles/negotiated prices + // still layer on top (lowest price wins). + add_column(m, "products", "business_sale_price_cents", ColType::BigIntegerNull).await + } + + async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { + remove_column(m, "products", "business_sale_price_cents").await + } +} diff --git a/src/controllers/admin_discounts.rs b/src/controllers/admin_discounts.rs index 867ddc9..804497d 100644 --- a/src/controllers/admin_discounts.rs +++ b/src/controllers/admin_discounts.rs @@ -1,9 +1,15 @@ -//! Admin management of per-product discounts. +//! Admin management of per-product discounts, in a place of their own rather +//! than on the product editor. //! -//! Discounts live on the product (`sale_price_cents`) but are set here, in a -//! place of their own, rather than on the product editor: an admin picks a -//! product, enters a discounted price, and the storefront then shows it on sale. -//! Editing a product never touches its discount, and vice versa. +//! Two audiences, switched by an `?audience=` tab: +//! - **personal** (default): the public sale price (`products.sale_price_cents`) +//! everyone sees. +//! - **business**: a baseline discount for all company accounts +//! (`products.business_sale_price_cents`). Per-company profiles/negotiated +//! prices still layer on top (lowest price wins). Both are computed off the +//! regular price. + +use std::collections::HashMap; use axum_extra::extract::cookie::CookieJar; use loco_rs::prelude::*; @@ -20,6 +26,8 @@ use crate::{ }, }; +const BUSINESS: &str = "business"; + #[derive(Debug, Deserialize)] struct DiscountForm { /// "fixed" (enter the new price) or "percent" (enter % off). Defaults to @@ -29,8 +37,35 @@ struct DiscountForm { percent: Option, } +/// Which discount column an audience tab operates on. +fn read_audience(params: &HashMap) -> &'static str { + match params.get("audience").map(String::as_str) { + Some(BUSINESS) => BUSINESS, + _ => "personal", + } +} + +fn current_value(product: &products::Model, audience: &str) -> Option { + if audience == BUSINESS { + product.business_sale_price_cents + } else { + product.sale_price_cents + } +} + +fn set_value(active: &mut products::ActiveModel, audience: &str, value: Option) { + if audience == BUSINESS { + active.business_sale_price_cents = Set(value); + } else { + active.sale_price_cents = Set(value); + } +} + +fn list_redirect(audience: &str) -> Result { + format::redirect(&format!("/admin/catalog/discounts?audience={audience}")) +} + /// Resolve a percentage off the regular price into a fixed sale price in cents. -/// Rounds the discount amount to the nearest cent. fn percent_to_sale_cents(regular_cents: i64, percent: f64) -> i64 { let off = (regular_cents as f64 * percent / 100.0).round() as i64; regular_cents - off @@ -43,8 +78,7 @@ async fn product_by_id(ctx: &AppContext, id: i32) -> Result { .ok_or_else(|| Error::NotFound) } -/// Percent off the regular price, rounded to a whole number. `0` when there is -/// no positive regular price to discount from. +/// Percent off the regular price, rounded to a whole number. fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 { if regular_cents <= 0 { return 0; @@ -53,7 +87,8 @@ fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 { off.round() as i64 } -/// Row shape for the discounts list. +/// Row shape for the discounts list, carrying both audiences' values so the +/// template can show whichever tab is active. fn list_row(product: &products::Model) -> serde_json::Value { json!({ "id": product.id, @@ -64,6 +99,11 @@ fn list_row(product: &products::Model) -> serde_json::Value { "on_sale": product.on_sale(), "sale_price": product.sale_price_cents.map(format_price), "percent_off": product.sale_price_cents.map(|sale| percent_off(product.price_cents, sale)), + "business_on_sale": product.business_on_sale(), + "business_sale_price": product.business_sale_price_cents.map(format_price), + "business_percent_off": product + .business_sale_price_cents + .map(|sale| percent_off(product.price_cents, sale)), }) } @@ -72,9 +112,11 @@ async fn index( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, + Query(params): Query>, State(ctx): State, ) -> Result { guard::current_admin(auth, &ctx).await?; + let audience = read_audience(¶ms); let list = products::Entity::find() .order_by_asc(products::Column::Name) .all(&ctx.db) @@ -83,7 +125,7 @@ async fn index( format::view( &v, "admin/catalog/discounts.html", - json!({ "products": rows, "lang": current_lang(&jar) }), + json!({ "products": rows, "audience": audience, "lang": current_lang(&jar) }), ) } @@ -96,11 +138,11 @@ struct FormPrefill { percent: String, } -/// Render the single-product discount form, optionally with a validation error. fn render_form( v: &TeraView, jar: &CookieJar, product: &products::Model, + audience: &str, prefill: &FormPrefill, error: Option<&str>, ) -> Result { @@ -115,9 +157,9 @@ fn render_form( "currency": product.currency, "regular_price": format_price(product.price_cents), "regular_cents": product.price_cents, - "on_sale": product.on_sale(), - "sale_price": product.sale_price_cents.map(format_price), }, + "audience": audience, + "has_discount": current_value(product, audience).is_some(), "mode": mode, "fixed": prefill.fixed, "percent": prefill.percent, @@ -133,17 +175,21 @@ async fn edit( jar: CookieJar, ViewEngine(v): ViewEngine, Path(id): Path, + Query(params): Query>, State(ctx): State, ) -> Result { guard::current_admin(auth, &ctx).await?; + let audience = read_audience(¶ms); let product = product_by_id(&ctx, id).await?; - // Re-editing always opens in fixed mode showing the current sale price. + // Re-editing always opens in fixed mode showing the current price. let prefill = FormPrefill { mode: "fixed".to_string(), - fixed: product.sale_price_cents.map(format_price).unwrap_or_default(), + fixed: current_value(&product, audience) + .map(format_price) + .unwrap_or_default(), percent: String::new(), }; - render_form(&v, &jar, &product, &prefill, None) + render_form(&v, &jar, &product, audience, &prefill, None) } #[debug_handler] @@ -152,10 +198,12 @@ async fn update( jar: CookieJar, ViewEngine(v): ViewEngine, Path(id): Path, + Query(params): Query>, State(ctx): State, Form(form): Form, ) -> Result { guard::current_admin(auth, &ctx).await?; + let audience = read_audience(¶ms); let product = product_by_id(&ctx, id).await?; let mode = match form.mode.as_deref() { @@ -165,20 +213,18 @@ async fn update( let fixed = form.sale_price.unwrap_or_default().trim().to_string(); let percent = form.percent.unwrap_or_default().trim().to_string(); - // Whatever the mode, both raw inputs are echoed back on error so neither tab - // loses what was typed. let prefill = FormPrefill { mode: mode.to_string(), fixed: fixed.clone(), percent: percent.clone(), }; - let render_err = |key: &str| render_form(&v, &jar, &product, &prefill, Some(key)); + let render_err = |key: &str| render_form(&v, &jar, &product, audience, &prefill, Some(key)); // Resolve the entered discount into a fixed sale price in cents. An empty // input in the active mode clears the discount (same as the Remove action). let sale_cents = if mode == "percent" { if percent.is_empty() { - return clear_discount(&ctx, product).await; + return clear_discount(&ctx, product, audience).await; } let pct = match parse_percent(&percent) { Some(pct) => pct, @@ -190,7 +236,7 @@ async fn update( percent_to_sale_cents(product.price_cents, pct) } else { if fixed.is_empty() { - return clear_discount(&ctx, product).await; + return clear_discount(&ctx, product, audience).await; } match parse_price_to_cents(&fixed) { Ok(cents) => cents, @@ -198,8 +244,6 @@ async fn update( } }; - // A discount must be a positive price strictly below the regular price — - // otherwise it isn't a discount. if sale_cents <= 0 { return render_err("discount-must-be-positive"); } @@ -208,27 +252,33 @@ async fn update( } let mut active = product.into_active_model(); - active.sale_price_cents = Set(Some(sale_cents)); + set_value(&mut active, audience, Some(sale_cents)); active.update(&ctx.db).await?; - format::redirect("/admin/catalog/discounts") + list_redirect(audience) } -async fn clear_discount(ctx: &AppContext, product: products::Model) -> Result { +async fn clear_discount( + ctx: &AppContext, + product: products::Model, + audience: &str, +) -> Result { let mut active = product.into_active_model(); - active.sale_price_cents = Set(None); + set_value(&mut active, audience, None); active.update(&ctx.db).await?; - format::redirect("/admin/catalog/discounts") + list_redirect(audience) } #[debug_handler] async fn remove( auth: auth::JWT, Path(id): Path, + Query(params): Query>, State(ctx): State, ) -> Result { guard::current_admin(auth, &ctx).await?; + let audience = read_audience(¶ms); let product = product_by_id(&ctx, id).await?; - clear_discount(&ctx, product).await + clear_discount(&ctx, product, audience).await } pub fn routes() -> Routes { diff --git a/src/models/_entities/products.rs b/src/models/_entities/products.rs index 82f3b51..43a09a6 100644 --- a/src/models/_entities/products.rs +++ b/src/models/_entities/products.rs @@ -17,6 +17,7 @@ pub struct Model { pub description: Option, pub price_cents: i64, pub sale_price_cents: Option, + pub business_sale_price_cents: Option, pub currency: String, pub sku: Option, pub stock: i32, diff --git a/src/models/products.rs b/src/models/products.rs index 8bf3e74..4a49e90 100644 --- a/src/models/products.rs +++ b/src/models/products.rs @@ -37,6 +37,13 @@ impl Model { self.price_cents } } + + /// Whether a baseline business discount (for all company accounts) is set and + /// actually below the regular price. + #[must_use] + pub fn business_on_sale(&self) -> bool { + matches!(self.business_sale_price_cents, Some(sale) if sale < self.price_cents) + } } // implement your write-oriented logic here diff --git a/src/shared/pricing.rs b/src/shared/pricing.rs index bc28a45..8e82dce 100644 --- a/src/shared/pricing.rs +++ b/src/shared/pricing.rs @@ -210,7 +210,13 @@ fn detail_for(product: &products::Model, b2b: Option<&B2bContext>) -> PriceDetai auto_cents = Some(apply_discount_bp(regular, chosen.percent_bp)); } - let business = [manual, auto_cents].into_iter().flatten().min(); + // Baseline business discount for all company accounts (set on the discounts + // page), alongside the per-company automated and negotiated layers. + let business_global = product.business_sale_price_cents; + let business = [manual, auto_cents, business_global] + .into_iter() + .flatten() + .min(); let priced = decide(regular, public, business); PriceDetail {