discount profiles and discounts overall implemented and working
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled

This commit is contained in:
Priec
2026-06-21 23:46:37 +02:00
parent c713627a2c
commit 1df8d66d5d
27 changed files with 1317 additions and 89 deletions

View File

@@ -4,18 +4,25 @@
//! 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.)
//! public price, any admin-set negotiated price, and the price from their
//! assigned automated discount profiles. **Lowest wins.**
use std::collections::{HashMap, HashSet};
use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use crate::models::{account_product_prices, products, users};
use crate::models::{
account_discount_profiles, account_product_prices, account_product_resolutions,
discount_profile_products, discount_profiles, products, users,
};
use crate::shared::money::apply_discount_bp;
/// `account_type` value that unlocks business pricing.
const COMPANY: &str = "company";
/// The resolved price for one product and one viewer.
/// The resolved price for one product and one viewer (the slim shape templates
/// and the cart use).
#[derive(Debug, Clone, Copy)]
pub struct PricedProduct {
/// What the viewer pays, in minor units.
@@ -34,26 +41,57 @@ impl PricedProduct {
}
}
/// Is this viewer a business (company) account?
fn is_company(user: Option<&users::Model>) -> bool {
matches!(user, Some(u) if u.account_type == COMPANY)
/// Full breakdown for one product and one viewer, used by the admin company page
/// (which needs to show each layer and any collision). The storefront only needs
/// [`PriceDetail::priced`].
#[derive(Debug, Clone)]
pub struct PriceDetail {
pub regular_cents: i64,
pub public_cents: i64,
pub manual_cents: Option<i64>,
pub auto_cents: Option<i64>,
/// The profile that produced `auto_cents` (the resolved/only/biggest one).
pub auto_profile_id: Option<i32>,
/// Every assigned profile that covers this product.
pub covering_profile_ids: Vec<i32>,
/// True when more than one profile covers the product and the admin has not
/// resolved which wins (a fallback was used).
pub collision: bool,
pub price_cents: i64,
pub is_business: bool,
}
/// 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,
impl PriceDetail {
fn public_only(regular_cents: i64, public_cents: i64) -> Self {
Self {
regular_cents,
public_cents,
manual_cents: None,
auto_cents: None,
auto_profile_id: None,
covering_profile_ids: Vec::new(),
collision: false,
price_cents: public_cents,
is_business: false,
}
}
#[must_use]
pub fn priced(&self) -> PricedProduct {
PricedProduct {
price_cents: self.price_cents,
regular_cents: self.regular_cents,
is_business: self.is_business,
}
}
}
/// 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,
/// The "lowest wins" decision: pick the business price only when it is at or
/// below the public price. Pure, so it is unit-tested directly.
fn decide(regular_cents: i64, public_cents: i64, business: Option<i64>) -> PricedProduct {
match business {
Some(b) if b <= public_cents => PricedProduct {
price_cents: b,
regular_cents,
is_business: true,
},
@@ -65,8 +103,141 @@ fn combine(regular_cents: i64, public_cents: i64, manual: Option<i64>) -> Priced
}
}
fn resolve(product: &products::Model, manual: Option<i64>) -> PricedProduct {
combine(product.price_cents, product.effective_price_cents(), manual)
/// Is this viewer a business (company) account?
fn is_company(user: Option<&users::Model>) -> bool {
matches!(user, Some(u) if u.account_type == COMPANY)
}
/// Everything needed to resolve every product's business price for one account,
/// loaded once so listing pages and the cart avoid N+1 queries.
struct B2bContext {
manual: HashMap<i32, i64>,
profiles: Vec<discount_profiles::Model>,
membership: HashMap<i32, HashSet<i32>>,
resolutions: HashMap<i32, i32>,
}
async fn load_b2b(ctx: &AppContext, user_id: i32) -> Result<B2bContext> {
let manual = account_product_prices::Model::map_for_user(&ctx.db, user_id).await?;
let assigns = account_discount_profiles::Entity::find()
.filter(account_discount_profiles::Column::UserId.eq(user_id))
.all(&ctx.db)
.await?;
let profile_ids: Vec<i32> = assigns.iter().map(|a| a.discount_profile_id).collect();
let (profiles, membership) = if profile_ids.is_empty() {
(Vec::new(), HashMap::new())
} else {
let profiles = discount_profiles::Entity::find()
.filter(discount_profiles::Column::Id.is_in(profile_ids.clone()))
.all(&ctx.db)
.await?;
let rows = discount_profile_products::Entity::find()
.filter(discount_profile_products::Column::DiscountProfileId.is_in(profile_ids))
.all(&ctx.db)
.await?;
let mut membership: HashMap<i32, HashSet<i32>> = HashMap::new();
for row in rows {
membership
.entry(row.discount_profile_id)
.or_default()
.insert(row.product_id);
}
(profiles, membership)
};
let resolutions = account_product_resolutions::Entity::find()
.filter(account_product_resolutions::Column::UserId.eq(user_id))
.all(&ctx.db)
.await?
.into_iter()
.map(|r| (r.product_id, r.discount_profile_id))
.collect();
Ok(B2bContext {
manual,
profiles,
membership,
resolutions,
})
}
/// Resolve one product's full price breakdown for `b2b` (None = public viewer).
fn detail_for(product: &products::Model, b2b: Option<&B2bContext>) -> PriceDetail {
let regular = product.price_cents;
let public = product.effective_price_cents();
let Some(b2b) = b2b else {
return PriceDetail::public_only(regular, public);
};
let manual = b2b.manual.get(&product.id).copied();
// Which assigned profiles cover this product.
let empty = HashSet::new();
let covering: Vec<&discount_profiles::Model> = b2b
.profiles
.iter()
.filter(|p| p.covers(product.id, b2b.membership.get(&p.id).unwrap_or(&empty)))
.collect();
let mut auto_cents = None;
let mut auto_profile_id = None;
let mut collision = false;
if !covering.is_empty() {
let chosen = if covering.len() == 1 {
covering[0]
} else {
// Two+ profiles collide: honour the admin's resolution, else fall
// back to the biggest discount and flag it for resolving.
match b2b
.resolutions
.get(&product.id)
.and_then(|rid| covering.iter().find(|p| p.id == *rid).copied())
{
Some(resolved) => resolved,
None => {
collision = true;
covering
.iter()
.max_by_key(|p| p.percent_bp)
.copied()
.expect("covering is non-empty")
}
}
};
auto_profile_id = Some(chosen.id);
auto_cents = Some(apply_discount_bp(regular, chosen.percent_bp));
}
let business = [manual, auto_cents].into_iter().flatten().min();
let priced = decide(regular, public, business);
PriceDetail {
regular_cents: regular,
public_cents: public,
manual_cents: manual,
auto_cents,
auto_profile_id,
covering_profile_ids: covering.iter().map(|p| p.id).collect(),
collision,
price_cents: priced.price_cents,
is_business: priced.is_business,
}
}
/// Full breakdowns for many products for `user`, batching per-account lookups.
pub async fn detail_many(
ctx: &AppContext,
list: &[products::Model],
user: Option<&users::Model>,
) -> Result<Vec<PriceDetail>> {
let b2b = if is_company(user) {
Some(load_b2b(ctx, user.expect("is_company implies Some").id).await?)
} else {
None
};
Ok(list.iter().map(|p| detail_for(p, b2b.as_ref())).collect())
}
/// Price one product for `user` (`None` = anonymous/public).
@@ -75,17 +246,8 @@ pub async fn price_for(
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))
let detail = detail_many(ctx, std::slice::from_ref(product), user).await?;
Ok(detail[0].priced())
}
/// Price many products for `user`, batching the per-account lookups to avoid
@@ -95,55 +257,53 @@ pub async fn price_many(
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
Ok(detail_many(ctx, list, user)
.await?
.iter()
.map(|product| resolve(product, manual.get(&product.id).copied()))
.map(PriceDetail::priced)
.collect())
}
#[cfg(test)]
mod tests {
use super::combine;
use super::decide;
use crate::shared::money::apply_discount_bp;
// regular 100.00, no public sale, no negotiated price.
#[test]
fn public_only() {
let p = combine(10000, 10000, None);
let p = decide(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));
fn business_lower_wins() {
let p = decide(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));
fn public_sale_beats_business() {
// regular 100, public sale 80, business best 90 -> pay 80, not business.
let p = decide(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));
fn business_equal_is_business() {
let p = decide(10000, 10000, Some(10000));
assert_eq!(p.price_cents, 10000);
assert!(!p.is_reduced());
assert!(p.is_business);
}
#[test]
fn discount_bp_rounds_half_up() {
assert_eq!(apply_discount_bp(10000, 500), 9500); // 5%
assert_eq!(apply_discount_bp(10000, 1500), 8500); // 15%
assert_eq!(apply_discount_bp(999, 500), 949); // 49.95 -> 50 off
assert_eq!(apply_discount_bp(10000, 0), 10000);
}
}