//! Single source of truth for the price a given viewer pays for a product, so //! the storefront, cart, and placed orders always agree. //! //! 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}; use loco_rs::prelude::*; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use crate::models::{ account_discount_profiles, account_product_prices, account_product_resolutions, audience_discount_profiles, discount_profile_products, discount_profiles, product_variants, 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 { pub price_cents: i64, pub regular_cents: i64, /// True when a business-specific deal (not just the personal price) won. pub is_business: bool, } impl PricedProduct { #[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. #[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, pub auto_cents: Option, pub auto_profile_id: Option, pub covering_profile_ids: Vec, pub collision: bool, pub price_cents: i64, pub is_business: bool, } 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, } } } /// 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) -> PricedProduct { match business { Some(b) if b <= personal_cents => PricedProduct { price_cents: b, regular_cents, is_business: true, }, _ => PricedProduct { price_cents: personal_cents, regular_cents, is_business: false, }, } } fn is_company(user: Option<&users::Model>) -> bool { matches!(user, Some(u) if u.account_type == COMPANY) } /// `min` of `Option`s, ignoring `None`s. fn lowest(values: [Option; 4]) -> Option { 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, membership: HashMap>, } 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 { 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>> { let mut membership: HashMap> = 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) -> Result { 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 { let ids: Vec = 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, profiles: LoadedProfiles, resolutions: HashMap, } async fn load_b2b(ctx: &AppContext, user_id: i32) -> Result { let manual = account_product_prices::Model::map_for_user(&ctx.db, user_id).await?; let ids: Vec = 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(); 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)) .all(&ctx.db) .await? .into_iter() .map(|r| (r.variant_id, r.discount_profile_id)) .collect(); Ok(B2bContext { manual, profiles, resolutions, }) } /// Everything loaded once per request to price any product for the viewer. struct PricingCtx { personal: LoadedProfiles, business: LoadedProfiles, b2b: Option, } /// Resolve the per-company automated price for a variant. Discount profiles /// cover the *product* (`product_id`); the admin's collision resolution is set /// per *variant* (`variant_id`). Result: 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, variant_id: i32, ) -> (Option, Option, bool, Vec) { let covering = b2b.profiles.covering(product_id); let ids: Vec = 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(&variant_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_variant(variant: &product_variants::Model, pc: &PricingCtx) -> PriceDetail { let regular = variant.price_cents; let product_id = variant.product_id; // Personal price: the variant's own public sale + personal-audience profiles // (which cover the product), lowest wins. let personal_sale = variant.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). The negotiated price is keyed // to the variant; the baseline business sale lives on the variant. let manual = b2b.manual.get(&variant.id).copied(); let (auto_cents, auto_profile_id, collision, covering_ids) = company_auto(b2b, regular, product_id, variant.id); let business_global = variant.business_sale_price_cents; 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: personal_effective, manual_cents: manual, auto_cents, auto_profile_id, covering_profile_ids: covering_ids, collision, price_cents: priced.price_cents, is_business: priced.is_business, } } async fn load_pricing_ctx(ctx: &AppContext, user: Option<&users::Model>) -> Result { 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 variants for `user`, batching per-request lookups. pub async fn detail_variants( ctx: &AppContext, list: &[product_variants::Model], user: Option<&users::Model>, ) -> Result> { let pc = load_pricing_ctx(ctx, user).await?; Ok(list.iter().map(|v| detail_for_variant(v, &pc)).collect()) } /// Effective prices for a whole audience using only the global layers (the /// per-product sale + audience-assigned profiles + the business baseline), with /// no specific company's per-company deals. Used by the discounts admin page to /// preview what each tab's discounts produce. `audience` is "personal" or /// "business". pub async fn audience_price_variants( ctx: &AppContext, list: &[product_variants::Model], audience: &str, ) -> Result> { let personal = load_audience(ctx, AUDIENCE_PERSONAL).await?; let (business, b2b) = if audience == AUDIENCE_BUSINESS { ( load_audience(ctx, AUDIENCE_BUSINESS).await?, // A generic company with no per-company profiles/negotiated prices. Some(B2bContext { manual: HashMap::new(), profiles: LoadedProfiles::empty(), resolutions: HashMap::new(), }), ) } else { (LoadedProfiles::empty(), None) }; let pc = PricingCtx { personal, business, b2b, }; Ok(list.iter().map(|v| detail_for_variant(v, &pc).priced()).collect()) } /// Like [`audience_price_many`], but prices against an *unsaved* set of profile /// ids for the active audience instead of the persisted assignment. Used by the /// products page to preview effective prices as the admin toggles profile /// checkboxes, before they hit Save. For the business tab the personal layer /// stays the persisted one (businesses get the lower of personal/business), and /// only the business layer is replaced by the previewed selection. pub async fn audience_price_variants_preview( ctx: &AppContext, list: &[product_variants::Model], audience: &str, selected_profile_ids: Vec, ) -> Result> { let preview = load_profiles_by_ids(ctx, selected_profile_ids).await?; let (personal, business, b2b) = if audience == AUDIENCE_BUSINESS { ( load_audience(ctx, AUDIENCE_PERSONAL).await?, preview, Some(B2bContext { manual: HashMap::new(), profiles: LoadedProfiles::empty(), resolutions: HashMap::new(), }), ) } else { (preview, LoadedProfiles::empty(), None) }; let pc = PricingCtx { personal, business, b2b, }; Ok(list.iter().map(|v| detail_for_variant(v, &pc).priced()).collect()) } /// Price one variant for `user` (`None` = anonymous/public). pub async fn price_variant( ctx: &AppContext, variant: &product_variants::Model, user: Option<&users::Model>, ) -> Result { let detail = detail_variants(ctx, std::slice::from_ref(variant), user).await?; Ok(detail[0].priced()) } /// Price many variants for `user`, batching the per-request lookups. pub async fn price_variants( ctx: &AppContext, list: &[product_variants::Model], user: Option<&users::Model>, ) -> Result> { Ok(detail_variants(ctx, list, user) .await? .iter() .map(PriceDetail::priced) .collect()) } #[cfg(test)] mod tests { use super::decide; use crate::shared::money::apply_discount_bp; #[test] fn personal_only() { let p = decide(10000, 10000, None); assert_eq!(p.price_cents, 10000); assert!(!p.is_business); } #[test] fn business_lower_wins() { let p = decide(10000, 10000, Some(9000)); assert_eq!(p.price_cents, 9000); assert!(p.is_business); } #[test] 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); } #[test] fn business_equal_is_business() { let p = decide(10000, 10000, Some(10000)); assert_eq!(p.price_cents, 10000); assert!(p.is_business); } #[test] fn discount_bp_rounds_half_up() { assert_eq!(apply_discount_bp(10000, 500), 9500); assert_eq!(apply_discount_bp(10000, 1500), 8500); assert_eq!(apply_discount_bp(10000, 0), 10000); } }