personal discounts to businesses done
This commit is contained in:
@@ -59,15 +59,21 @@ pub struct Chrome {
|
||||
}
|
||||
|
||||
pub async fn chrome(ctx: &AppContext, jar: &CookieJar) -> Chrome {
|
||||
match current_user(ctx, jar).await {
|
||||
Some(user) if is_admin(ctx, &user) => Chrome {
|
||||
chrome_from(ctx, current_user(ctx, jar).await.as_ref())
|
||||
}
|
||||
|
||||
/// Same as [`chrome`] but from an already-loaded user, so a handler that needs
|
||||
/// the user model (e.g. for pricing) can resolve chrome without a second lookup.
|
||||
pub fn chrome_from(ctx: &AppContext, user: Option<&users::Model>) -> Chrome {
|
||||
match user {
|
||||
Some(user) if is_admin(ctx, user) => Chrome {
|
||||
logged_in_admin: true,
|
||||
..Default::default()
|
||||
},
|
||||
Some(user) => Chrome {
|
||||
logged_in_customer: true,
|
||||
customer_name: Some(user.name),
|
||||
customer_account_type: Some(user.account_type),
|
||||
customer_name: Some(user.name.clone()),
|
||||
customer_account_type: Some(user.account_type.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
None => Chrome::default(),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
pub mod csrf;
|
||||
pub mod guard;
|
||||
pub mod money;
|
||||
pub mod pricing;
|
||||
pub mod rbac;
|
||||
pub mod settings;
|
||||
pub mod slug;
|
||||
|
||||
149
src/shared/pricing.rs
Normal file
149
src/shared/pricing.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
//! Single source of truth for the price a given viewer pays for a product, so
|
||||
//! the storefront, cart, and placed orders always agree.
|
||||
//!
|
||||
//! Everyone sees the public price — the lower of the regular price and any
|
||||
//! public sale ([`products::Model::effective_price_cents`]). A logged-in
|
||||
//! **company** account additionally gets their business price: the lowest of the
|
||||
//! public price and any admin-set negotiated price. (Phase 2 will add automated
|
||||
//! discount profiles as a further input to the same "lowest wins" rule.)
|
||||
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
|
||||
use crate::models::{account_product_prices, products, users};
|
||||
|
||||
/// `account_type` value that unlocks business pricing.
|
||||
const COMPANY: &str = "company";
|
||||
|
||||
/// The resolved price for one product and one viewer.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PricedProduct {
|
||||
/// What the viewer pays, in minor units.
|
||||
pub price_cents: i64,
|
||||
/// The regular list price, used as the struck-through reference.
|
||||
pub regular_cents: i64,
|
||||
/// True when a business-specific deal (not just a public sale) set the price.
|
||||
pub is_business: bool,
|
||||
}
|
||||
|
||||
impl PricedProduct {
|
||||
/// Whether the final price is below the regular price (render it reduced).
|
||||
#[must_use]
|
||||
pub fn is_reduced(&self) -> bool {
|
||||
self.price_cents < self.regular_cents
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this viewer a business (company) account?
|
||||
fn is_company(user: Option<&users::Model>) -> bool {
|
||||
matches!(user, Some(u) if u.account_type == COMPANY)
|
||||
}
|
||||
|
||||
/// The public (non-business) price for a product.
|
||||
fn public_priced(product: &products::Model) -> PricedProduct {
|
||||
PricedProduct {
|
||||
price_cents: product.effective_price_cents(),
|
||||
regular_cents: product.price_cents,
|
||||
is_business: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Combine the public price with the business layers (Phase 1: the manual
|
||||
/// negotiated price), lowest wins. Pure so it can be unit-tested in isolation.
|
||||
fn combine(regular_cents: i64, public_cents: i64, manual: Option<i64>) -> PricedProduct {
|
||||
match manual {
|
||||
Some(m) if m <= public_cents => PricedProduct {
|
||||
price_cents: m,
|
||||
regular_cents,
|
||||
is_business: true,
|
||||
},
|
||||
_ => PricedProduct {
|
||||
price_cents: public_cents,
|
||||
regular_cents,
|
||||
is_business: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve(product: &products::Model, manual: Option<i64>) -> PricedProduct {
|
||||
combine(product.price_cents, product.effective_price_cents(), manual)
|
||||
}
|
||||
|
||||
/// Price one product for `user` (`None` = anonymous/public).
|
||||
pub async fn price_for(
|
||||
ctx: &AppContext,
|
||||
product: &products::Model,
|
||||
user: Option<&users::Model>,
|
||||
) -> Result<PricedProduct> {
|
||||
if !is_company(user) {
|
||||
return Ok(public_priced(product));
|
||||
}
|
||||
let user = user.expect("is_company implies Some");
|
||||
let manual = account_product_prices::Entity::find()
|
||||
.filter(account_product_prices::Column::UserId.eq(user.id))
|
||||
.filter(account_product_prices::Column::ProductId.eq(product.id))
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.map(|row| row.price_cents);
|
||||
Ok(resolve(product, manual))
|
||||
}
|
||||
|
||||
/// Price many products for `user`, batching the per-account lookups to avoid
|
||||
/// N+1 queries on listing pages and the cart.
|
||||
pub async fn price_many(
|
||||
ctx: &AppContext,
|
||||
list: &[products::Model],
|
||||
user: Option<&users::Model>,
|
||||
) -> Result<Vec<PricedProduct>> {
|
||||
if !is_company(user) {
|
||||
return Ok(list.iter().map(public_priced).collect());
|
||||
}
|
||||
let user = user.expect("is_company implies Some");
|
||||
let manual = account_product_prices::Model::map_for_user(&ctx.db, user.id).await?;
|
||||
Ok(list
|
||||
.iter()
|
||||
.map(|product| resolve(product, manual.get(&product.id).copied()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::combine;
|
||||
|
||||
// regular 100.00, no public sale, no negotiated price.
|
||||
#[test]
|
||||
fn public_only() {
|
||||
let p = combine(10000, 10000, None);
|
||||
assert_eq!(p.price_cents, 10000);
|
||||
assert!(!p.is_reduced());
|
||||
assert!(!p.is_business);
|
||||
}
|
||||
|
||||
// A negotiated price below the public price wins and is flagged business.
|
||||
#[test]
|
||||
fn negotiated_lower_wins() {
|
||||
let p = combine(10000, 10000, Some(9000));
|
||||
assert_eq!(p.price_cents, 9000);
|
||||
assert!(p.is_reduced());
|
||||
assert!(p.is_business);
|
||||
}
|
||||
|
||||
// A public sale below the negotiated price wins (lowest wins); not business.
|
||||
#[test]
|
||||
fn public_sale_beats_negotiated() {
|
||||
// regular 100, public sale 80, negotiated 90 -> pay 80.
|
||||
let p = combine(10000, 8000, Some(9000));
|
||||
assert_eq!(p.price_cents, 8000);
|
||||
assert!(p.is_reduced());
|
||||
assert!(!p.is_business);
|
||||
}
|
||||
|
||||
// A negotiated price equal to the public price is still treated as theirs.
|
||||
#[test]
|
||||
fn negotiated_equal_is_business() {
|
||||
let p = combine(10000, 10000, Some(10000));
|
||||
assert_eq!(p.price_cents, 10000);
|
||||
assert!(!p.is_reduced());
|
||||
assert!(p.is_business);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user