Files
kompress_eshop/src/controllers/admin_discounts.rs
2026-06-22 08:47:22 +02:00

371 lines
12 KiB
Rust

//! Admin management of per-product discounts, in a place of their own rather
//! than on the product editor.
//!
//! 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, HashSet};
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait,
};
use serde::Deserialize;
use serde_json::json;
use crate::{
controllers::i18n::current_lang,
models::{audience_discount_profiles, discount_profiles, products},
shared::{
guard,
money::{format_bp, format_price, parse_percent, parse_price_to_cents},
pricing,
},
};
const BUSINESS: &str = "business";
#[derive(Debug, Deserialize)]
struct DiscountForm {
/// "fixed" (enter the new price) or "percent" (enter % off). Defaults to
/// fixed for older/JSON callers.
mode: Option<String>,
sale_price: Option<String>,
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.
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
}
async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
products::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
/// 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;
}
let off = (regular_cents - sale_cents) as f64 / regular_cents as f64 * 100.0;
off.round() as i64
}
/// Row shape for the discounts list, carrying both audiences' per-product values
/// plus the resolved effective price for the active tab (after profiles).
fn list_row(product: &products::Model, effective: &pricing::PricedProduct) -> serde_json::Value {
json!({
"id": product.id,
"name": product.name,
"slug": product.slug,
"currency": product.currency,
"regular_price": format_price(product.price_cents),
"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)),
// The price this audience actually pays after the per-product discount
// and any applied profiles.
"effective_price": format_price(effective.price_cents),
"effective_reduced": effective.is_reduced(),
"effective_percent_off": percent_off(product.price_cents, effective.price_cents),
})
}
#[debug_handler]
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)
.await?;
let effective = pricing::audience_price_many(&ctx, &list, audience).await?;
let rows: Vec<serde_json::Value> = list
.iter()
.zip(effective.iter())
.map(|(product, priced)| list_row(product, priced))
.collect();
// Profiles applied globally to this audience, plus all profiles to choose from.
let assigned: HashSet<i32> = audience_discount_profiles::Entity::find()
.filter(audience_discount_profiles::Column::Audience.eq(audience))
.all(&ctx.db)
.await?
.into_iter()
.map(|a| a.discount_profile_id)
.collect();
let all_profiles = discount_profiles::Entity::find()
.order_by_asc(discount_profiles::Column::Name)
.all(&ctx.db)
.await?;
let profiles: Vec<serde_json::Value> = all_profiles
.iter()
.map(|p| {
json!({
"id": p.id,
"name": p.name,
"percent": format_bp(p.percent_bp),
"scope_type": p.scope_type,
"assigned": assigned.contains(&p.id),
})
})
.collect();
format::view(
&v,
"admin/catalog/discounts.html",
json!({
"products": rows,
"profiles": profiles,
"audience": audience,
"lang": current_lang(&jar),
}),
)
}
/// Replace the profiles applied to this audience with the submitted checkbox set
/// (`profile_ids`, a repeated field parsed directly from the body).
#[debug_handler]
async fn sync_profiles(
auth: auth::JWT,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>,
body: String,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let audience = read_audience(&params);
let profile_ids: Vec<i32> = form_urlencoded::parse(body.as_bytes())
.filter(|(k, _)| k == "profile_ids")
.filter_map(|(_, value)| value.parse::<i32>().ok())
.collect();
let txn = ctx.db.begin().await?;
audience_discount_profiles::Entity::delete_many()
.filter(audience_discount_profiles::Column::Audience.eq(audience))
.exec(&txn)
.await?;
for profile_id in profile_ids {
audience_discount_profiles::ActiveModel {
audience: Set(audience.to_string()),
discount_profile_id: Set(profile_id),
..Default::default()
}
.insert(&txn)
.await?;
}
txn.commit().await?;
list_redirect(audience)
}
/// What to pre-fill the form with: the chosen input mode and the raw values for
/// each field, so a rejected submit (or a re-edit) shows what the admin had.
#[derive(Default)]
struct FormPrefill {
mode: String,
fixed: String,
percent: String,
}
fn render_form(
v: &TeraView,
jar: &CookieJar,
product: &products::Model,
audience: &str,
prefill: &FormPrefill,
error: Option<&str>,
) -> Result<Response> {
let mode = if prefill.mode == "percent" { "percent" } else { "fixed" };
format::view(
v,
"admin/catalog/discount_form.html",
json!({
"product": {
"id": product.id,
"name": product.name,
"currency": product.currency,
"regular_price": format_price(product.price_cents),
"regular_cents": product.price_cents,
},
"audience": audience,
"has_discount": current_value(product, audience).is_some(),
"mode": mode,
"fixed": prefill.fixed,
"percent": prefill.percent,
"error": error,
"lang": current_lang(jar),
}),
)
}
#[debug_handler]
async fn edit(
auth: auth::JWT,
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 price.
let prefill = FormPrefill {
mode: "fixed".to_string(),
fixed: current_value(&product, audience)
.map(format_price)
.unwrap_or_default(),
percent: String::new(),
};
render_form(&v, &jar, &product, audience, &prefill, None)
}
#[debug_handler]
async fn update(
auth: auth::JWT,
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() {
Some("percent") => "percent",
_ => "fixed",
};
let fixed = form.sale_price.unwrap_or_default().trim().to_string();
let percent = form.percent.unwrap_or_default().trim().to_string();
let prefill = FormPrefill {
mode: mode.to_string(),
fixed: fixed.clone(),
percent: percent.clone(),
};
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, audience).await;
}
let pct = match parse_percent(&percent) {
Some(pct) => pct,
None => return render_err("discount-invalid"),
};
if pct <= 0.0 || pct >= 100.0 {
return render_err("discount-percent-range");
}
percent_to_sale_cents(product.price_cents, pct)
} else {
if fixed.is_empty() {
return clear_discount(&ctx, product, audience).await;
}
match parse_price_to_cents(&fixed) {
Ok(cents) => cents,
Err(_) => return render_err("discount-invalid"),
}
};
if sale_cents <= 0 {
return render_err("discount-must-be-positive");
}
if sale_cents >= product.price_cents {
return render_err("discount-below-regular");
}
let mut active = product.into_active_model();
set_value(&mut active, audience, Some(sale_cents));
active.update(&ctx.db).await?;
list_redirect(audience)
}
async fn clear_discount(
ctx: &AppContext,
product: products::Model,
audience: &str,
) -> Result<Response> {
let mut active = product.into_active_model();
set_value(&mut active, audience, None);
active.update(&ctx.db).await?;
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, audience).await
}
pub fn routes() -> Routes {
Routes::new()
.add("/admin/catalog/discounts", get(index))
.add("/admin/catalog/discounts/profiles", post(sync_profiles))
.add("/admin/catalog/discounts/{id}/edit", get(edit))
.add("/admin/catalog/discounts/{id}", post(update))
.add("/admin/catalog/discounts/{id}/remove", post(remove))
}