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 %}
+
+ {% 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);
}
}