//! 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, sale_price: Option, 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. 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::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, 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) .await?; let effective = pricing::audience_price_many(&ctx, &list, audience).await?; let rows: Vec = 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 = 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 = 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>, State(ctx): State, body: String, ) -> Result { guard::current_admin(auth, &ctx).await?; let audience = read_audience(¶ms); let profile_ids: Vec = form_urlencoded::parse(body.as_bytes()) .filter(|(k, _)| k == "profile_ids") .filter_map(|(_, value)| value.parse::().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 { 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, 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 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, 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() { 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 { 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, 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, 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)) }