discounts

This commit is contained in:
Priec
2026-06-21 22:33:47 +02:00
parent 2ee87fbdd7
commit 9ce1cb97f0
20 changed files with 399 additions and 10 deletions

View File

@@ -17,7 +17,7 @@ use std::{path::Path, sync::Arc};
#[allow(unused_imports)]
use crate::{
controllers::{
account, admin_categories, admin_dashboard, admin_form, admin_orders,
account, admin_categories, admin_dashboard, admin_discounts, admin_form, admin_orders,
admin_products, admin_shipping, auth, auth_pages, cart, checkout, home, i18n, media, oauth2,
shop,
},
@@ -104,6 +104,7 @@ impl Hooks for App {
// admin
.add_route(admin_dashboard::routes())
.add_route(admin_products::routes())
.add_route(admin_discounts::routes())
.add_route(admin_categories::routes())
.add_route(admin_orders::routes())
.add_route(admin_shipping::routes())

View File

@@ -0,0 +1,185 @@
//! Admin management of per-product discounts.
//!
//! 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.
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set};
use serde::Deserialize;
use serde_json::json;
use crate::{
controllers::i18n::current_lang,
models::products,
shared::{
guard,
money::{format_price, parse_price_to_cents},
},
};
#[derive(Debug, Deserialize)]
struct DiscountForm {
sale_price: String,
}
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. `0` when there is
/// no positive regular price to discount from.
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.
fn list_row(product: &products::Model) -> 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)),
})
}
#[debug_handler]
async fn index(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let list = products::Entity::find()
.order_by_asc(products::Column::Name)
.all(&ctx.db)
.await?;
let rows: Vec<serde_json::Value> = list.iter().map(list_row).collect();
format::view(
&v,
"admin/catalog/discounts.html",
json!({ "products": rows, "lang": current_lang(&jar) }),
)
}
/// 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).
fn render_form(
v: &TeraView,
jar: &CookieJar,
product: &products::Model,
entered: Option<String>,
error: Option<&str>,
) -> Result<Response> {
let current = product.sale_price_cents.map(format_price);
let value = entered.or_else(|| current.clone()).unwrap_or_default();
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),
"on_sale": product.on_sale(),
"sale_price": current,
},
"value": value,
"error": error,
"lang": current_lang(jar),
}),
)
}
#[debug_handler]
async fn edit(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let product = product_by_id(&ctx, id).await?;
render_form(&v, &jar, &product, None, None)
}
#[debug_handler]
async fn update(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Form(form): Form<DiscountForm>,
) -> Result<Response> {
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"),
};
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();
active.sale_price_cents = Set(Some(sale_cents));
active.update(&ctx.db).await?;
format::redirect("/admin/catalog/discounts")
}
async fn clear_discount(ctx: &AppContext, product: products::Model) -> Result<Response> {
let mut active = product.into_active_model();
active.sale_price_cents = Set(None);
active.update(&ctx.db).await?;
format::redirect("/admin/catalog/discounts")
}
#[debug_handler]
async fn remove(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let product = product_by_id(&ctx, id).await?;
clear_discount(&ctx, product).await
}
pub fn routes() -> Routes {
Routes::new()
.add("/admin/catalog/discounts", get(index))
.add("/admin/catalog/discounts/{id}/edit", get(edit))
.add("/admin/catalog/discounts/{id}", post(update))
.add("/admin/catalog/discounts/{id}/remove", post(remove))
}

View File

@@ -201,14 +201,15 @@ pub(crate) async fn resolve_cart(
if qty == 0 {
continue;
}
let line_total = product.price_cents * i64::from(qty);
let unit_price = product.effective_price_cents();
let line_total = unit_price * i64::from(qty);
total += line_total;
valid.push((product.id, qty));
lines.push(json!({
"id": product.id,
"name": product.name,
"slug": product.slug,
"price": format_price(product.price_cents),
"price": format_price(unit_price),
"currency": product.currency,
"quantity": qty,
"stock": product.stock,

View File

@@ -4,6 +4,7 @@ pub mod auth_pages;
pub mod oauth2;
pub mod admin_categories;
pub mod admin_dashboard;
pub mod admin_discounts;
pub mod admin_form;
pub mod admin_orders;
pub mod admin_products;

View File

@@ -16,6 +16,7 @@ pub struct Model {
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
pub price_cents: i64,
pub sale_price_cents: Option<i64>,
pub currency: String,
pub sku: Option<String>,
pub stock: i32,

View File

@@ -61,13 +61,15 @@ pub async fn place(ctx: &AppContext, items: &[(i32, i32)], details: Checkout) ->
)));
}
currency = product.currency.clone();
subtotal += product.price_cents * i64::from(*qty);
// Snapshot the effective price (honouring any active discount).
let unit_price_cents = product.effective_price_cents();
subtotal += unit_price_cents * i64::from(*qty);
let mut active = product.clone().into_active_model();
active.stock = Set(product.stock - *qty);
active.update(&txn).await?;
snapshots.push((product.id, product.name, product.price_cents, *qty));
snapshots.push((product.id, product.name, unit_price_cents, *qty));
}
let order = ActiveModel {

View File

@@ -19,7 +19,25 @@ impl ActiveModelBehavior for ActiveModel {
}
// implement your read-oriented logic here
impl Model {}
impl Model {
/// Whether a discount is currently active: a sale price is set and is
/// strictly below the regular price.
#[must_use]
pub fn on_sale(&self) -> bool {
matches!(self.sale_price_cents, Some(sale) if sale < self.price_cents)
}
/// The price actually charged: the sale price when [`Model::on_sale`],
/// otherwise the regular price.
#[must_use]
pub fn effective_price_cents(&self) -> i64 {
if self.on_sale() {
self.sale_price_cents.unwrap_or(self.price_cents)
} else {
self.price_cents
}
}
}
// implement your write-oriented logic here
impl ActiveModel {}

View File

@@ -17,7 +17,9 @@ pub fn product_card(
"name": product.name,
"slug": product.slug,
"description": product.description,
"price": format_price(product.price_cents),
"price": format_price(product.effective_price_cents()),
"on_sale": product.on_sale(),
"regular_price": format_price(product.price_cents),
"currency": product.currency,
"sku": product.sku,
"stock": product.stock,