discount profiles and discounts overall implemented and working
This commit is contained in:
@@ -1,24 +1,31 @@
|
||||
//! Admin management of business (company) accounts and their negotiated prices.
|
||||
//! Admin management of business (company) accounts and their pricing.
|
||||
//!
|
||||
//! Phase 1: list company accounts and, per account, set/clear a manually
|
||||
//! negotiated price per product ("personal agreement"). The effective price the
|
||||
//! business pays is always resolved by [`crate::shared::pricing`] (lowest of the
|
||||
//! public price and the negotiated price), shown here for reference.
|
||||
//! Per company the admin can: assign reusable discount profiles (the automated
|
||||
//! layer), resolve per-product collisions when two assigned profiles cover the
|
||||
//! same product, and set a manually negotiated price per product. The effective
|
||||
//! price the business pays is always resolved by [`crate::shared::pricing`]
|
||||
//! (lowest of public / automated / negotiated), shown here for reference.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
|
||||
QueryOrder, Set, TransactionTrait,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
controllers::i18n::current_lang,
|
||||
models::{account_product_prices, products, _entities::users},
|
||||
models::{
|
||||
account_discount_profiles, account_product_prices, account_product_resolutions,
|
||||
discount_profiles, products, _entities::users,
|
||||
},
|
||||
shared::{
|
||||
guard,
|
||||
money::{format_price, parse_price_to_cents},
|
||||
money::{format_bp, format_price, parse_price_to_cents},
|
||||
pricing,
|
||||
},
|
||||
};
|
||||
@@ -30,18 +37,32 @@ struct PriceForm {
|
||||
price: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResolutionForm {
|
||||
profile_id: i32,
|
||||
}
|
||||
|
||||
async fn company_by_id(ctx: &AppContext, id: i32) -> Result<users::Model> {
|
||||
let user = users::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
// Negotiated pricing only applies to company accounts.
|
||||
if user.account_type != COMPANY {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn assigned_profile_ids(ctx: &AppContext, user_id: i32) -> Result<HashSet<i32>> {
|
||||
Ok(account_discount_profiles::Entity::find()
|
||||
.filter(account_discount_profiles::Column::UserId.eq(user_id))
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|a| a.discount_profile_id)
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn index(
|
||||
auth: auth::JWT,
|
||||
@@ -89,27 +110,58 @@ async fn show(
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let company = company_by_id(&ctx, id).await?;
|
||||
|
||||
// All profiles (for the assignment section + name lookup) and which are
|
||||
// assigned to this company.
|
||||
let all_profiles = discount_profiles::Entity::find()
|
||||
.order_by_asc(discount_profiles::Column::Name)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let assigned = assigned_profile_ids(&ctx, company.id).await?;
|
||||
let profile_name: HashMap<i32, String> =
|
||||
all_profiles.iter().map(|p| (p.id, p.name.clone())).collect();
|
||||
let profiles_json: 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();
|
||||
|
||||
let list = products::Entity::find()
|
||||
.order_by_asc(products::Column::Name)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let priced = pricing::price_many(&ctx, &list, Some(&company)).await?;
|
||||
let manual = account_product_prices::Model::map_for_user(&ctx.db, company.id).await?;
|
||||
let details = pricing::detail_many(&ctx, &list, Some(&company)).await?;
|
||||
|
||||
let rows: Vec<serde_json::Value> = list
|
||||
.iter()
|
||||
.zip(priced.iter())
|
||||
.map(|(product, priced)| {
|
||||
.zip(details.iter())
|
||||
.map(|(product, d)| {
|
||||
let covering: Vec<serde_json::Value> = d
|
||||
.covering_profile_ids
|
||||
.iter()
|
||||
.map(|pid| json!({ "id": pid, "name": profile_name.get(pid) }))
|
||||
.collect();
|
||||
json!({
|
||||
"product_id": product.id,
|
||||
"name": product.name,
|
||||
"currency": product.currency,
|
||||
"regular_price": format_price(product.price_cents),
|
||||
"public_price": format_price(product.effective_price_cents()),
|
||||
"regular_price": format_price(d.regular_cents),
|
||||
"public_price": format_price(d.public_cents),
|
||||
"on_public_sale": product.on_sale(),
|
||||
"manual_price": manual.get(&product.id).copied().map(format_price),
|
||||
"effective_price": format_price(priced.price_cents),
|
||||
"is_business": priced.is_business,
|
||||
"manual_price": d.manual_cents.map(format_price),
|
||||
"auto_price": d.auto_cents.map(format_price),
|
||||
"auto_profile_name": d.auto_profile_id.and_then(|pid| profile_name.get(&pid)),
|
||||
"auto_profile_id": d.auto_profile_id,
|
||||
"collision": d.collision,
|
||||
"covering": covering,
|
||||
"effective_price": format_price(d.price_cents),
|
||||
"is_business": d.is_business,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -119,6 +171,7 @@ async fn show(
|
||||
"admin/customers/show.html",
|
||||
json!({
|
||||
"customer": { "id": company.id, "name": company.name, "email": company.email },
|
||||
"profiles": profiles_json,
|
||||
"products": rows,
|
||||
"error": params.get("error"),
|
||||
"lang": current_lang(&jar),
|
||||
@@ -137,7 +190,6 @@ async fn set_price(
|
||||
let company = company_by_id(&ctx, id).await?;
|
||||
|
||||
let entered = form.price.trim().to_string();
|
||||
// An empty value clears the negotiated price (same as the Remove action).
|
||||
if entered.is_empty() {
|
||||
account_product_prices::Model::clear(&ctx.db, company.id, product_id).await?;
|
||||
return format::redirect(&format!("/admin/customers/{id}"));
|
||||
@@ -145,7 +197,11 @@ async fn set_price(
|
||||
|
||||
let cents = match parse_price_to_cents(&entered) {
|
||||
Ok(cents) if cents > 0 => cents,
|
||||
_ => return format::redirect(&format!("/admin/customers/{id}?error=discount-must-be-positive")),
|
||||
_ => {
|
||||
return format::redirect(&format!(
|
||||
"/admin/customers/{id}?error=discount-must-be-positive"
|
||||
))
|
||||
}
|
||||
};
|
||||
account_product_prices::Model::upsert(&ctx.db, company.id, product_id, cents).await?;
|
||||
format::redirect(&format!("/admin/customers/{id}"))
|
||||
@@ -163,13 +219,82 @@ async fn remove_price(
|
||||
format::redirect(&format!("/admin/customers/{id}"))
|
||||
}
|
||||
|
||||
/// Replace the company's assigned profiles with the submitted set of checkboxes
|
||||
/// (`profile_ids`, a repeated field axum `Form` can't collect, parsed directly).
|
||||
#[debug_handler]
|
||||
async fn sync_profiles(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
body: String,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let company = company_by_id(&ctx, id).await?;
|
||||
|
||||
let profile_ids: Vec<i32> = form_urlencoded::parse(body.as_bytes())
|
||||
.filter(|(k, _)| k == "profile_ids")
|
||||
.filter_map(|(_, v)| v.parse::<i32>().ok())
|
||||
.collect();
|
||||
|
||||
let txn = ctx.db.begin().await?;
|
||||
account_discount_profiles::Entity::delete_many()
|
||||
.filter(account_discount_profiles::Column::UserId.eq(company.id))
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
for profile_id in profile_ids {
|
||||
account_discount_profiles::ActiveModel {
|
||||
user_id: Set(company.id),
|
||||
discount_profile_id: Set(profile_id),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&txn)
|
||||
.await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
format::redirect(&format!("/admin/customers/{id}"))
|
||||
}
|
||||
|
||||
/// Record the admin's chosen winning profile for a colliding product.
|
||||
#[debug_handler]
|
||||
async fn set_resolution(
|
||||
auth: auth::JWT,
|
||||
Path((id, product_id)): Path<(i32, i32)>,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(form): Form<ResolutionForm>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let company = company_by_id(&ctx, id).await?;
|
||||
|
||||
let existing = account_product_resolutions::Entity::find()
|
||||
.filter(account_product_resolutions::Column::UserId.eq(company.id))
|
||||
.filter(account_product_resolutions::Column::ProductId.eq(product_id))
|
||||
.one(&ctx.db)
|
||||
.await?;
|
||||
let mut active = match existing {
|
||||
Some(row) => row.into_active_model(),
|
||||
None => account_product_resolutions::ActiveModel {
|
||||
user_id: Set(company.id),
|
||||
product_id: Set(product_id),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
active.discount_profile_id = Set(form.profile_id);
|
||||
active.save(&ctx.db).await?;
|
||||
format::redirect(&format!("/admin/customers/{id}"))
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/admin/customers", get(index))
|
||||
.add("/admin/customers/{id}", get(show))
|
||||
.add("/admin/customers/{id}/profiles", post(sync_profiles))
|
||||
.add("/admin/customers/{id}/prices/{product_id}", post(set_price))
|
||||
.add(
|
||||
"/admin/customers/{id}/prices/{product_id}/remove",
|
||||
post(remove_price),
|
||||
)
|
||||
.add(
|
||||
"/admin/customers/{id}/resolutions/{product_id}",
|
||||
post(set_resolution),
|
||||
)
|
||||
}
|
||||
|
||||
298
src/controllers/admin_discount_profiles.rs
Normal file
298
src/controllers/admin_discount_profiles.rs
Normal file
@@ -0,0 +1,298 @@
|
||||
//! Admin CRUD for reusable discount profiles (a named percentage over a product
|
||||
//! scope). Profiles are assigned to business accounts on the customer page; here
|
||||
//! the admin only defines them.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, ModelTrait, PaginatorTrait,
|
||||
QueryFilter, QueryOrder, Set, TransactionTrait,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
controllers::i18n::current_lang,
|
||||
models::{discount_profile_products, discount_profiles, products},
|
||||
shared::{
|
||||
guard,
|
||||
money::{format_bp, parse_percent, percent_to_bp},
|
||||
},
|
||||
};
|
||||
|
||||
/// Scalar + repeated fields parsed from the profile form. `product_ids` is a
|
||||
/// repeated checkbox field, which `serde_urlencoded` (axum `Form`) can't collect,
|
||||
/// so the body is parsed directly.
|
||||
struct ProfileInput {
|
||||
name: String,
|
||||
percent: String,
|
||||
scope_type: String,
|
||||
product_ids: Vec<i32>,
|
||||
}
|
||||
|
||||
fn parse_profile_form(body: &str) -> ProfileInput {
|
||||
let mut name = String::new();
|
||||
let mut percent = String::new();
|
||||
let mut scope_type = discount_profiles::SCOPE_INCLUDE.to_string();
|
||||
let mut product_ids = Vec::new();
|
||||
for (key, value) in form_urlencoded::parse(body.as_bytes()) {
|
||||
match key.as_ref() {
|
||||
"name" => name = value.into_owned(),
|
||||
"percent" => percent = value.into_owned(),
|
||||
"scope_type" => scope_type = value.into_owned(),
|
||||
"product_ids" => {
|
||||
if let Ok(id) = value.parse::<i32>() {
|
||||
product_ids.push(id);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
ProfileInput {
|
||||
name,
|
||||
percent,
|
||||
scope_type,
|
||||
product_ids,
|
||||
}
|
||||
}
|
||||
|
||||
async fn profile_by_id(ctx: &AppContext, id: i32) -> Result<discount_profiles::Model> {
|
||||
discount_profiles::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
#[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 profiles = discount_profiles::Entity::find()
|
||||
.order_by_asc(discount_profiles::Column::Name)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
let mut rows = Vec::with_capacity(profiles.len());
|
||||
for profile in &profiles {
|
||||
let count = discount_profile_products::Entity::find()
|
||||
.filter(discount_profile_products::Column::DiscountProfileId.eq(profile.id))
|
||||
.count(&ctx.db)
|
||||
.await?;
|
||||
rows.push(json!({
|
||||
"id": profile.id,
|
||||
"name": profile.name,
|
||||
"percent": format_bp(profile.percent_bp),
|
||||
"scope_type": profile.scope_type,
|
||||
"product_count": count,
|
||||
}));
|
||||
}
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"admin/catalog/discount_profiles.html",
|
||||
json!({ "profiles": rows, "lang": current_lang(&jar) }),
|
||||
)
|
||||
}
|
||||
|
||||
/// Render the create/edit form. `profile` is null on create.
|
||||
async fn render_form(
|
||||
ctx: &AppContext,
|
||||
v: &TeraView,
|
||||
jar: &CookieJar,
|
||||
profile: Option<&discount_profiles::Model>,
|
||||
selected: &HashSet<i32>,
|
||||
error: Option<&str>,
|
||||
) -> Result<Response> {
|
||||
let all_products = products::Entity::find()
|
||||
.order_by_asc(products::Column::Name)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let product_rows: Vec<serde_json::Value> = all_products
|
||||
.iter()
|
||||
.map(|p| json!({ "id": p.id, "name": p.name, "selected": selected.contains(&p.id) }))
|
||||
.collect();
|
||||
|
||||
let profile_json = match profile {
|
||||
Some(p) => json!({
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"percent": format_bp(p.percent_bp),
|
||||
"scope_type": p.scope_type,
|
||||
}),
|
||||
None => serde_json::Value::Null,
|
||||
};
|
||||
|
||||
format::view(
|
||||
v,
|
||||
"admin/catalog/discount_profile_form.html",
|
||||
json!({
|
||||
"profile": profile_json,
|
||||
"products": product_rows,
|
||||
"error": error,
|
||||
"lang": current_lang(jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn new(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
render_form(&ctx, &v, &jar, None, &HashSet::new(), None).await
|
||||
}
|
||||
|
||||
#[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 profile = profile_by_id(&ctx, id).await?;
|
||||
let selected = member_ids(&ctx, id).await?;
|
||||
render_form(&ctx, &v, &jar, Some(&profile), &selected, None).await
|
||||
}
|
||||
|
||||
async fn member_ids(ctx: &AppContext, profile_id: i32) -> Result<HashSet<i32>> {
|
||||
Ok(discount_profile_products::Entity::find()
|
||||
.filter(discount_profile_products::Column::DiscountProfileId.eq(profile_id))
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| r.product_id)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Validate the parsed form into `(name, percent_bp, scope_type)`, or an error key.
|
||||
fn validate(input: &ProfileInput) -> std::result::Result<(String, i32, String), &'static str> {
|
||||
let name = input.name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err("profile-name-required");
|
||||
}
|
||||
let pct = parse_percent(&input.percent).ok_or("discount-invalid")?;
|
||||
if pct <= 0.0 || pct >= 100.0 {
|
||||
return Err("discount-percent-range");
|
||||
}
|
||||
let scope = if input.scope_type == discount_profiles::SCOPE_ALL_EXCEPT {
|
||||
discount_profiles::SCOPE_ALL_EXCEPT
|
||||
} else {
|
||||
discount_profiles::SCOPE_INCLUDE
|
||||
};
|
||||
Ok((name, percent_to_bp(pct), scope.to_string()))
|
||||
}
|
||||
|
||||
/// Replace a profile's product membership with `product_ids`.
|
||||
async fn sync_membership(
|
||||
ctx: &AppContext,
|
||||
profile_id: i32,
|
||||
product_ids: &[i32],
|
||||
) -> Result<()> {
|
||||
let txn = ctx.db.begin().await?;
|
||||
discount_profile_products::Entity::delete_many()
|
||||
.filter(discount_profile_products::Column::DiscountProfileId.eq(profile_id))
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
for product_id in product_ids {
|
||||
discount_profile_products::ActiveModel {
|
||||
discount_profile_id: Set(profile_id),
|
||||
product_id: Set(*product_id),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&txn)
|
||||
.await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn create(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
body: String,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let input = parse_profile_form(&body);
|
||||
let (name, percent_bp, scope_type) = match validate(&input) {
|
||||
Ok(values) => values,
|
||||
Err(key) => {
|
||||
let selected: HashSet<i32> = input.product_ids.iter().copied().collect();
|
||||
return render_form(&ctx, &v, &jar, None, &selected, Some(key)).await;
|
||||
}
|
||||
};
|
||||
|
||||
let profile = discount_profiles::ActiveModel {
|
||||
name: Set(name),
|
||||
percent_bp: Set(percent_bp),
|
||||
scope_type: Set(scope_type),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
sync_membership(&ctx, profile.id, &input.product_ids).await?;
|
||||
format::redirect("/admin/catalog/discount-profiles")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn update(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
body: String,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let profile = profile_by_id(&ctx, id).await?;
|
||||
let input = parse_profile_form(&body);
|
||||
let (name, percent_bp, scope_type) = match validate(&input) {
|
||||
Ok(values) => values,
|
||||
Err(key) => {
|
||||
let selected: HashSet<i32> = input.product_ids.iter().copied().collect();
|
||||
return render_form(&ctx, &v, &jar, Some(&profile), &selected, Some(key)).await;
|
||||
}
|
||||
};
|
||||
|
||||
let mut active = profile.into_active_model();
|
||||
active.name = Set(name);
|
||||
active.percent_bp = Set(percent_bp);
|
||||
active.scope_type = Set(scope_type);
|
||||
active.update(&ctx.db).await?;
|
||||
sync_membership(&ctx, id, &input.product_ids).await?;
|
||||
format::redirect("/admin/catalog/discount-profiles")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn delete(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
// FK cascades remove membership, assignments and resolutions.
|
||||
profile_by_id(&ctx, id).await?.delete(&ctx.db).await?;
|
||||
format::redirect("/admin/catalog/discount-profiles")
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/admin/catalog/discount-profiles", get(index))
|
||||
.add("/admin/catalog/discount-profiles/new", get(new))
|
||||
.add("/admin/catalog/discount-profiles", post(create))
|
||||
.add("/admin/catalog/discount-profiles/{id}/edit", get(edit))
|
||||
.add("/admin/catalog/discount-profiles/{id}", post(update))
|
||||
.add("/admin/catalog/discount-profiles/{id}/delete", post(delete))
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use crate::{
|
||||
models::products,
|
||||
shared::{
|
||||
guard,
|
||||
money::{format_price, parse_price_to_cents},
|
||||
money::{format_price, parse_percent, parse_price_to_cents},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -29,12 +29,6 @@ struct DiscountForm {
|
||||
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 {
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod oauth2;
|
||||
pub mod admin_categories;
|
||||
pub mod admin_customers;
|
||||
pub mod admin_dashboard;
|
||||
pub mod admin_discount_profiles;
|
||||
pub mod admin_discounts;
|
||||
pub mod admin_form;
|
||||
pub mod admin_orders;
|
||||
|
||||
Reference in New Issue
Block a user