global discount price
This commit is contained in:
@@ -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).
|
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-personal = Personal
|
||||||
audience-business = Business
|
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
|
on-sale = On sale
|
||||||
no-discount = No discount
|
no-discount = No discount
|
||||||
discount = Discount
|
discount = Discount
|
||||||
|
|||||||
@@ -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).
|
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-personal = Osobné
|
||||||
audience-business = Firemné
|
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
|
on-sale = V akcii
|
||||||
no-discount = Bez zľavy
|
no-discount = Bez zľavy
|
||||||
discount = Zľava
|
discount = Zľava
|
||||||
|
|||||||
@@ -27,6 +27,33 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- discount profiles applied to this audience -->
|
||||||
|
<section class="mt-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="discount-profiles", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
<p class="mt-1 text-sm text-on-surface/70 dark:text-on-surface-dark/70">
|
||||||
|
{% 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 %}
|
||||||
|
</p>
|
||||||
|
{% if profiles | length > 0 %}
|
||||||
|
<form method="post" action="/admin/catalog/discounts/profiles?audience={{ audience }}" class="mt-3 space-y-3">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
{% for profile in profiles %}
|
||||||
|
<label class="flex items-center gap-2 text-sm text-on-surface dark:text-on-surface-dark">
|
||||||
|
<input type="checkbox" name="profile_ids" value="{{ profile.id }}" {% if profile.assigned %}checked{% endif %}>
|
||||||
|
<span>{{ profile.name }} <span class="text-on-surface/60 dark:text-on-surface-dark/60">(−{{ profile.percent }}%, {% if profile.scope_type == "all_except" %}{{ t(key="scope-all-except", lang=lang | default(value='sk')) }}{% else %}{{ t(key="scope-include", lang=lang | default(value='sk')) }}{% endif %})</span></span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", size="px-4 py-2 text-sm") }}
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-2 text-sm text-on-surface/70 dark:text-on-surface-dark/70">
|
||||||
|
{{ t(key="admin-no-profiles", lang=lang | default(value='sk')) }}
|
||||||
|
<a href="/admin/catalog/discount-profiles/new" class="text-primary dark:text-primary-dark">{{ t(key="new-profile", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="mt-4 {{ ui::table_wrap_cls() }}">
|
<div class="mt-4 {{ ui::table_wrap_cls() }}">
|
||||||
{% if products | length > 0 %}
|
{% if products | length > 0 %}
|
||||||
<table class="{{ ui::table_cls() }}">
|
<table class="{{ ui::table_cls() }}">
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ mod m20260621_000001_add_sale_price_to_products;
|
|||||||
mod m20260621_000002_account_product_prices;
|
mod m20260621_000002_account_product_prices;
|
||||||
mod m20260621_000003_discount_profiles;
|
mod m20260621_000003_discount_profiles;
|
||||||
mod m20260621_000004_add_business_sale_price_to_products;
|
mod m20260621_000004_add_business_sale_price_to_products;
|
||||||
|
mod m20260622_000001_audience_discount_profiles;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -82,6 +83,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260621_000002_account_product_prices::Migration),
|
Box::new(m20260621_000002_account_product_prices::Migration),
|
||||||
Box::new(m20260621_000003_discount_profiles::Migration),
|
Box::new(m20260621_000003_discount_profiles::Migration),
|
||||||
Box::new(m20260621_000004_add_business_sale_price_to_products::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)
|
// inject-above (do not remove this comment)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
40
migration/src/m20260622_000001_audience_discount_profiles.rs
Normal file
40
migration/src/m20260622_000001_audience_discount_profiles.rs
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,20 +9,22 @@
|
|||||||
//! prices still layer on top (lowest price wins). Both are computed off the
|
//! prices still layer on top (lowest price wins). Both are computed off the
|
||||||
//! regular price.
|
//! regular price.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
use loco_rs::prelude::*;
|
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::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers::i18n::current_lang,
|
controllers::i18n::current_lang,
|
||||||
models::products,
|
models::{audience_discount_profiles, discount_profiles, products},
|
||||||
shared::{
|
shared::{
|
||||||
guard,
|
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)
|
.all(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
let rows: Vec<serde_json::Value> = list.iter().map(list_row).collect();
|
let rows: Vec<serde_json::Value> = list.iter().map(list_row).collect();
|
||||||
|
|
||||||
|
// Profiles applied globally to this audience, plus all profiles to choose from.
|
||||||
|
let assigned: HashSet<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();
|
||||||
|
let all_profiles = discount_profiles::Entity::find()
|
||||||
|
.order_by_asc(discount_profiles::Column::Name)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let profiles: Vec<serde_json::Value> = 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(
|
format::view(
|
||||||
&v,
|
&v,
|
||||||
"admin/catalog/discounts.html",
|
"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<HashMap<String, String>>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
body: String,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let audience = read_audience(¶ms);
|
||||||
|
|
||||||
|
let profile_ids: Vec<i32> = form_urlencoded::parse(body.as_bytes())
|
||||||
|
.filter(|(k, _)| k == "profile_ids")
|
||||||
|
.filter_map(|(_, value)| value.parse::<i32>().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
|
/// 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.
|
/// each field, so a rejected submit (or a re-edit) shows what the admin had.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -284,6 +352,7 @@ async fn remove(
|
|||||||
pub fn routes() -> Routes {
|
pub fn routes() -> Routes {
|
||||||
Routes::new()
|
Routes::new()
|
||||||
.add("/admin/catalog/discounts", get(index))
|
.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}/edit", get(edit))
|
||||||
.add("/admin/catalog/discounts/{id}", post(update))
|
.add("/admin/catalog/discounts/{id}", post(update))
|
||||||
.add("/admin/catalog/discounts/{id}/remove", post(remove))
|
.add("/admin/catalog/discounts/{id}/remove", post(remove))
|
||||||
|
|||||||
36
src/models/_entities/audience_discount_profiles.rs
Normal file
36
src/models/_entities/audience_discount_profiles.rs
Normal file
@@ -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<super::discount_profiles::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::DiscountProfiles.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ pub mod prelude;
|
|||||||
|
|
||||||
pub mod account_discount_profiles;
|
pub mod account_discount_profiles;
|
||||||
pub mod account_product_prices;
|
pub mod account_product_prices;
|
||||||
|
pub mod audience_discount_profiles;
|
||||||
pub mod account_product_resolutions;
|
pub mod account_product_resolutions;
|
||||||
pub mod audit_logs;
|
pub mod audit_logs;
|
||||||
pub mod categories;
|
pub mod categories;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
pub use super::account_discount_profiles::Entity as AccountDiscountProfiles;
|
pub use super::account_discount_profiles::Entity as AccountDiscountProfiles;
|
||||||
pub use super::account_product_prices::Entity as AccountProductPrices;
|
pub use super::account_product_prices::Entity as AccountProductPrices;
|
||||||
pub use super::account_product_resolutions::Entity as AccountProductResolutions;
|
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::audit_logs::Entity as AuditLogs;
|
||||||
pub use super::categories::Entity as Categories;
|
pub use super::categories::Entity as Categories;
|
||||||
pub use super::customer_profiles::Entity as CustomerProfiles;
|
pub use super::customer_profiles::Entity as CustomerProfiles;
|
||||||
|
|||||||
18
src/models/audience_discount_profiles.rs
Normal file
18
src/models/audience_discount_profiles.rs
Normal file
@@ -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<C>(self, _db: &C, _insert: bool) -> std::result::Result<Self, DbErr>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ pub mod _entities;
|
|||||||
pub mod account_discount_profiles;
|
pub mod account_discount_profiles;
|
||||||
pub mod account_product_prices;
|
pub mod account_product_prices;
|
||||||
pub mod account_product_resolutions;
|
pub mod account_product_resolutions;
|
||||||
|
pub mod audience_discount_profiles;
|
||||||
pub mod audit_logs;
|
pub mod audit_logs;
|
||||||
pub mod categories;
|
pub mod categories;
|
||||||
pub mod discount_profile_products;
|
pub mod discount_profile_products;
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
//! Single source of truth for the price a given viewer pays for a product, so
|
//! Single source of truth for the price a given viewer pays for a product, so
|
||||||
//! the storefront, cart, and placed orders always agree.
|
//! the storefront, cart, and placed orders always agree.
|
||||||
//!
|
//!
|
||||||
//! Everyone sees the public price — the lower of the regular price and any
|
//! Layers, all combined by "lowest wins":
|
||||||
//! public sale ([`products::Model::effective_price_cents`]). A logged-in
|
//! - **Personal price** (everyone, incl. anonymous): the lower of the regular
|
||||||
//! **company** account additionally gets their business price: the lowest of the
|
//! price, the public sale (`products.sale_price_cents`), and any discount
|
||||||
//! public price, any admin-set negotiated price, and the price from their
|
//! profiles assigned to the *personal* audience on the discounts page.
|
||||||
//! assigned automated discount profiles. **Lowest wins.**
|
//! - **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 std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
@@ -14,48 +19,42 @@ use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
|||||||
|
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
account_discount_profiles, account_product_prices, account_product_resolutions,
|
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;
|
use crate::shared::money::apply_discount_bp;
|
||||||
|
|
||||||
/// `account_type` value that unlocks business pricing.
|
/// `account_type` value that unlocks business pricing.
|
||||||
const COMPANY: &str = "company";
|
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
|
/// The resolved price for one product and one viewer (the slim shape templates
|
||||||
/// and the cart use).
|
/// and the cart use).
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub struct PricedProduct {
|
pub struct PricedProduct {
|
||||||
/// What the viewer pays, in minor units.
|
|
||||||
pub price_cents: i64,
|
pub price_cents: i64,
|
||||||
/// The regular list price, used as the struck-through reference.
|
|
||||||
pub regular_cents: i64,
|
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,
|
pub is_business: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PricedProduct {
|
impl PricedProduct {
|
||||||
/// Whether the final price is below the regular price (render it reduced).
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn is_reduced(&self) -> bool {
|
pub fn is_reduced(&self) -> bool {
|
||||||
self.price_cents < self.regular_cents
|
self.price_cents < self.regular_cents
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full breakdown for one product and one viewer, used by the admin company page
|
/// 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`].
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PriceDetail {
|
pub struct PriceDetail {
|
||||||
pub regular_cents: i64,
|
pub regular_cents: i64,
|
||||||
|
/// The personal price (what non-business viewers pay).
|
||||||
pub public_cents: i64,
|
pub public_cents: i64,
|
||||||
pub manual_cents: Option<i64>,
|
pub manual_cents: Option<i64>,
|
||||||
pub auto_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>,
|
pub auto_profile_id: Option<i32>,
|
||||||
/// Every assigned profile that covers this product.
|
|
||||||
pub covering_profile_ids: Vec<i32>,
|
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 collision: bool,
|
||||||
pub price_cents: i64,
|
pub price_cents: i64,
|
||||||
pub is_business: bool,
|
pub is_business: bool,
|
||||||
@@ -86,66 +85,134 @@ impl PriceDetail {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The "lowest wins" decision: pick the business price only when it is at or
|
/// The "lowest wins" decision: take the business price only when it is at or
|
||||||
/// below the public price. Pure, so it is unit-tested directly.
|
/// below the personal price. Pure, so it is unit-tested directly.
|
||||||
fn decide(regular_cents: i64, public_cents: i64, business: Option<i64>) -> PricedProduct {
|
fn decide(regular_cents: i64, personal_cents: i64, business: Option<i64>) -> PricedProduct {
|
||||||
match business {
|
match business {
|
||||||
Some(b) if b <= public_cents => PricedProduct {
|
Some(b) if b <= personal_cents => PricedProduct {
|
||||||
price_cents: b,
|
price_cents: b,
|
||||||
regular_cents,
|
regular_cents,
|
||||||
is_business: true,
|
is_business: true,
|
||||||
},
|
},
|
||||||
_ => PricedProduct {
|
_ => PricedProduct {
|
||||||
price_cents: public_cents,
|
price_cents: personal_cents,
|
||||||
regular_cents,
|
regular_cents,
|
||||||
is_business: false,
|
is_business: false,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Is this viewer a business (company) account?
|
|
||||||
fn is_company(user: Option<&users::Model>) -> bool {
|
fn is_company(user: Option<&users::Model>) -> bool {
|
||||||
matches!(user, Some(u) if u.account_type == COMPANY)
|
matches!(user, Some(u) if u.account_type == COMPANY)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything needed to resolve every product's business price for one account,
|
/// `min` of `Option`s, ignoring `None`s.
|
||||||
/// loaded once so listing pages and the cart avoid N+1 queries.
|
fn lowest(values: [Option<i64>; 4]) -> Option<i64> {
|
||||||
struct B2bContext {
|
values.into_iter().flatten().min()
|
||||||
manual: HashMap<i32, i64>,
|
|
||||||
profiles: Vec<discount_profiles::Model>,
|
|
||||||
membership: HashMap<i32, HashSet<i32>>,
|
|
||||||
resolutions: HashMap<i32, i32>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_b2b(ctx: &AppContext, user_id: i32) -> Result<B2bContext> {
|
/// A set of discount profiles plus their product membership, with the logic to
|
||||||
let manual = account_product_prices::Model::map_for_user(&ctx.db, user_id).await?;
|
/// price a product against them.
|
||||||
|
struct LoadedProfiles {
|
||||||
|
profiles: Vec<discount_profiles::Model>,
|
||||||
|
membership: HashMap<i32, HashSet<i32>>,
|
||||||
|
}
|
||||||
|
|
||||||
let assigns = account_discount_profiles::Entity::find()
|
impl LoadedProfiles {
|
||||||
.filter(account_discount_profiles::Column::UserId.eq(user_id))
|
fn empty() -> Self {
|
||||||
.all(&ctx.db)
|
Self {
|
||||||
.await?;
|
profiles: Vec::new(),
|
||||||
let profile_ids: Vec<i32> = assigns.iter().map(|a| a.discount_profile_id).collect();
|
membership: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let (profiles, membership) = if profile_ids.is_empty() {
|
/// The best (lowest) price any covering profile produces off the regular
|
||||||
(Vec::new(), HashMap::new())
|
/// price, or `None` when no profile covers the product.
|
||||||
} else {
|
fn best_price(&self, regular_cents: i64, product_id: i32) -> Option<i64> {
|
||||||
let profiles = discount_profiles::Entity::find()
|
let empty = HashSet::new();
|
||||||
.filter(discount_profiles::Column::Id.is_in(profile_ids.clone()))
|
self.profiles
|
||||||
.all(&ctx.db)
|
.iter()
|
||||||
.await?;
|
.filter(|p| p.covers(product_id, self.membership.get(&p.id).unwrap_or(&empty)))
|
||||||
let rows = discount_profile_products::Entity::find()
|
.map(|p| apply_discount_bp(regular_cents, p.percent_bp))
|
||||||
.filter(discount_profile_products::Column::DiscountProfileId.is_in(profile_ids))
|
.min()
|
||||||
.all(&ctx.db)
|
}
|
||||||
.await?;
|
|
||||||
|
/// 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();
|
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 {
|
for row in rows {
|
||||||
membership
|
membership
|
||||||
.entry(row.discount_profile_id)
|
.entry(row.discount_profile_id)
|
||||||
.or_default()
|
.or_default()
|
||||||
.insert(row.product_id);
|
.insert(row.product_id);
|
||||||
}
|
}
|
||||||
(profiles, membership)
|
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()
|
let resolutions = account_product_resolutions::Entity::find()
|
||||||
.filter(account_product_resolutions::Column::UserId.eq(user_id))
|
.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 {
|
Ok(B2bContext {
|
||||||
manual,
|
manual,
|
||||||
profiles,
|
profiles,
|
||||||
membership,
|
|
||||||
resolutions,
|
resolutions,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve one product's full price breakdown for `b2b` (None = public viewer).
|
/// Everything loaded once per request to price any product for the viewer.
|
||||||
fn detail_for(product: &products::Model, b2b: Option<&B2bContext>) -> PriceDetail {
|
struct PricingCtx {
|
||||||
let regular = product.price_cents;
|
personal: LoadedProfiles,
|
||||||
let public = product.effective_price_cents();
|
business: LoadedProfiles,
|
||||||
let Some(b2b) = b2b else {
|
b2b: Option<B2bContext>,
|
||||||
return PriceDetail::public_only(regular, public);
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let manual = b2b.manual.get(&product.id).copied();
|
/// Resolve the per-company automated price for a product: the single covering
|
||||||
|
/// profile, the admin-resolved winner on a collision, or (unresolved) the
|
||||||
// Which assigned profiles cover this product.
|
/// biggest discount, flagged.
|
||||||
let empty = HashSet::new();
|
fn company_auto(
|
||||||
let covering: Vec<&discount_profiles::Model> = b2b
|
b2b: &B2bContext,
|
||||||
.profiles
|
regular_cents: i64,
|
||||||
.iter()
|
product_id: i32,
|
||||||
.filter(|p| p.covers(product.id, b2b.membership.get(&p.id).unwrap_or(&empty)))
|
) -> (Option<i64>, Option<i32>, bool, Vec<i32>) {
|
||||||
.collect();
|
let covering = b2b.profiles.covering(product_id);
|
||||||
|
let ids: Vec<i32> = covering.iter().map(|p| p.id).collect();
|
||||||
let mut auto_cents = None;
|
if covering.is_empty() {
|
||||||
let mut auto_profile_id = None;
|
return (None, None, false, ids);
|
||||||
let mut collision = false;
|
}
|
||||||
if !covering.is_empty() {
|
let (chosen, collision) = if covering.len() == 1 {
|
||||||
let chosen = if covering.len() == 1 {
|
(covering[0], false)
|
||||||
covering[0]
|
|
||||||
} else {
|
} else {
|
||||||
// Two+ profiles collide: honour the admin's resolution, else fall
|
|
||||||
// back to the biggest discount and flag it for resolving.
|
|
||||||
match b2b
|
match b2b
|
||||||
.resolutions
|
.resolutions
|
||||||
.get(&product.id)
|
.get(&product_id)
|
||||||
.and_then(|rid| covering.iter().find(|p| p.id == *rid).copied())
|
.and_then(|rid| covering.iter().find(|p| p.id == *rid).copied())
|
||||||
{
|
{
|
||||||
Some(resolved) => resolved,
|
Some(resolved) => (resolved, false),
|
||||||
None => {
|
None => (
|
||||||
collision = true;
|
|
||||||
covering
|
covering
|
||||||
.iter()
|
.iter()
|
||||||
.max_by_key(|p| p.percent_bp)
|
.max_by_key(|p| p.percent_bp)
|
||||||
.copied()
|
.copied()
|
||||||
.expect("covering is non-empty")
|
.expect("covering is non-empty"),
|
||||||
}
|
true,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
auto_profile_id = Some(chosen.id);
|
(
|
||||||
auto_cents = Some(apply_discount_bp(regular, chosen.percent_bp));
|
Some(apply_discount_bp(regular_cents, chosen.percent_bp)),
|
||||||
}
|
Some(chosen.id),
|
||||||
|
collision,
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Baseline business discount for all company accounts (set on the discounts
|
fn detail_for(product: &products::Model, pc: &PricingCtx) -> PriceDetail {
|
||||||
// page), alongside the per-company automated and negotiated layers.
|
let regular = product.price_cents;
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
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_global = product.business_sale_price_cents;
|
||||||
let business = [manual, auto_cents, business_global]
|
let business_profile = pc.business.best_price(regular, product.id);
|
||||||
.into_iter()
|
let business_best = lowest([manual, auto_cents, business_global, business_profile]);
|
||||||
.flatten()
|
|
||||||
.min();
|
|
||||||
let priced = decide(regular, public, business);
|
|
||||||
|
|
||||||
|
let priced = decide(regular, personal_effective, business_best);
|
||||||
PriceDetail {
|
PriceDetail {
|
||||||
regular_cents: regular,
|
regular_cents: regular,
|
||||||
public_cents: public,
|
public_cents: personal_effective,
|
||||||
manual_cents: manual,
|
manual_cents: manual,
|
||||||
auto_cents,
|
auto_cents,
|
||||||
auto_profile_id,
|
auto_profile_id,
|
||||||
covering_profile_ids: covering.iter().map(|p| p.id).collect(),
|
covering_profile_ids: covering_ids,
|
||||||
collision,
|
collision,
|
||||||
price_cents: priced.price_cents,
|
price_cents: priced.price_cents,
|
||||||
is_business: priced.is_business,
|
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(
|
pub async fn detail_many(
|
||||||
ctx: &AppContext,
|
ctx: &AppContext,
|
||||||
list: &[products::Model],
|
list: &[products::Model],
|
||||||
user: Option<&users::Model>,
|
user: Option<&users::Model>,
|
||||||
) -> Result<Vec<PriceDetail>> {
|
) -> Result<Vec<PriceDetail>> {
|
||||||
let b2b = if is_company(user) {
|
let pc = load_pricing_ctx(ctx, user).await?;
|
||||||
Some(load_b2b(ctx, user.expect("is_company implies Some").id).await?)
|
Ok(list.iter().map(|p| detail_for(p, &pc)).collect())
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
Ok(list.iter().map(|p| detail_for(p, b2b.as_ref())).collect())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Price one product for `user` (`None` = anonymous/public).
|
/// Price one product for `user` (`None` = anonymous/public).
|
||||||
@@ -256,8 +348,7 @@ pub async fn price_for(
|
|||||||
Ok(detail[0].priced())
|
Ok(detail[0].priced())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Price many products for `user`, batching the per-account lookups to avoid
|
/// Price many products for `user`, batching the per-request lookups.
|
||||||
/// N+1 queries on listing pages and the cart.
|
|
||||||
pub async fn price_many(
|
pub async fn price_many(
|
||||||
ctx: &AppContext,
|
ctx: &AppContext,
|
||||||
list: &[products::Model],
|
list: &[products::Model],
|
||||||
@@ -276,10 +367,9 @@ mod tests {
|
|||||||
use crate::shared::money::apply_discount_bp;
|
use crate::shared::money::apply_discount_bp;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn public_only() {
|
fn personal_only() {
|
||||||
let p = decide(10000, 10000, None);
|
let p = decide(10000, 10000, None);
|
||||||
assert_eq!(p.price_cents, 10000);
|
assert_eq!(p.price_cents, 10000);
|
||||||
assert!(!p.is_reduced());
|
|
||||||
assert!(!p.is_business);
|
assert!(!p.is_business);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,8 +381,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn public_sale_beats_business() {
|
fn personal_beats_business() {
|
||||||
// regular 100, public sale 80, business best 90 -> pay 80, not business.
|
// personal already 80 (e.g. personal profile), business best 90 -> 80.
|
||||||
let p = decide(10000, 8000, Some(9000));
|
let p = decide(10000, 8000, Some(9000));
|
||||||
assert_eq!(p.price_cents, 8000);
|
assert_eq!(p.price_cents, 8000);
|
||||||
assert!(!p.is_business);
|
assert!(!p.is_business);
|
||||||
@@ -307,9 +397,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn discount_bp_rounds_half_up() {
|
fn discount_bp_rounds_half_up() {
|
||||||
assert_eq!(apply_discount_bp(10000, 500), 9500); // 5%
|
assert_eq!(apply_discount_bp(10000, 500), 9500);
|
||||||
assert_eq!(apply_discount_bp(10000, 1500), 8500); // 15%
|
assert_eq!(apply_discount_bp(10000, 1500), 8500);
|
||||||
assert_eq!(apply_discount_bp(999, 500), 949); // 49.95 -> 50 off
|
|
||||||
assert_eq!(apply_discount_bp(10000, 0), 10000);
|
assert_eq!(apply_discount_bp(10000, 0), 10000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user