percentage discounts
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled

This commit is contained in:
Priec
2026-06-21 22:41:30 +02:00
parent 9ce1cb97f0
commit ed566b5347
5 changed files with 161 additions and 28 deletions

View File

@@ -22,7 +22,24 @@ use crate::{
#[derive(Debug, Deserialize)]
struct DiscountForm {
sale_price: String,
/// "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>,
}
/// Parse a percentage typed as "20", "20.5" or "20,5" into an `f64`.
fn parse_percent(value: &str) -> Option<f64> {
let parsed: f64 = value.trim().replace(',', ".").parse().ok()?;
parsed.is_finite().then_some(parsed)
}
/// 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
}
async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
@@ -76,17 +93,24 @@ async fn index(
)
}
/// Render the single-product discount form, optionally with a validation error
/// and the value the admin just typed (so a rejected submit isn't lost).
/// 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,
}
/// Render the single-product discount form, optionally with a validation error.
fn render_form(
v: &TeraView,
jar: &CookieJar,
product: &products::Model,
entered: Option<String>,
prefill: &FormPrefill,
error: Option<&str>,
) -> Result<Response> {
let current = product.sale_price_cents.map(format_price);
let value = entered.or_else(|| current.clone()).unwrap_or_default();
let mode = if prefill.mode == "percent" { "percent" } else { "fixed" };
format::view(
v,
"admin/catalog/discount_form.html",
@@ -96,10 +120,13 @@ fn render_form(
"name": product.name,
"currency": product.currency,
"regular_price": format_price(product.price_cents),
"regular_cents": product.price_cents,
"on_sale": product.on_sale(),
"sale_price": current,
"sale_price": product.sale_price_cents.map(format_price),
},
"value": value,
"mode": mode,
"fixed": prefill.fixed,
"percent": prefill.percent,
"error": error,
"lang": current_lang(jar),
}),
@@ -116,7 +143,13 @@ async fn edit(
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let product = product_by_id(&ctx, id).await?;
render_form(&v, &jar, &product, None, None)
// Re-editing always opens in fixed mode showing the current sale price.
let prefill = FormPrefill {
mode: "fixed".to_string(),
fixed: product.sale_price_cents.map(format_price).unwrap_or_default(),
percent: String::new(),
};
render_form(&v, &jar, &product, &prefill, None)
}
#[debug_handler]
@@ -131,20 +164,48 @@ async fn update(
guard::current_admin(auth, &ctx).await?;
let product = product_by_id(&ctx, id).await?;
let entered = form.sale_price.trim().to_string();
// An empty value clears the discount (same as the Remove action).
if entered.is_empty() {
return clear_discount(&ctx, product).await;
}
// A discount must be a valid, positive price strictly below the regular
// price — otherwise it isn't a discount. Reject inline, keeping the input.
let render_err = |key: &str| render_form(&v, &jar, &product, Some(entered.clone()), Some(key));
let sale_cents = match parse_price_to_cents(&entered) {
Ok(cents) => cents,
Err(_) => return render_err("discount-invalid"),
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();
// 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));
// 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;
}
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).await;
}
match parse_price_to_cents(&fixed) {
Ok(cents) => cents,
Err(_) => return render_err("discount-invalid"),
}
};
// 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");
}