discounts now work well
This commit is contained in:
@@ -26,7 +26,7 @@ use crate::{
|
||||
},
|
||||
shared::{
|
||||
guard,
|
||||
money::{format_bp, format_price, parse_price_to_cents},
|
||||
money::{format_bp, format_price, parse_percent, parse_price_to_cents},
|
||||
pricing,
|
||||
slug::{slugify, unique_slug},
|
||||
},
|
||||
@@ -105,30 +105,9 @@ struct VariantInput {
|
||||
/// `None` = available but not inventory-tracked.
|
||||
stock: Option<i32>,
|
||||
price_cents: i64,
|
||||
business_sale_cents: Option<i64>,
|
||||
position: i32,
|
||||
}
|
||||
|
||||
/// The optional business-sale price field on a variant row: blank means "no
|
||||
/// business quick-sale", a value must parse and be below the regular price.
|
||||
fn parse_optional_sale(
|
||||
form: &MultipartForm,
|
||||
i: usize,
|
||||
key: &str,
|
||||
price_cents: i64,
|
||||
) -> Result<Option<i64>> {
|
||||
let Some(raw) = form.text(&format!("variants[{i}][{key}]")) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let cents = parse_price_to_cents(&raw)?;
|
||||
if cents <= 0 || cents >= price_cents {
|
||||
return Err(Error::BadRequest(
|
||||
"a sale price must be positive and below the regular price".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Some(cents))
|
||||
}
|
||||
|
||||
/// Parse the repeated variant rows from the form, in submission order. Blank
|
||||
/// rows (no price and no label) are skipped; at least one valid row is required.
|
||||
fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
|
||||
@@ -168,7 +147,6 @@ fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
|
||||
.ok_or_else(|| Error::BadRequest("stock must be 0 or more".to_string()))?,
|
||||
),
|
||||
};
|
||||
let business_sale_cents = parse_optional_sale(form, i, "business_sale", price_cents)?;
|
||||
let id = form
|
||||
.text(&format!("variants[{i}][id]"))
|
||||
.and_then(|s| s.parse::<i32>().ok());
|
||||
@@ -179,7 +157,6 @@ fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
|
||||
sku,
|
||||
stock,
|
||||
price_cents,
|
||||
business_sale_cents,
|
||||
position: out.len() as i32,
|
||||
});
|
||||
}
|
||||
@@ -197,9 +174,9 @@ fn apply_variant(active: &mut product_variants::ActiveModel, input: &VariantInpu
|
||||
active.sku = Set(input.sku.clone());
|
||||
active.stock = Set(input.stock);
|
||||
active.price_cents = Set(input.price_cents);
|
||||
// The per-variant public sale price was removed from the UI; keep it cleared.
|
||||
active.sale_price_cents = Set(None);
|
||||
active.business_sale_price_cents = Set(input.business_sale_cents);
|
||||
// Discounts (public + business sale) are owned by the discount page and keyed
|
||||
// per option/audience; the product form must leave those columns untouched so
|
||||
// it never clobbers a discount. New variants default them to NULL.
|
||||
active.position = Set(input.position);
|
||||
}
|
||||
|
||||
@@ -257,7 +234,6 @@ fn variant_form_json(variant: &product_variants::Model) -> serde_json::Value {
|
||||
"sku": variant.sku,
|
||||
"stock": variant.stock,
|
||||
"price": format_price(variant.price_cents),
|
||||
"business_sale": variant.business_sale_price_cents.map(format_price),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -340,9 +316,13 @@ async fn index(
|
||||
.sum::<i32>()
|
||||
.to_string()
|
||||
};
|
||||
// The product is "on sale" for this audience if any option carries a
|
||||
// discount; the per-option amounts live on the discount page.
|
||||
let on_sale = variants.iter().any(|v| current_value(v, audience).is_some());
|
||||
rows.push(product_row(
|
||||
product,
|
||||
priced,
|
||||
on_sale,
|
||||
variants.len(),
|
||||
stock_display,
|
||||
image,
|
||||
@@ -373,6 +353,7 @@ async fn index(
|
||||
fn product_row(
|
||||
product: &products::Model,
|
||||
effective: &pricing::PricedProduct,
|
||||
on_sale: bool,
|
||||
variant_count: usize,
|
||||
stock_display: String,
|
||||
image: Option<String>,
|
||||
@@ -390,6 +371,7 @@ fn product_row(
|
||||
"image": image,
|
||||
"category_name": category_name,
|
||||
"regular_price": format_price(effective.regular_cents),
|
||||
"on_sale": on_sale,
|
||||
"effective_price": format_price(effective.price_cents),
|
||||
"effective_reduced": effective.is_reduced(),
|
||||
"effective_percent_off": percent_off(effective.regular_cents, effective.price_cents),
|
||||
@@ -619,6 +601,30 @@ fn list_redirect(audience: &str) -> Result<Response> {
|
||||
format::redirect(&format!("/admin/catalog/products?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
|
||||
}
|
||||
|
||||
/// Which discount value an audience tab sees on a variant.
|
||||
fn current_value(variant: &product_variants::Model, audience: &str) -> Option<i64> {
|
||||
if audience == BUSINESS {
|
||||
variant.business_sale_price_cents
|
||||
} else {
|
||||
variant.sale_price_cents
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the discount column on a variant for a given audience.
|
||||
fn set_value(active: &mut product_variants::ActiveModel, audience: &str, value: Option<i64>) {
|
||||
if audience == BUSINESS {
|
||||
active.business_sale_price_cents = Set(value);
|
||||
} else {
|
||||
active.sale_price_cents = Set(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent off the regular price, rounded to a whole number.
|
||||
fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 {
|
||||
if regular_cents <= 0 {
|
||||
@@ -742,6 +748,231 @@ async fn sync_profiles(
|
||||
list_redirect(audience)
|
||||
}
|
||||
|
||||
// --- Per-variant discounts ---------------------------------------------------
|
||||
//
|
||||
// Each product is sold as one or more options (variants). A discount can be set
|
||||
// on every option individually, for the active audience: personal writes the
|
||||
// public `sale_price_cents`, business writes `business_sale_price_cents`. Per
|
||||
// option the admin picks a fixed sale price or a percentage off the regular
|
||||
// price; an empty value clears that option's discount.
|
||||
|
||||
/// One option row in the discount form. Carries enough to pre-fill the editor
|
||||
/// and to survive a validation-error round-trip.
|
||||
struct DiscountRow {
|
||||
id: i32,
|
||||
label: String,
|
||||
regular_cents: i64,
|
||||
mode: String,
|
||||
fixed: String,
|
||||
percent: String,
|
||||
has_discount: bool,
|
||||
}
|
||||
|
||||
impl DiscountRow {
|
||||
/// Pre-fill from the discount stored for this audience.
|
||||
fn from_db(v: &product_variants::Model, audience: &str) -> Self {
|
||||
let sale = current_value(v, audience);
|
||||
DiscountRow {
|
||||
id: v.id,
|
||||
label: v.label.clone(),
|
||||
regular_cents: v.price_cents,
|
||||
mode: "fixed".to_string(),
|
||||
fixed: sale.map(format_price).unwrap_or_default(),
|
||||
percent: String::new(),
|
||||
has_discount: sale.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-fill from the submitted values, to repaint the form after an error.
|
||||
fn from_submitted(
|
||||
v: &product_variants::Model,
|
||||
audience: &str,
|
||||
pairs: &HashMap<String, String>,
|
||||
) -> Self {
|
||||
let get = |key: &str| {
|
||||
pairs
|
||||
.get(&format!("v[{}][{key}]", v.id))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let mode = get("mode");
|
||||
DiscountRow {
|
||||
id: v.id,
|
||||
label: v.label.clone(),
|
||||
regular_cents: v.price_cents,
|
||||
mode: if mode == "percent" {
|
||||
mode
|
||||
} else {
|
||||
"fixed".to_string()
|
||||
},
|
||||
fixed: get("fixed"),
|
||||
percent: get("percent"),
|
||||
has_discount: current_value(v, audience).is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_json(&self, currency: &str) -> serde_json::Value {
|
||||
json!({
|
||||
"id": self.id,
|
||||
"label": self.label,
|
||||
"regular_cents": self.regular_cents,
|
||||
"regular_price": format_price(self.regular_cents),
|
||||
"currency": currency,
|
||||
"mode": self.mode,
|
||||
"fixed": self.fixed,
|
||||
"percent": self.percent,
|
||||
"has_discount": self.has_discount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one submitted option into the sale price to store. `Ok(None)` clears
|
||||
/// the discount; `Err` is an i18n key for the validation message.
|
||||
fn resolve_row(
|
||||
regular_cents: i64,
|
||||
mode: &str,
|
||||
fixed: &str,
|
||||
percent: &str,
|
||||
) -> std::result::Result<Option<i64>, &'static str> {
|
||||
let sale_cents = if mode == "percent" {
|
||||
if percent.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let pct = parse_percent(percent).ok_or("discount-invalid")?;
|
||||
if pct <= 0.0 || pct >= 100.0 {
|
||||
return Err("discount-percent-range");
|
||||
}
|
||||
percent_to_sale_cents(regular_cents, pct)
|
||||
} else {
|
||||
if fixed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
parse_price_to_cents(fixed).map_err(|_| "discount-invalid")?
|
||||
};
|
||||
if sale_cents <= 0 {
|
||||
return Err("discount-must-be-positive");
|
||||
}
|
||||
if sale_cents >= regular_cents {
|
||||
return Err("discount-below-regular");
|
||||
}
|
||||
Ok(Some(sale_cents))
|
||||
}
|
||||
|
||||
async fn discount_view(
|
||||
v: &TeraView,
|
||||
jar: &CookieJar,
|
||||
product: &products::Model,
|
||||
rows: &[DiscountRow],
|
||||
audience: &str,
|
||||
error: Option<&str>,
|
||||
) -> Result<Response> {
|
||||
let rows_json: Vec<_> = rows.iter().map(|r| r.to_json(&product.currency)).collect();
|
||||
let has_discount = rows.iter().any(|r| r.has_discount);
|
||||
format::view(
|
||||
v,
|
||||
"admin/catalog/discount_form.html",
|
||||
json!({
|
||||
"product": {
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
"currency": product.currency,
|
||||
},
|
||||
"rows": rows_json,
|
||||
"audience": audience,
|
||||
"has_discount": has_discount,
|
||||
"error": error.map(|e| e.to_string()),
|
||||
"lang": current_lang(jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn discount_show(
|
||||
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(¶ms);
|
||||
let product = product_by_id(&ctx, id).await?;
|
||||
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
|
||||
let rows: Vec<DiscountRow> = variants
|
||||
.iter()
|
||||
.map(|variant| DiscountRow::from_db(variant, audience))
|
||||
.collect();
|
||||
discount_view(&v, &jar, &product, &rows, audience, None).await
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn discount_update(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(id): Path<i32>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
State(ctx): State<AppContext>,
|
||||
body: String,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let audience = read_audience(¶ms);
|
||||
let product = product_by_id(&ctx, id).await?;
|
||||
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
|
||||
|
||||
let pairs: HashMap<String, String> = form_urlencoded::parse(body.as_bytes())
|
||||
.into_owned()
|
||||
.collect();
|
||||
|
||||
// Resolve every option before persisting anything, so one bad row can't leave
|
||||
// the product half-discounted. On the first error, repaint with the inputs.
|
||||
let mut resolved: Vec<(product_variants::Model, Option<i64>)> = Vec::new();
|
||||
for variant in &variants {
|
||||
let row = DiscountRow::from_submitted(variant, audience, &pairs);
|
||||
match resolve_row(variant.price_cents, &row.mode, &row.fixed, &row.percent) {
|
||||
Ok(value) => resolved.push((variant.clone(), value)),
|
||||
Err(key) => {
|
||||
let rows: Vec<DiscountRow> = variants
|
||||
.iter()
|
||||
.map(|v| DiscountRow::from_submitted(v, audience, &pairs))
|
||||
.collect();
|
||||
return discount_view(&v, &jar, &product, &rows, audience, Some(key)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let txn = ctx.db.begin().await?;
|
||||
for (variant, value) in &resolved {
|
||||
let mut active = variant.clone().into_active_model();
|
||||
set_value(&mut active, audience, *value);
|
||||
active.update(&txn).await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
list_redirect(audience)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn discount_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(¶ms);
|
||||
let _product = product_by_id(&ctx, id).await?;
|
||||
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
|
||||
let txn = ctx.db.begin().await?;
|
||||
for variant in &variants {
|
||||
let mut active = variant.clone().into_active_model();
|
||||
set_value(&mut active, audience, None);
|
||||
active.update(&txn).await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
list_redirect(audience)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
// Several images may be uploaded in one submission; allow a generous total
|
||||
// (per-file size is still capped at IMAGE_MAX_BYTES while reading).
|
||||
@@ -764,4 +995,16 @@ pub fn routes() -> Routes {
|
||||
post(update).layer(image_limit),
|
||||
)
|
||||
.add("/admin/catalog/products/{id}/delete", post(delete))
|
||||
.add(
|
||||
"/admin/catalog/products/{id}/discount/edit",
|
||||
get(discount_show),
|
||||
)
|
||||
.add(
|
||||
"/admin/catalog/products/{id}/discount",
|
||||
post(discount_update),
|
||||
)
|
||||
.add(
|
||||
"/admin/catalog/products/{id}/discount/remove",
|
||||
post(discount_remove),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user