discount for business and personall in discount page

This commit is contained in:
Priec
2026-06-22 00:04:01 +02:00
parent 1df8d66d5d
commit d2b463135b
10 changed files with 158 additions and 44 deletions

View File

@@ -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<String>,
}
/// Which discount column an audience tab operates on.
fn read_audience(params: &HashMap<String, String>) -> &'static str {
match params.get("audience").map(String::as_str) {
Some(BUSINESS) => BUSINESS,
_ => "personal",
}
}
fn current_value(product: &products::Model, audience: &str) -> Option<i64> {
if audience == BUSINESS {
product.business_sale_price_cents
} else {
product.sale_price_cents
}
}
fn set_value(active: &mut products::ActiveModel, audience: &str, value: Option<i64>) {
if audience == BUSINESS {
active.business_sale_price_cents = Set(value);
} else {
active.sale_price_cents = Set(value);
}
}
fn list_redirect(audience: &str) -> Result<Response> {
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<products::Model> {
.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<TeraView>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let audience = read_audience(&params);
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<Response> {
@@ -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<TeraView>,
Path(id): Path<i32>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let audience = read_audience(&params);
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<TeraView>,
Path(id): Path<i32>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>,
Form(form): Form<DiscountForm>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let audience = read_audience(&params);
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<Response> {
async fn clear_discount(
ctx: &AppContext,
product: products::Model,
audience: &str,
) -> Result<Response> {
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<i32>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let audience = read_audience(&params);
let product = product_by_id(&ctx, id).await?;
clear_discount(&ctx, product).await
clear_discount(&ctx, product, audience).await
}
pub fn routes() -> Routes {