478 lines
16 KiB
Rust
478 lines
16 KiB
Rust
//! 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<i64>,
|
|
pub auto_cents: Option<i64>,
|
|
pub auto_profile_id: Option<i32>,
|
|
pub covering_profile_ids: Vec<i32>,
|
|
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<i64>) -> 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<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 ids: Vec<i32> = 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<B2bContext>,
|
|
}
|
|
|
|
/// 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<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(&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<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 variants for `user`, batching per-request lookups.
|
|
pub async fn detail_variants(
|
|
ctx: &AppContext,
|
|
list: &[product_variants::Model],
|
|
user: Option<&users::Model>,
|
|
) -> Result<Vec<PriceDetail>> {
|
|
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<Vec<PricedProduct>> {
|
|
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<i32>,
|
|
) -> Result<Vec<PricedProduct>> {
|
|
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<PricedProduct> {
|
|
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<Vec<PricedProduct>> {
|
|
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);
|
|
}
|
|
}
|