discount for business and personall in discount page

This commit is contained in:
Priec
2026-06-22 00:04:01 +02:00
parent 1df8d66d5d
commit d2b463135b
10 changed files with 158 additions and 44 deletions

View File

@@ -211,6 +211,9 @@ price = Price
sale-price = Sale price sale-price = Sale price
admin-discounts = Discounts admin-discounts = Discounts
admin-discounts-desc = Set discounted product prices. A discount shows up as a sale in the shop. admin-discounts-desc = Set discounted product prices. A discount shows up as a sale in the shop.
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
on-sale = On sale on-sale = On sale
no-discount = No discount no-discount = No discount
discount = Discount discount = Discount

View File

@@ -211,6 +211,9 @@ price = Cena
sale-price = Zľavnená cena sale-price = Zľavnená cena
admin-discounts = Zľavy admin-discounts = Zľavy
admin-discounts-desc = Nastavte zľavnené ceny produktov. Zľava sa v obchode zobrazí ako akcia. admin-discounts-desc = Nastavte zľavnené ceny produktov. Zľava sa v obchode zobrazí ako akcia.
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é
on-sale = V akcii on-sale = V akcii
no-discount = Bez zľavy no-discount = Bez zľavy
discount = Zľava discount = Zľava

View File

@@ -6,11 +6,16 @@
{% block content %} {% block content %}
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h1> <div>
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/discounts", size="px-3 py-2 text-sm") }} <h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h1>
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">
{% if audience == "business" %}{{ t(key="audience-business", lang=lang | default(value='sk')) }}{% else %}{{ t(key="audience-personal", lang=lang | default(value='sk')) }}{% endif %}
</p>
</div>
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/discounts?audience=" ~ audience, size="px-3 py-2 text-sm") }}
</div> </div>
<form method="post" action="/admin/catalog/discounts/{{ product.id }}" <form method="post" action="/admin/catalog/discounts/{{ product.id }}?audience={{ audience }}"
x-data="{ x-data="{
mode: '{{ mode }}', mode: '{{ mode }}',
fixed: '{{ fixed }}', fixed: '{{ fixed }}',
@@ -89,8 +94,8 @@
<div class="flex flex-wrap gap-3 pt-2"> <div class="flex flex-wrap gap-3 pt-2">
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit") }} {{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit") }}
{% if product.on_sale %} {% if has_discount %}
{{ ui::button(variant="outline-danger", label=t(key="remove-discount", lang=lang | default(value='sk')), type="submit", attrs='formaction="/admin/catalog/discounts/' ~ product.id ~ '/remove"') }} {{ ui::button(variant="outline-danger", label=t(key="remove-discount", lang=lang | default(value='sk')), type="submit", attrs='formaction="/admin/catalog/discounts/' ~ product.id ~ '/remove?audience=' ~ audience ~ '"') }}
{% endif %} {% endif %}
</div> </div>
</form> </form>

View File

@@ -5,14 +5,29 @@
{% block crumb %}{{ t(key="admin-discounts", lang=lang | default(value='sk')) }}{% endblock crumb %} {% block crumb %}{{ t(key="admin-discounts", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %} {% block content %}
{% set business = audience == "business" %}
<div class="flex flex-wrap items-end justify-between gap-3"> <div class="flex flex-wrap items-end justify-between gap-3">
<div> <div>
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-discounts", lang=lang | default(value='sk')) }}</h1> <h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-discounts", lang=lang | default(value='sk')) }}</h1>
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-discounts-desc", lang=lang | default(value='sk')) }}</p> <p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">
{% if business %}{{ t(key="business-discount-desc", lang=lang | default(value='sk')) }}{% else %}{{ t(key="admin-discounts-desc", lang=lang | default(value='sk')) }}{% endif %}
</p>
</div> </div>
</div> </div>
<div class="mt-6 {{ ui::table_wrap_cls() }}"> <!-- audience tabs -->
<div class="mt-4 inline-flex rounded-radius border border-outline p-1 dark:border-outline-dark">
<a href="/admin/catalog/discounts?audience=personal"
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if not business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
{{ t(key="audience-personal", lang=lang | default(value='sk')) }}
</a>
<a href="/admin/catalog/discounts?audience=business"
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
{{ t(key="audience-business", lang=lang | default(value='sk')) }}
</a>
</div>
<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() }}">
<thead class="{{ ui::thead_cls() }}"> <thead class="{{ ui::thead_cls() }}">
@@ -26,21 +41,23 @@
</thead> </thead>
<tbody class="{{ ui::tbody_cls() }}"> <tbody class="{{ ui::tbody_cls() }}">
{% for product in products %} {% for product in products %}
{% if business %}{% set on_sale = product.business_on_sale %}{% set sale_price = product.business_sale_price %}{% set pct = product.business_percent_off %}
{% else %}{% set on_sale = product.on_sale %}{% set sale_price = product.sale_price %}{% set pct = product.percent_off %}{% endif %}
<tr class="{{ ui::row_cls() }}"> <tr class="{{ ui::row_cls() }}">
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</div> <div class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</div>
</td> </td>
<td class="px-4 py-3 tabular-nums">{{ product.regular_price }} {{ product.currency }}</td> <td class="px-4 py-3 tabular-nums">{{ product.regular_price }} {{ product.currency }}</td>
<td class="px-4 py-3 tabular-nums"> <td class="px-4 py-3 tabular-nums">
{% if product.on_sale %} {% if on_sale %}
<span class="font-medium text-danger">{{ product.sale_price }} {{ product.currency }}</span> <span class="font-medium text-danger">{{ sale_price }} {{ product.currency }}</span>
<span class="ml-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60">({{ product.percent_off }}%)</span> <span class="ml-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60">({{ pct }}%)</span>
{% else %} {% else %}
<span class="text-on-surface/40 dark:text-on-surface-dark/40"></span> <span class="text-on-surface/40 dark:text-on-surface-dark/40"></span>
{% endif %} {% endif %}
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
{% if product.on_sale %} {% if on_sale %}
{{ ui::badge(label=t(key="on-sale", lang=lang | default(value='sk')), variant="danger") }} {{ ui::badge(label=t(key="on-sale", lang=lang | default(value='sk')), variant="danger") }}
{% else %} {% else %}
{{ ui::badge(label=t(key="no-discount", lang=lang | default(value='sk')), variant="neutral") }} {{ ui::badge(label=t(key="no-discount", lang=lang | default(value='sk')), variant="neutral") }}
@@ -48,9 +65,9 @@
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex flex-wrap justify-end gap-2"> <div class="flex flex-wrap justify-end gap-2">
{{ ui::button(variant="outline-secondary", label=t(key="set-discount", lang=lang | default(value='sk')), href="/admin/catalog/discounts/" ~ product.id ~ "/edit", size="px-3 py-1.5 text-xs") }} {{ ui::button(variant="outline-secondary", label=t(key="set-discount", lang=lang | default(value='sk')), href="/admin/catalog/discounts/" ~ product.id ~ "/edit?audience=" ~ audience, size="px-3 py-1.5 text-xs") }}
{% if product.on_sale %} {% if on_sale %}
<form method="post" action="/admin/catalog/discounts/{{ product.id }}/remove"> <form method="post" action="/admin/catalog/discounts/{{ product.id }}/remove?audience={{ audience }}">
{{ ui::csrf_field() }} {{ ui::csrf_field() }}
{{ ui::button(variant="outline-danger", label=t(key="remove-discount", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }} {{ ui::button(variant="outline-danger", label=t(key="remove-discount", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
</form> </form>

View File

@@ -38,6 +38,7 @@ mod m20260620_000001_add_totp_to_users;
mod m20260621_000001_add_sale_price_to_products; 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;
pub struct Migrator; pub struct Migrator;
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -80,6 +81,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260621_000001_add_sale_price_to_products::Migration), Box::new(m20260621_000001_add_sale_price_to_products::Migration),
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),
// inject-above (do not remove this comment) // inject-above (do not remove this comment)
] ]
} }

View File

@@ -0,0 +1,20 @@
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> {
// Optional per-product discounted price (minor units) shown to ALL
// business (company) accounts as a baseline, computed off the regular
// price like the personal sale. Per-company profiles/negotiated prices
// still layer on top (lowest price wins).
add_column(m, "products", "business_sale_price_cents", ColType::BigIntegerNull).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
remove_column(m, "products", "business_sale_price_cents").await
}
}

View File

@@ -1,9 +1,15 @@
//! Admin management of per-product discounts. //! Admin management of per-product discounts, in a place of their own rather
//! than on the product editor.
//! //!
//! Discounts live on the product (`sale_price_cents`) but are set here, in a //! Two audiences, switched by an `?audience=` tab:
//! place of their own, rather than on the product editor: an admin picks a //! - **personal** (default): the public sale price (`products.sale_price_cents`)
//! product, enters a discounted price, and the storefront then shows it on sale. //! everyone sees.
//! Editing a product never touches its discount, and vice versa. //! - **business**: a baseline discount for all company accounts
//! (`products.business_sale_price_cents`). Per-company profiles/negotiated
//! prices still layer on top (lowest price wins). Both are computed off the
//! regular price.
use std::collections::HashMap;
use axum_extra::extract::cookie::CookieJar; use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*; use loco_rs::prelude::*;
@@ -20,6 +26,8 @@ use crate::{
}, },
}; };
const BUSINESS: &str = "business";
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct DiscountForm { struct DiscountForm {
/// "fixed" (enter the new price) or "percent" (enter % off). Defaults to /// "fixed" (enter the new price) or "percent" (enter % off). Defaults to
@@ -29,8 +37,35 @@ struct DiscountForm {
percent: Option<String>, percent: Option<String>,
} }
/// Which discount column an audience tab operates on.
fn read_audience(params: &HashMap<String, String>) -> &'static str {
match params.get("audience").map(String::as_str) {
Some(BUSINESS) => BUSINESS,
_ => "personal",
}
}
fn current_value(product: &products::Model, audience: &str) -> Option<i64> {
if audience == BUSINESS {
product.business_sale_price_cents
} else {
product.sale_price_cents
}
}
fn set_value(active: &mut products::ActiveModel, audience: &str, value: Option<i64>) {
if audience == BUSINESS {
active.business_sale_price_cents = Set(value);
} else {
active.sale_price_cents = Set(value);
}
}
fn list_redirect(audience: &str) -> Result<Response> {
format::redirect(&format!("/admin/catalog/discounts?audience={audience}"))
}
/// Resolve a percentage off the regular price into a fixed sale price in cents. /// Resolve a percentage off the regular price into a fixed sale price in cents.
/// Rounds the discount amount to the nearest cent.
fn percent_to_sale_cents(regular_cents: i64, percent: f64) -> i64 { fn percent_to_sale_cents(regular_cents: i64, percent: f64) -> i64 {
let off = (regular_cents as f64 * percent / 100.0).round() as i64; let off = (regular_cents as f64 * percent / 100.0).round() as i64;
regular_cents - off regular_cents - off
@@ -43,8 +78,7 @@ async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
.ok_or_else(|| Error::NotFound) .ok_or_else(|| Error::NotFound)
} }
/// Percent off the regular price, rounded to a whole number. `0` when there is /// Percent off the regular price, rounded to a whole number.
/// no positive regular price to discount from.
fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 { fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 {
if regular_cents <= 0 { if regular_cents <= 0 {
return 0; return 0;
@@ -53,7 +87,8 @@ fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 {
off.round() as i64 off.round() as i64
} }
/// Row shape for the discounts list. /// Row shape for the discounts list, carrying both audiences' values so the
/// template can show whichever tab is active.
fn list_row(product: &products::Model) -> serde_json::Value { fn list_row(product: &products::Model) -> serde_json::Value {
json!({ json!({
"id": product.id, "id": product.id,
@@ -64,6 +99,11 @@ fn list_row(product: &products::Model) -> serde_json::Value {
"on_sale": product.on_sale(), "on_sale": product.on_sale(),
"sale_price": product.sale_price_cents.map(format_price), "sale_price": product.sale_price_cents.map(format_price),
"percent_off": product.sale_price_cents.map(|sale| percent_off(product.price_cents, sale)), "percent_off": product.sale_price_cents.map(|sale| percent_off(product.price_cents, sale)),
"business_on_sale": product.business_on_sale(),
"business_sale_price": product.business_sale_price_cents.map(format_price),
"business_percent_off": product
.business_sale_price_cents
.map(|sale| percent_off(product.price_cents, sale)),
}) })
} }
@@ -72,9 +112,11 @@ async fn index(
auth: auth::JWT, auth: auth::JWT,
jar: CookieJar, jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
guard::current_admin(auth, &ctx).await?; guard::current_admin(auth, &ctx).await?;
let audience = read_audience(&params);
let list = products::Entity::find() let list = products::Entity::find()
.order_by_asc(products::Column::Name) .order_by_asc(products::Column::Name)
.all(&ctx.db) .all(&ctx.db)
@@ -83,7 +125,7 @@ async fn index(
format::view( format::view(
&v, &v,
"admin/catalog/discounts.html", "admin/catalog/discounts.html",
json!({ "products": rows, "lang": current_lang(&jar) }), json!({ "products": rows, "audience": audience, "lang": current_lang(&jar) }),
) )
} }
@@ -96,11 +138,11 @@ struct FormPrefill {
percent: String, percent: String,
} }
/// Render the single-product discount form, optionally with a validation error.
fn render_form( fn render_form(
v: &TeraView, v: &TeraView,
jar: &CookieJar, jar: &CookieJar,
product: &products::Model, product: &products::Model,
audience: &str,
prefill: &FormPrefill, prefill: &FormPrefill,
error: Option<&str>, error: Option<&str>,
) -> Result<Response> { ) -> Result<Response> {
@@ -115,9 +157,9 @@ fn render_form(
"currency": product.currency, "currency": product.currency,
"regular_price": format_price(product.price_cents), "regular_price": format_price(product.price_cents),
"regular_cents": product.price_cents, "regular_cents": product.price_cents,
"on_sale": product.on_sale(),
"sale_price": product.sale_price_cents.map(format_price),
}, },
"audience": audience,
"has_discount": current_value(product, audience).is_some(),
"mode": mode, "mode": mode,
"fixed": prefill.fixed, "fixed": prefill.fixed,
"percent": prefill.percent, "percent": prefill.percent,
@@ -133,17 +175,21 @@ async fn edit(
jar: CookieJar, jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>, Path(id): Path<i32>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
guard::current_admin(auth, &ctx).await?; guard::current_admin(auth, &ctx).await?;
let audience = read_audience(&params);
let product = product_by_id(&ctx, id).await?; let product = product_by_id(&ctx, id).await?;
// Re-editing always opens in fixed mode showing the current sale price. // Re-editing always opens in fixed mode showing the current price.
let prefill = FormPrefill { let prefill = FormPrefill {
mode: "fixed".to_string(), mode: "fixed".to_string(),
fixed: product.sale_price_cents.map(format_price).unwrap_or_default(), fixed: current_value(&product, audience)
.map(format_price)
.unwrap_or_default(),
percent: String::new(), percent: String::new(),
}; };
render_form(&v, &jar, &product, &prefill, None) render_form(&v, &jar, &product, audience, &prefill, None)
} }
#[debug_handler] #[debug_handler]
@@ -152,10 +198,12 @@ async fn update(
jar: CookieJar, jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>, Path(id): Path<i32>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
Form(form): Form<DiscountForm>, Form(form): Form<DiscountForm>,
) -> Result<Response> { ) -> Result<Response> {
guard::current_admin(auth, &ctx).await?; guard::current_admin(auth, &ctx).await?;
let audience = read_audience(&params);
let product = product_by_id(&ctx, id).await?; let product = product_by_id(&ctx, id).await?;
let mode = match form.mode.as_deref() { let mode = match form.mode.as_deref() {
@@ -165,20 +213,18 @@ async fn update(
let fixed = form.sale_price.unwrap_or_default().trim().to_string(); let fixed = form.sale_price.unwrap_or_default().trim().to_string();
let percent = form.percent.unwrap_or_default().trim().to_string(); let percent = form.percent.unwrap_or_default().trim().to_string();
// Whatever the mode, both raw inputs are echoed back on error so neither tab
// loses what was typed.
let prefill = FormPrefill { let prefill = FormPrefill {
mode: mode.to_string(), mode: mode.to_string(),
fixed: fixed.clone(), fixed: fixed.clone(),
percent: percent.clone(), percent: percent.clone(),
}; };
let render_err = |key: &str| render_form(&v, &jar, &product, &prefill, Some(key)); let render_err = |key: &str| render_form(&v, &jar, &product, audience, &prefill, Some(key));
// Resolve the entered discount into a fixed sale price in cents. An empty // Resolve the entered discount into a fixed sale price in cents. An empty
// input in the active mode clears the discount (same as the Remove action). // input in the active mode clears the discount (same as the Remove action).
let sale_cents = if mode == "percent" { let sale_cents = if mode == "percent" {
if percent.is_empty() { if percent.is_empty() {
return clear_discount(&ctx, product).await; return clear_discount(&ctx, product, audience).await;
} }
let pct = match parse_percent(&percent) { let pct = match parse_percent(&percent) {
Some(pct) => pct, Some(pct) => pct,
@@ -190,7 +236,7 @@ async fn update(
percent_to_sale_cents(product.price_cents, pct) percent_to_sale_cents(product.price_cents, pct)
} else { } else {
if fixed.is_empty() { if fixed.is_empty() {
return clear_discount(&ctx, product).await; return clear_discount(&ctx, product, audience).await;
} }
match parse_price_to_cents(&fixed) { match parse_price_to_cents(&fixed) {
Ok(cents) => cents, Ok(cents) => cents,
@@ -198,8 +244,6 @@ async fn update(
} }
}; };
// A discount must be a positive price strictly below the regular price —
// otherwise it isn't a discount.
if sale_cents <= 0 { if sale_cents <= 0 {
return render_err("discount-must-be-positive"); return render_err("discount-must-be-positive");
} }
@@ -208,27 +252,33 @@ async fn update(
} }
let mut active = product.into_active_model(); let mut active = product.into_active_model();
active.sale_price_cents = Set(Some(sale_cents)); set_value(&mut active, audience, Some(sale_cents));
active.update(&ctx.db).await?; active.update(&ctx.db).await?;
format::redirect("/admin/catalog/discounts") list_redirect(audience)
} }
async fn clear_discount(ctx: &AppContext, product: products::Model) -> Result<Response> { async fn clear_discount(
ctx: &AppContext,
product: products::Model,
audience: &str,
) -> Result<Response> {
let mut active = product.into_active_model(); let mut active = product.into_active_model();
active.sale_price_cents = Set(None); set_value(&mut active, audience, None);
active.update(&ctx.db).await?; active.update(&ctx.db).await?;
format::redirect("/admin/catalog/discounts") list_redirect(audience)
} }
#[debug_handler] #[debug_handler]
async fn remove( async fn remove(
auth: auth::JWT, auth: auth::JWT,
Path(id): Path<i32>, Path(id): Path<i32>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
guard::current_admin(auth, &ctx).await?; guard::current_admin(auth, &ctx).await?;
let audience = read_audience(&params);
let product = product_by_id(&ctx, id).await?; let product = product_by_id(&ctx, id).await?;
clear_discount(&ctx, product).await clear_discount(&ctx, product, audience).await
} }
pub fn routes() -> Routes { pub fn routes() -> Routes {

View File

@@ -17,6 +17,7 @@ pub struct Model {
pub description: Option<String>, pub description: Option<String>,
pub price_cents: i64, pub price_cents: i64,
pub sale_price_cents: Option<i64>, pub sale_price_cents: Option<i64>,
pub business_sale_price_cents: Option<i64>,
pub currency: String, pub currency: String,
pub sku: Option<String>, pub sku: Option<String>,
pub stock: i32, pub stock: i32,

View File

@@ -37,6 +37,13 @@ impl Model {
self.price_cents self.price_cents
} }
} }
/// Whether a baseline business discount (for all company accounts) is set and
/// actually below the regular price.
#[must_use]
pub fn business_on_sale(&self) -> bool {
matches!(self.business_sale_price_cents, Some(sale) if sale < self.price_cents)
}
} }
// implement your write-oriented logic here // implement your write-oriented logic here

View File

@@ -210,7 +210,13 @@ fn detail_for(product: &products::Model, b2b: Option<&B2bContext>) -> PriceDetai
auto_cents = Some(apply_discount_bp(regular, chosen.percent_bp)); auto_cents = Some(apply_discount_bp(regular, chosen.percent_bp));
} }
let business = [manual, auto_cents].into_iter().flatten().min(); // Baseline business discount for all company accounts (set on the discounts
// page), alongside the per-company automated and negotiated layers.
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 priced = decide(regular, public, business);
PriceDetail { PriceDetail {