diff --git a/assets/i18n/en/main.ftl b/assets/i18n/en/main.ftl index b7ec7e0..18437dd 100644 --- a/assets/i18n/en/main.ftl +++ b/assets/i18n/en/main.ftl @@ -214,6 +214,8 @@ admin-discounts-desc = Set discounted product prices. A discount shows up as a s business-discount-desc = A baseline discount for all business accounts (off the regular price). Profiles and negotiated prices apply on top (lowest price wins). audience-personal = Personal audience-business = Business +apply-profiles-personal-hint = These profiles lower the public price for all customers. +apply-profiles-business-hint = These profiles lower the price for all business accounts. Businesses always get the lower of the personal and business price. on-sale = On sale no-discount = No discount discount = Discount diff --git a/assets/i18n/sk/main.ftl b/assets/i18n/sk/main.ftl index 2a96641..b97b5b1 100644 --- a/assets/i18n/sk/main.ftl +++ b/assets/i18n/sk/main.ftl @@ -214,6 +214,8 @@ admin-discounts-desc = Nastavte zľavnené ceny produktov. Zľava sa v obchode z business-discount-desc = Základná zľava pre všetky firemné účty (z bežnej ceny). Profily a dohodnuté ceny sa uplatnia navyše (platí najnižšia cena). audience-personal = Osobné audience-business = Firemné +apply-profiles-personal-hint = Tieto profily znížia verejnú cenu pre všetkých zákazníkov. +apply-profiles-business-hint = Tieto profily znížia cenu pre všetky firemné účty. Firmy vždy dostanú nižšiu z osobnej a firemnej ceny. on-sale = V akcii no-discount = Bez zľavy discount = Zľava diff --git a/assets/views/admin/catalog/discounts.html b/assets/views/admin/catalog/discounts.html index 17f233f..94d71da 100644 --- a/assets/views/admin/catalog/discounts.html +++ b/assets/views/admin/catalog/discounts.html @@ -27,6 +27,33 @@ + +
+

{{ t(key="discount-profiles", lang=lang | default(value='sk')) }}

+

+ {% if business %}{{ t(key="apply-profiles-business-hint", lang=lang | default(value='sk')) }}{% else %}{{ t(key="apply-profiles-personal-hint", lang=lang | default(value='sk')) }}{% endif %} +

+ {% if profiles | length > 0 %} +
+ {{ ui::csrf_field() }} +
+ {% for profile in profiles %} + + {% endfor %} +
+ {{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", size="px-4 py-2 text-sm") }} +
+ {% else %} +

+ {{ t(key="admin-no-profiles", lang=lang | default(value='sk')) }} + {{ t(key="new-profile", lang=lang | default(value='sk')) }} +

+ {% endif %} +
+
{% if products | length > 0 %} diff --git a/migration/src/lib.rs b/migration/src/lib.rs index a7557f0..7afeb5f 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -39,6 +39,7 @@ mod m20260621_000001_add_sale_price_to_products; mod m20260621_000002_account_product_prices; mod m20260621_000003_discount_profiles; mod m20260621_000004_add_business_sale_price_to_products; +mod m20260622_000001_audience_discount_profiles; pub struct Migrator; #[async_trait::async_trait] @@ -82,6 +83,7 @@ impl MigratorTrait for Migrator { Box::new(m20260621_000002_account_product_prices::Migration), Box::new(m20260621_000003_discount_profiles::Migration), Box::new(m20260621_000004_add_business_sale_price_to_products::Migration), + Box::new(m20260622_000001_audience_discount_profiles::Migration), // inject-above (do not remove this comment) ] } diff --git a/migration/src/m20260622_000001_audience_discount_profiles.rs b/migration/src/m20260622_000001_audience_discount_profiles.rs new file mode 100644 index 0000000..0aff9cf --- /dev/null +++ b/migration/src/m20260622_000001_audience_discount_profiles.rs @@ -0,0 +1,40 @@ +use loco_rs::schema::*; +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> { + // Discount profiles applied globally to a whole audience, set on the + // discounts page: "personal" lowers the public price for everyone, + // "business" lowers the price for all company accounts. Per-company + // assignments (account_discount_profiles) still layer on top. + create_table( + m, + "audience_discount_profiles", + &[ + ("id", ColType::PkAuto), + ("audience", ColType::String), + ], + &[("discount_profile", "")], + ) + .await?; + + m.create_index( + Index::create() + .name("idx_audience_discount_profiles_unique") + .table(Alias::new("audience_discount_profiles")) + .col(Alias::new("audience")) + .col(Alias::new("discount_profile_id")) + .unique() + .to_owned(), + ) + .await + } + + async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { + drop_table(m, "audience_discount_profiles").await + } +} diff --git a/src/controllers/admin_discounts.rs b/src/controllers/admin_discounts.rs index 804497d..9ec1b5e 100644 --- a/src/controllers/admin_discounts.rs +++ b/src/controllers/admin_discounts.rs @@ -9,20 +9,22 @@ //! prices still layer on top (lowest price wins). Both are computed off the //! regular price. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use axum_extra::extract::cookie::CookieJar; use loco_rs::prelude::*; -use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait, +}; use serde::Deserialize; use serde_json::json; use crate::{ controllers::i18n::current_lang, - models::products, + models::{audience_discount_profiles, discount_profiles, products}, shared::{ guard, - money::{format_price, parse_percent, parse_price_to_cents}, + money::{format_bp, format_price, parse_percent, parse_price_to_cents}, }, }; @@ -122,13 +124,79 @@ async fn index( .all(&ctx.db) .await?; let rows: Vec = list.iter().map(list_row).collect(); + + // Profiles applied globally to this audience, plus all profiles to choose from. + let assigned: HashSet = 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(); + let all_profiles = discount_profiles::Entity::find() + .order_by_asc(discount_profiles::Column::Name) + .all(&ctx.db) + .await?; + let profiles: Vec = all_profiles + .iter() + .map(|p| { + json!({ + "id": p.id, + "name": p.name, + "percent": format_bp(p.percent_bp), + "scope_type": p.scope_type, + "assigned": assigned.contains(&p.id), + }) + }) + .collect(); + format::view( &v, "admin/catalog/discounts.html", - json!({ "products": rows, "audience": audience, "lang": current_lang(&jar) }), + json!({ + "products": rows, + "profiles": profiles, + "audience": audience, + "lang": current_lang(&jar), + }), ) } +/// Replace the profiles applied to this audience with the submitted checkbox set +/// (`profile_ids`, a repeated field parsed directly from the body). +#[debug_handler] +async fn sync_profiles( + auth: auth::JWT, + Query(params): Query>, + State(ctx): State, + body: String, +) -> Result { + guard::current_admin(auth, &ctx).await?; + let audience = read_audience(¶ms); + + let profile_ids: Vec = form_urlencoded::parse(body.as_bytes()) + .filter(|(k, _)| k == "profile_ids") + .filter_map(|(_, value)| value.parse::().ok()) + .collect(); + + let txn = ctx.db.begin().await?; + audience_discount_profiles::Entity::delete_many() + .filter(audience_discount_profiles::Column::Audience.eq(audience)) + .exec(&txn) + .await?; + for profile_id in profile_ids { + audience_discount_profiles::ActiveModel { + audience: Set(audience.to_string()), + discount_profile_id: Set(profile_id), + ..Default::default() + } + .insert(&txn) + .await?; + } + txn.commit().await?; + list_redirect(audience) +} + /// What to pre-fill the form with: the chosen input mode and the raw values for /// each field, so a rejected submit (or a re-edit) shows what the admin had. #[derive(Default)] @@ -284,6 +352,7 @@ async fn remove( pub fn routes() -> Routes { Routes::new() .add("/admin/catalog/discounts", get(index)) + .add("/admin/catalog/discounts/profiles", post(sync_profiles)) .add("/admin/catalog/discounts/{id}/edit", get(edit)) .add("/admin/catalog/discounts/{id}", post(update)) .add("/admin/catalog/discounts/{id}/remove", post(remove)) diff --git a/src/models/_entities/audience_discount_profiles.rs b/src/models/_entities/audience_discount_profiles.rs new file mode 100644 index 0000000..a822891 --- /dev/null +++ b/src/models/_entities/audience_discount_profiles.rs @@ -0,0 +1,36 @@ +//! `SeaORM` Entity assigning a discount profile to a whole audience (all +//! personal viewers or all company accounts). Hand-written to match the +//! `audience_discount_profiles` migration. + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "audience_discount_profiles")] +pub struct Model { + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + #[sea_orm(primary_key)] + pub id: i32, + /// "personal" or "business". + pub audience: String, + pub discount_profile_id: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::discount_profiles::Entity", + from = "Column::DiscountProfileId", + to = "super::discount_profiles::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + DiscountProfiles, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::DiscountProfiles.def() + } +} diff --git a/src/models/_entities/mod.rs b/src/models/_entities/mod.rs index 897462e..bba88fa 100644 --- a/src/models/_entities/mod.rs +++ b/src/models/_entities/mod.rs @@ -4,6 +4,7 @@ pub mod prelude; pub mod account_discount_profiles; pub mod account_product_prices; +pub mod audience_discount_profiles; pub mod account_product_resolutions; pub mod audit_logs; pub mod categories; diff --git a/src/models/_entities/prelude.rs b/src/models/_entities/prelude.rs index db2d74f..6b9ebe5 100644 --- a/src/models/_entities/prelude.rs +++ b/src/models/_entities/prelude.rs @@ -3,6 +3,7 @@ pub use super::account_discount_profiles::Entity as AccountDiscountProfiles; pub use super::account_product_prices::Entity as AccountProductPrices; pub use super::account_product_resolutions::Entity as AccountProductResolutions; +pub use super::audience_discount_profiles::Entity as AudienceDiscountProfiles; pub use super::audit_logs::Entity as AuditLogs; pub use super::categories::Entity as Categories; pub use super::customer_profiles::Entity as CustomerProfiles; diff --git a/src/models/audience_discount_profiles.rs b/src/models/audience_discount_profiles.rs new file mode 100644 index 0000000..9b8ca67 --- /dev/null +++ b/src/models/audience_discount_profiles.rs @@ -0,0 +1,18 @@ +//! Discount profiles applied globally to an audience ("personal" or "business"). + +pub use crate::models::_entities::audience_discount_profiles::{ + ActiveModel, Column, Entity, Model, +}; +use sea_orm::entity::prelude::*; + +pub type AudienceDiscountProfiles = Entity; + +#[async_trait::async_trait] +impl ActiveModelBehavior for ActiveModel { + async fn before_save(self, _db: &C, _insert: bool) -> std::result::Result + where + C: ConnectionTrait, + { + Ok(self) + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index d3421fb..2114454 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -9,6 +9,7 @@ pub mod _entities; pub mod account_discount_profiles; pub mod account_product_prices; pub mod account_product_resolutions; +pub mod audience_discount_profiles; pub mod audit_logs; pub mod categories; pub mod discount_profile_products; diff --git a/src/shared/pricing.rs b/src/shared/pricing.rs index 8e82dce..d39c5ce 100644 --- a/src/shared/pricing.rs +++ b/src/shared/pricing.rs @@ -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, pub auto_cents: Option, - /// The profile that produced `auto_cents` (the resolved/only/biggest one). pub auto_profile_id: Option, - /// Every assigned profile that covers this product. pub covering_profile_ids: Vec, - /// 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) -> 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) -> 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, +/// `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 assigns = account_discount_profiles::Entity::find() + let ids: Vec = account_discount_profiles::Entity::find() .filter(account_discount_profiles::Column::UserId.eq(user_id)) .all(&ctx.db) - .await?; - let profile_ids: Vec = 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> = 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 { 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, +} + +/// 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, 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(&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 { + 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> { - 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); } }