personal discounts to businesses done

This commit is contained in:
Priec
2026-06-21 23:21:24 +02:00
parent ed566b5347
commit c713627a2c
35 changed files with 912 additions and 39 deletions

View File

@@ -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(),

View File

@@ -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
View 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);
}
}