global discount price
This commit is contained in:
@@ -1,11 +1,16 @@
|
||||
//! 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, any admin-set negotiated price, and the price from their
|
||||
//! assigned automated discount profiles. **Lowest wins.**
|
||||
//! Layers, all combined by "lowest wins":
|
||||
//! - **Personal price** (everyone, incl. anonymous): the lower of the regular
|
||||
//! price, the public sale (`products.sale_price_cents`), and any discount
|
||||
//! profiles assigned to the *personal* audience on the discounts page.
|
||||
//! - **Business price** (company accounts): the lower of the personal price, the
|
||||
//! global business baseline (`products.business_sale_price_cents`), discount
|
||||
//! profiles assigned to the *business* audience, the company's own assigned
|
||||
//! profiles, and the company's manually negotiated price.
|
||||
//!
|
||||
//! A company therefore always pays the lowest of personal vs business.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
@@ -14,48 +19,42 @@ use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
|
||||
use crate::models::{
|
||||
account_discount_profiles, account_product_prices, account_product_resolutions,
|
||||
discount_profile_products, discount_profiles, products, users,
|
||||
audience_discount_profiles, 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";
|
||||
const AUDIENCE_PERSONAL: &str = "personal";
|
||||
const AUDIENCE_BUSINESS: &str = "business";
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
/// True when a business-specific deal (not just the personal price) won.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`].
|
||||
/// Full breakdown for one product and one viewer, used by the admin company page.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PriceDetail {
|
||||
pub regular_cents: i64,
|
||||
/// The personal price (what non-business viewers pay).
|
||||
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,
|
||||
@@ -86,66 +85,134 @@ impl PriceDetail {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// The "lowest wins" decision: take the business price only when it is at or
|
||||
/// below the personal price. Pure, so it is unit-tested directly.
|
||||
fn decide(regular_cents: i64, personal_cents: i64, business: Option<i64>) -> PricedProduct {
|
||||
match business {
|
||||
Some(b) if b <= public_cents => PricedProduct {
|
||||
Some(b) if b <= personal_cents => PricedProduct {
|
||||
price_cents: b,
|
||||
regular_cents,
|
||||
is_business: true,
|
||||
},
|
||||
_ => PricedProduct {
|
||||
price_cents: public_cents,
|
||||
price_cents: personal_cents,
|
||||
regular_cents,
|
||||
is_business: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
/// `min` of `Option`s, ignoring `None`s.
|
||||
fn lowest(values: [Option<i64>; 4]) -> Option<i64> {
|
||||
values.into_iter().flatten().min()
|
||||
}
|
||||
|
||||
/// A set of discount profiles plus their product membership, with the logic to
|
||||
/// price a product against them.
|
||||
struct LoadedProfiles {
|
||||
profiles: Vec<discount_profiles::Model>,
|
||||
membership: HashMap<i32, HashSet<i32>>,
|
||||
}
|
||||
|
||||
impl LoadedProfiles {
|
||||
fn empty() -> Self {
|
||||
Self {
|
||||
profiles: Vec::new(),
|
||||
membership: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The best (lowest) price any covering profile produces off the regular
|
||||
/// price, or `None` when no profile covers the product.
|
||||
fn best_price(&self, regular_cents: i64, product_id: i32) -> Option<i64> {
|
||||
let empty = HashSet::new();
|
||||
self.profiles
|
||||
.iter()
|
||||
.filter(|p| p.covers(product_id, self.membership.get(&p.id).unwrap_or(&empty)))
|
||||
.map(|p| apply_discount_bp(regular_cents, p.percent_bp))
|
||||
.min()
|
||||
}
|
||||
|
||||
/// Every profile that covers a product (for the per-company collision UI).
|
||||
fn covering(&self, product_id: i32) -> Vec<&discount_profiles::Model> {
|
||||
let empty = HashSet::new();
|
||||
self.profiles
|
||||
.iter()
|
||||
.filter(|p| p.covers(product_id, self.membership.get(&p.id).unwrap_or(&empty)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_membership(
|
||||
ctx: &AppContext,
|
||||
profile_ids: &[i32],
|
||||
) -> Result<HashMap<i32, HashSet<i32>>> {
|
||||
let mut membership: HashMap<i32, HashSet<i32>> = HashMap::new();
|
||||
if profile_ids.is_empty() {
|
||||
return Ok(membership);
|
||||
}
|
||||
let rows = discount_profile_products::Entity::find()
|
||||
.filter(discount_profile_products::Column::DiscountProfileId.is_in(profile_ids.to_vec()))
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
for row in rows {
|
||||
membership
|
||||
.entry(row.discount_profile_id)
|
||||
.or_default()
|
||||
.insert(row.product_id);
|
||||
}
|
||||
Ok(membership)
|
||||
}
|
||||
|
||||
async fn load_profiles_by_ids(ctx: &AppContext, ids: Vec<i32>) -> Result<LoadedProfiles> {
|
||||
if ids.is_empty() {
|
||||
return Ok(LoadedProfiles::empty());
|
||||
}
|
||||
let profiles = discount_profiles::Entity::find()
|
||||
.filter(discount_profiles::Column::Id.is_in(ids.clone()))
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let membership = load_membership(ctx, &ids).await?;
|
||||
Ok(LoadedProfiles {
|
||||
profiles,
|
||||
membership,
|
||||
})
|
||||
}
|
||||
|
||||
/// Profiles assigned globally to an audience on the discounts page.
|
||||
async fn load_audience(ctx: &AppContext, audience: &str) -> Result<LoadedProfiles> {
|
||||
let ids: Vec<i32> = audience_discount_profiles::Entity::find()
|
||||
.filter(audience_discount_profiles::Column::Audience.eq(audience))
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|a| a.discount_profile_id)
|
||||
.collect();
|
||||
load_profiles_by_ids(ctx, ids).await
|
||||
}
|
||||
|
||||
/// Per-company pricing inputs (manual prices, assigned profiles, resolutions).
|
||||
struct B2bContext {
|
||||
manual: HashMap<i32, i64>,
|
||||
profiles: LoadedProfiles,
|
||||
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()
|
||||
let ids: Vec<i32> = 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)
|
||||
};
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|a| a.discount_profile_id)
|
||||
.collect();
|
||||
let profiles = load_profiles_by_ids(ctx, ids).await?;
|
||||
|
||||
let resolutions = account_product_resolutions::Entity::find()
|
||||
.filter(account_product_resolutions::Column::UserId.eq(user_id))
|
||||
@@ -158,92 +225,117 @@ async fn load_b2b(ctx: &AppContext, user_id: i32) -> Result<B2bContext> {
|
||||
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 {
|
||||
/// Everything loaded once per request to price any product for the viewer.
|
||||
struct PricingCtx {
|
||||
personal: LoadedProfiles,
|
||||
business: LoadedProfiles,
|
||||
b2b: Option<B2bContext>,
|
||||
}
|
||||
|
||||
/// Resolve the per-company automated price for a product: the single covering
|
||||
/// profile, the admin-resolved winner on a collision, or (unresolved) the
|
||||
/// biggest discount, flagged.
|
||||
fn company_auto(
|
||||
b2b: &B2bContext,
|
||||
regular_cents: i64,
|
||||
product_id: i32,
|
||||
) -> (Option<i64>, Option<i32>, bool, Vec<i32>) {
|
||||
let covering = b2b.profiles.covering(product_id);
|
||||
let ids: Vec<i32> = covering.iter().map(|p| p.id).collect();
|
||||
if covering.is_empty() {
|
||||
return (None, None, false, ids);
|
||||
}
|
||||
let (chosen, collision) = if covering.len() == 1 {
|
||||
(covering[0], false)
|
||||
} else {
|
||||
match b2b
|
||||
.resolutions
|
||||
.get(&product_id)
|
||||
.and_then(|rid| covering.iter().find(|p| p.id == *rid).copied())
|
||||
{
|
||||
Some(resolved) => (resolved, false),
|
||||
None => (
|
||||
covering
|
||||
.iter()
|
||||
.max_by_key(|p| p.percent_bp)
|
||||
.copied()
|
||||
.expect("covering is non-empty"),
|
||||
true,
|
||||
),
|
||||
}
|
||||
};
|
||||
(
|
||||
Some(apply_discount_bp(regular_cents, chosen.percent_bp)),
|
||||
Some(chosen.id),
|
||||
collision,
|
||||
ids,
|
||||
)
|
||||
}
|
||||
|
||||
fn detail_for(product: &products::Model, pc: &PricingCtx) -> PriceDetail {
|
||||
let regular = product.price_cents;
|
||||
let public = product.effective_price_cents();
|
||||
let Some(b2b) = b2b else {
|
||||
return PriceDetail::public_only(regular, public);
|
||||
|
||||
// Personal price: public sale + personal-audience profiles, lowest wins.
|
||||
let personal_sale = product.effective_price_cents();
|
||||
let personal_profile = pc.personal.best_price(regular, product.id);
|
||||
let personal_effective = personal_profile
|
||||
.map_or(personal_sale, |p| personal_sale.min(p));
|
||||
|
||||
let Some(b2b) = &pc.b2b else {
|
||||
return PriceDetail::public_only(regular, personal_effective);
|
||||
};
|
||||
|
||||
// Business candidates (company accounts only).
|
||||
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));
|
||||
}
|
||||
|
||||
// Baseline business discount for all company accounts (set on the discounts
|
||||
// page), alongside the per-company automated and negotiated layers.
|
||||
let (auto_cents, auto_profile_id, collision, covering_ids) =
|
||||
company_auto(b2b, regular, product.id);
|
||||
let business_global = product.business_sale_price_cents;
|
||||
let business = [manual, auto_cents, business_global]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.min();
|
||||
let priced = decide(regular, public, business);
|
||||
let business_profile = pc.business.best_price(regular, product.id);
|
||||
let business_best = lowest([manual, auto_cents, business_global, business_profile]);
|
||||
|
||||
let priced = decide(regular, personal_effective, business_best);
|
||||
PriceDetail {
|
||||
regular_cents: regular,
|
||||
public_cents: public,
|
||||
public_cents: personal_effective,
|
||||
manual_cents: manual,
|
||||
auto_cents,
|
||||
auto_profile_id,
|
||||
covering_profile_ids: covering.iter().map(|p| p.id).collect(),
|
||||
covering_profile_ids: covering_ids,
|
||||
collision,
|
||||
price_cents: priced.price_cents,
|
||||
is_business: priced.is_business,
|
||||
}
|
||||
}
|
||||
|
||||
/// Full breakdowns for many products for `user`, batching per-account lookups.
|
||||
async fn load_pricing_ctx(ctx: &AppContext, user: Option<&users::Model>) -> Result<PricingCtx> {
|
||||
let personal = load_audience(ctx, AUDIENCE_PERSONAL).await?;
|
||||
let (business, b2b) = if is_company(user) {
|
||||
(
|
||||
load_audience(ctx, AUDIENCE_BUSINESS).await?,
|
||||
Some(load_b2b(ctx, user.expect("is_company implies Some").id).await?),
|
||||
)
|
||||
} else {
|
||||
(LoadedProfiles::empty(), None)
|
||||
};
|
||||
Ok(PricingCtx {
|
||||
personal,
|
||||
business,
|
||||
b2b,
|
||||
})
|
||||
}
|
||||
|
||||
/// Full breakdowns for many products for `user`, batching per-request 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())
|
||||
let pc = load_pricing_ctx(ctx, user).await?;
|
||||
Ok(list.iter().map(|p| detail_for(p, &pc)).collect())
|
||||
}
|
||||
|
||||
/// Price one product for `user` (`None` = anonymous/public).
|
||||
@@ -256,8 +348,7 @@ pub async fn price_for(
|
||||
Ok(detail[0].priced())
|
||||
}
|
||||
|
||||
/// Price many products for `user`, batching the per-account lookups to avoid
|
||||
/// N+1 queries on listing pages and the cart.
|
||||
/// Price many products for `user`, batching the per-request lookups.
|
||||
pub async fn price_many(
|
||||
ctx: &AppContext,
|
||||
list: &[products::Model],
|
||||
@@ -276,10 +367,9 @@ mod tests {
|
||||
use crate::shared::money::apply_discount_bp;
|
||||
|
||||
#[test]
|
||||
fn public_only() {
|
||||
fn personal_only() {
|
||||
let p = decide(10000, 10000, None);
|
||||
assert_eq!(p.price_cents, 10000);
|
||||
assert!(!p.is_reduced());
|
||||
assert!(!p.is_business);
|
||||
}
|
||||
|
||||
@@ -291,8 +381,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_sale_beats_business() {
|
||||
// regular 100, public sale 80, business best 90 -> pay 80, not business.
|
||||
fn personal_beats_business() {
|
||||
// personal already 80 (e.g. personal profile), business best 90 -> 80.
|
||||
let p = decide(10000, 8000, Some(9000));
|
||||
assert_eq!(p.price_cents, 8000);
|
||||
assert!(!p.is_business);
|
||||
@@ -307,9 +397,8 @@ mod tests {
|
||||
|
||||
#[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, 500), 9500);
|
||||
assert_eq!(apply_discount_bp(10000, 1500), 8500);
|
||||
assert_eq!(apply_discount_bp(10000, 0), 10000);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user