diff --git a/assets/i18n/en/main.ftl b/assets/i18n/en/main.ftl
index 9b3e1f6..71c9f2f 100644
--- a/assets/i18n/en/main.ftl
+++ b/assets/i18n/en/main.ftl
@@ -226,6 +226,17 @@ discount-invalid = Invalid price.
discount-must-be-positive = The sale price must be greater than zero.
discount-below-regular = The sale price must be below the regular price.
discount-percent-range = The percentage must be between 0 and 100.
+admin-customers = Business accounts
+admin-customers-desc = Manage negotiated prices for business (B2B) accounts.
+admin-no-customers = No business accounts yet.
+email = Email
+back = Back
+negotiated-prices = Negotiated prices
+negotiated-prices-hint = Set a price for a specific product for this business account. The customer always pays the lower of the public and negotiated price.
+manage-prices = Manage prices
+public-price = Public price
+negotiated-price = Negotiated price
+effective-price = Effective price
stock = Stock
sku = SKU
currency = Currency
diff --git a/assets/i18n/sk/main.ftl b/assets/i18n/sk/main.ftl
index fb64273..3450702 100644
--- a/assets/i18n/sk/main.ftl
+++ b/assets/i18n/sk/main.ftl
@@ -226,6 +226,17 @@ discount-invalid = Neplatná cena.
discount-must-be-positive = Zľavnená cena musí byť väčšia ako nula.
discount-below-regular = Zľavnená cena musí byť nižšia ako bežná cena.
discount-percent-range = Percento musí byť medzi 0 a 100.
+admin-customers = Firemné účty
+admin-customers-desc = Spravujte dohodnuté ceny pre firemné (B2B) účty.
+admin-no-customers = Zatiaľ žiadne firemné účty.
+email = E-mail
+back = Späť
+negotiated-prices = Dohodnuté ceny
+negotiated-prices-hint = Nastavte cenu pre konkrétny produkt pre tento firemný účet. Zákazník vždy zaplatí najnižšiu z verejnej a dohodnutej ceny.
+manage-prices = Spravovať ceny
+public-price = Verejná cena
+negotiated-price = Dohodnutá cena
+effective-price = Výsledná cena
stock = Sklad
sku = Kód (SKU)
currency = Mena
diff --git a/assets/views/admin/base.html b/assets/views/admin/base.html
index c0a9885..0fa56a6 100644
--- a/assets/views/admin/base.html
+++ b/assets/views/admin/base.html
@@ -90,6 +90,10 @@
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
{{ t(key="admin-orders", lang=lang | default(value='sk')) }}
+
+ {{ t(key="admin-customers", lang=lang | default(value='sk')) }}
+
{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}
diff --git a/assets/views/admin/customers/index.html b/assets/views/admin/customers/index.html
new file mode 100644
index 0000000..92fc190
--- /dev/null
+++ b/assets/views/admin/customers/index.html
@@ -0,0 +1,45 @@
+{% extends "admin/base.html" %}
+{% import "macros/ui.html" as ui %}
+
+{% block title %}{{ t(key="admin-customers", lang=lang | default(value='sk')) }}{% endblock title %}
+{% block crumb %}{{ t(key="admin-customers", lang=lang | default(value='sk')) }}{% endblock crumb %}
+
+{% block content %}
+
+
+
{{ t(key="admin-customers", lang=lang | default(value='sk')) }}
+
{{ t(key="admin-customers-desc", lang=lang | default(value='sk')) }}
+
+
+
+
+ {% if customers | length > 0 %}
+
+
+
+ {{ ui::th(label=t(key="name", lang=lang | default(value='sk'))) }}
+ {{ ui::th(label=t(key="email", lang=lang | default(value='sk'))) }}
+ {{ ui::th(label=t(key="negotiated-prices", lang=lang | default(value='sk'))) }}
+ {{ ui::th(label=t(key="actions", lang=lang | default(value='sk')), align="text-right") }}
+
+
+
+ {% for customer in customers %}
+
+ | {{ customer.name }} |
+ {{ customer.email }} |
+ {{ customer.negotiated_count }} |
+
+ {{ ui::button(variant="outline-secondary", label=t(key="manage-prices", lang=lang | default(value='sk')), href="/admin/customers/" ~ customer.id, size="px-3 py-1.5 text-xs") }}
+ |
+
+ {% endfor %}
+
+
+ {% else %}
+
+
{{ t(key="admin-no-customers", lang=lang | default(value='sk')) }}
+
+ {% endif %}
+
+{% endblock content %}
diff --git a/assets/views/admin/customers/show.html b/assets/views/admin/customers/show.html
new file mode 100644
index 0000000..455c4f8
--- /dev/null
+++ b/assets/views/admin/customers/show.html
@@ -0,0 +1,68 @@
+{% extends "admin/base.html" %}
+{% import "macros/ui.html" as ui %}
+
+{% block title %}{{ customer.name }}{% endblock title %}
+{% block crumb %}{{ t(key="admin-customers", lang=lang | default(value='sk')) }}{% endblock crumb %}
+
+{% block content %}
+
+
+
{{ customer.name }}
+
{{ customer.email }}
+
+ {{ ui::button(variant="outline-secondary", label=t(key="back", lang=lang | default(value='sk')), href="/admin/customers", size="px-3 py-2 text-sm") }}
+
+
+{% if error %}
+{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}
+{% endif %}
+
+{{ t(key="negotiated-prices-hint", lang=lang | default(value='sk')) }}
+
+
+ {% if products | length > 0 %}
+
+
+
+ {{ ui::th(label=t(key="product", lang=lang | default(value='sk'))) }}
+ {{ ui::th(label=t(key="public-price", lang=lang | default(value='sk'))) }}
+ {{ ui::th(label=t(key="negotiated-price", lang=lang | default(value='sk'))) }}
+ {{ ui::th(label=t(key="effective-price", lang=lang | default(value='sk')), align="text-right") }}
+
+
+
+ {% for product in products %}
+
+ | {{ product.name }} |
+
+ {% if product.on_public_sale %}
+ {{ product.public_price }} {{ product.currency }}
+ {{ product.regular_price }}
+ {% else %}
+ {{ product.public_price }} {{ product.currency }}
+ {% endif %}
+ |
+
+
+ |
+
+ {{ product.effective_price }} {{ product.currency }}
+ |
+
+ {% endfor %}
+
+
+ {% else %}
+
+
{{ t(key="admin-no-products", lang=lang | default(value='sk')) }}
+
+ {% endif %}
+
+{% endblock content %}
diff --git a/assets/views/shop/_cart_body.html b/assets/views/shop/_cart_body.html
index d04dd01..c56e45c 100644
--- a/assets/views/shop/_cart_body.html
+++ b/assets/views/shop/_cart_body.html
@@ -20,7 +20,14 @@
{{ item.name }}
|
- {{ item.price }} {{ item.currency }} |
+
+ {% if item.on_sale %}
+ {{ item.price }} {{ item.currency }}
+ {{ item.regular_price }}
+ {% else %}
+ {{ item.price }} {{ item.currency }}
+ {% endif %}
+ |
{# Changing the quantity posts via htmx (custom `cartchange` event) and
swaps only #cart-body. Dropping to 0 asks for confirmation first,
diff --git a/migration/src/lib.rs b/migration/src/lib.rs
index 26134e1..827b082 100644
--- a/migration/src/lib.rs
+++ b/migration/src/lib.rs
@@ -36,6 +36,7 @@ mod m20260618_000003_account_type;
mod m20260618_000004_account_ownership;
mod m20260620_000001_add_totp_to_users;
mod m20260621_000001_add_sale_price_to_products;
+mod m20260621_000002_account_product_prices;
pub struct Migrator;
#[async_trait::async_trait]
@@ -76,6 +77,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260618_000004_account_ownership::Migration),
Box::new(m20260620_000001_add_totp_to_users::Migration),
Box::new(m20260621_000001_add_sale_price_to_products::Migration),
+ Box::new(m20260621_000002_account_product_prices::Migration),
// inject-above (do not remove this comment)
]
}
diff --git a/migration/src/m20260621_000002_account_product_prices.rs b/migration/src/m20260621_000002_account_product_prices.rs
new file mode 100644
index 0000000..1e71dfc
--- /dev/null
+++ b/migration/src/m20260621_000002_account_product_prices.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> {
+ // A manually negotiated price (in minor units) for one product, for one
+ // business account — the "personal agreement" layer. `user`/`product`
+ // add the user_id/product_id FKs; the unique index below keeps it to one
+ // row per (account, product).
+ create_table(
+ m,
+ "account_product_prices",
+ &[
+ ("id", ColType::PkAuto),
+ ("price_cents", ColType::BigInteger),
+ ],
+ &[("user", ""), ("product", "")],
+ )
+ .await?;
+
+ m.create_index(
+ Index::create()
+ .name("idx_account_product_prices_user_product_unique")
+ .table(Alias::new("account_product_prices"))
+ .col(Alias::new("user_id"))
+ .col(Alias::new("product_id"))
+ .unique()
+ .to_owned(),
+ )
+ .await
+ }
+
+ async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
+ drop_table(m, "account_product_prices").await
+ }
+}
diff --git a/src/app.rs b/src/app.rs
index 36dff74..223984a 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -17,8 +17,9 @@ use std::{path::Path, sync::Arc};
#[allow(unused_imports)]
use crate::{
controllers::{
- account, admin_categories, admin_dashboard, admin_discounts, admin_form, admin_orders,
- admin_products, admin_shipping, auth, auth_pages, cart, checkout, home, i18n, media, oauth2,
+ account, admin_categories, admin_customers, admin_dashboard, admin_discounts, admin_form,
+ admin_orders, admin_products, admin_shipping, auth, auth_pages, cart, checkout, home, i18n,
+ media, oauth2,
shop,
},
initializers,
@@ -107,6 +108,7 @@ impl Hooks for App {
.add_route(admin_discounts::routes())
.add_route(admin_categories::routes())
.add_route(admin_orders::routes())
+ .add_route(admin_customers::routes())
.add_route(admin_shipping::routes())
}
diff --git a/src/controllers/admin_customers.rs b/src/controllers/admin_customers.rs
new file mode 100644
index 0000000..0b7d78e
--- /dev/null
+++ b/src/controllers/admin_customers.rs
@@ -0,0 +1,175 @@
+//! Admin management of business (company) accounts and their negotiated prices.
+//!
+//! Phase 1: list company accounts and, per account, set/clear a manually
+//! negotiated price per product ("personal agreement"). The effective price the
+//! business pays is always resolved by [`crate::shared::pricing`] (lowest of the
+//! public price and the negotiated price), shown here for reference.
+
+use std::collections::HashMap;
+
+use axum_extra::extract::cookie::CookieJar;
+use loco_rs::prelude::*;
+use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder};
+use serde::Deserialize;
+use serde_json::json;
+
+use crate::{
+ controllers::i18n::current_lang,
+ models::{account_product_prices, products, _entities::users},
+ shared::{
+ guard,
+ money::{format_price, parse_price_to_cents},
+ pricing,
+ },
+};
+
+const COMPANY: &str = "company";
+
+#[derive(Debug, Deserialize)]
+struct PriceForm {
+ price: String,
+}
+
+async fn company_by_id(ctx: &AppContext, id: i32) -> Result {
+ let user = users::Entity::find_by_id(id)
+ .one(&ctx.db)
+ .await?
+ .ok_or_else(|| Error::NotFound)?;
+ // Negotiated pricing only applies to company accounts.
+ if user.account_type != COMPANY {
+ return Err(Error::NotFound);
+ }
+ Ok(user)
+}
+
+#[debug_handler]
+async fn index(
+ auth: auth::JWT,
+ jar: CookieJar,
+ ViewEngine(v): ViewEngine,
+ State(ctx): State,
+) -> Result {
+ guard::current_admin(auth, &ctx).await?;
+ let companies = users::Entity::find()
+ .filter(users::Column::AccountType.eq(COMPANY))
+ .order_by_asc(users::Column::Name)
+ .all(&ctx.db)
+ .await?;
+
+ let mut rows = Vec::with_capacity(companies.len());
+ for company in &companies {
+ let negotiated = account_product_prices::Entity::find()
+ .filter(account_product_prices::Column::UserId.eq(company.id))
+ .count(&ctx.db)
+ .await?;
+ rows.push(json!({
+ "id": company.id,
+ "name": company.name,
+ "email": company.email,
+ "negotiated_count": negotiated,
+ }));
+ }
+
+ format::view(
+ &v,
+ "admin/customers/index.html",
+ json!({ "customers": rows, "lang": current_lang(&jar) }),
+ )
+}
+
+#[debug_handler]
+async fn show(
+ auth: auth::JWT,
+ jar: CookieJar,
+ ViewEngine(v): ViewEngine,
+ Path(id): Path,
+ Query(params): Query>,
+ State(ctx): State,
+) -> Result {
+ guard::current_admin(auth, &ctx).await?;
+ let company = company_by_id(&ctx, id).await?;
+
+ let list = products::Entity::find()
+ .order_by_asc(products::Column::Name)
+ .all(&ctx.db)
+ .await?;
+ let priced = pricing::price_many(&ctx, &list, Some(&company)).await?;
+ let manual = account_product_prices::Model::map_for_user(&ctx.db, company.id).await?;
+
+ let rows: Vec = list
+ .iter()
+ .zip(priced.iter())
+ .map(|(product, priced)| {
+ json!({
+ "product_id": product.id,
+ "name": product.name,
+ "currency": product.currency,
+ "regular_price": format_price(product.price_cents),
+ "public_price": format_price(product.effective_price_cents()),
+ "on_public_sale": product.on_sale(),
+ "manual_price": manual.get(&product.id).copied().map(format_price),
+ "effective_price": format_price(priced.price_cents),
+ "is_business": priced.is_business,
+ })
+ })
+ .collect();
+
+ format::view(
+ &v,
+ "admin/customers/show.html",
+ json!({
+ "customer": { "id": company.id, "name": company.name, "email": company.email },
+ "products": rows,
+ "error": params.get("error"),
+ "lang": current_lang(&jar),
+ }),
+ )
+}
+
+#[debug_handler]
+async fn set_price(
+ auth: auth::JWT,
+ Path((id, product_id)): Path<(i32, i32)>,
+ State(ctx): State,
+ Form(form): Form,
+) -> Result {
+ guard::current_admin(auth, &ctx).await?;
+ let company = company_by_id(&ctx, id).await?;
+
+ let entered = form.price.trim().to_string();
+ // An empty value clears the negotiated price (same as the Remove action).
+ if entered.is_empty() {
+ account_product_prices::Model::clear(&ctx.db, company.id, product_id).await?;
+ return format::redirect(&format!("/admin/customers/{id}"));
+ }
+
+ let cents = match parse_price_to_cents(&entered) {
+ Ok(cents) if cents > 0 => cents,
+ _ => return format::redirect(&format!("/admin/customers/{id}?error=discount-must-be-positive")),
+ };
+ account_product_prices::Model::upsert(&ctx.db, company.id, product_id, cents).await?;
+ format::redirect(&format!("/admin/customers/{id}"))
+}
+
+#[debug_handler]
+async fn remove_price(
+ auth: auth::JWT,
+ Path((id, product_id)): Path<(i32, i32)>,
+ State(ctx): State,
+) -> Result {
+ guard::current_admin(auth, &ctx).await?;
+ let company = company_by_id(&ctx, id).await?;
+ account_product_prices::Model::clear(&ctx.db, company.id, product_id).await?;
+ format::redirect(&format!("/admin/customers/{id}"))
+}
+
+pub fn routes() -> Routes {
+ Routes::new()
+ .add("/admin/customers", get(index))
+ .add("/admin/customers/{id}", get(show))
+ .add("/admin/customers/{id}/prices/{product_id}", post(set_price))
+ .add(
+ "/admin/customers/{id}/prices/{product_id}/remove",
+ post(remove_price),
+ )
+}
diff --git a/src/controllers/admin_products.rs b/src/controllers/admin_products.rs
index 860bff8..618001f 100644
--- a/src/controllers/admin_products.rs
+++ b/src/controllers/admin_products.rs
@@ -18,6 +18,7 @@ use crate::{
shared::{
guard,
money::parse_price_to_cents,
+ pricing,
slug::{slugify, unique_slug},
},
models::{categories, product_images, products},
@@ -129,7 +130,8 @@ async fn index(
.map(|c| c.name),
None => None,
};
- rows.push(view::product_card(&product, image, category_name));
+ let priced = pricing::price_for(&ctx, &product, None).await?;
+ rows.push(view::product_card(&product, &priced, image, category_name));
}
format::view(
&v,
diff --git a/src/controllers/cart.rs b/src/controllers/cart.rs
index c8c62e7..909aa2f 100644
--- a/src/controllers/cart.rs
+++ b/src/controllers/cart.rs
@@ -1,4 +1,4 @@
-use crate::{controllers::i18n::current_lang, shared::{guard, money::format_price}, models::products};
+use crate::{controllers::i18n::current_lang, shared::{guard, money::format_price, pricing}, models::products};
use axum::{
http::{HeaderMap, StatusCode},
response::Redirect,
@@ -189,10 +189,10 @@ pub(crate) async fn resolve_cart(
ctx: &AppContext,
jar: &CookieJar,
) -> Result<(Vec, Vec<(i32, i32)>, i64)> {
- let mut lines = Vec::new();
- let mut valid = Vec::new();
- let mut total: i64 = 0;
-
+ // Resolve the cart entries to in-stock products first, then price them all
+ // for the current viewer in one batch (the price depends on who's logged in).
+ let user = guard::current_user(ctx, jar).await;
+ let mut items: Vec<(products::Model, i32)> = Vec::new();
for (id, qty) in parse_cart(jar) {
let Some(product) = published_product(ctx, id).await? else {
continue;
@@ -201,15 +201,26 @@ pub(crate) async fn resolve_cart(
if qty == 0 {
continue;
}
- let unit_price = product.effective_price_cents();
- let line_total = unit_price * i64::from(qty);
+ items.push((product, qty));
+ }
+ let products_only: Vec = items.iter().map(|(p, _)| p.clone()).collect();
+ let priced = pricing::price_many(ctx, &products_only, user.as_ref()).await?;
+
+ let mut lines = Vec::new();
+ let mut valid = Vec::new();
+ let mut total: i64 = 0;
+ for ((product, qty), priced) in items.iter().zip(priced.iter()) {
+ let unit_price = priced.price_cents;
+ let line_total = unit_price * i64::from(*qty);
total += line_total;
- valid.push((product.id, qty));
+ valid.push((product.id, *qty));
lines.push(json!({
"id": product.id,
"name": product.name,
"slug": product.slug,
"price": format_price(unit_price),
+ "regular_price": format_price(priced.regular_cents),
+ "on_sale": priced.is_reduced(),
"currency": product.currency,
"quantity": qty,
"stock": product.stock,
diff --git a/src/controllers/checkout.rs b/src/controllers/checkout.rs
index 9d64ed4..88638ba 100644
--- a/src/controllers/checkout.rs
+++ b/src/controllers/checkout.rs
@@ -331,6 +331,7 @@ async fn place_order(
pickup_point_id,
pickup_point_name,
},
+ logged_in_customer,
)
.await?;
diff --git a/src/controllers/home.rs b/src/controllers/home.rs
index c46d3b1..08752f9 100644
--- a/src/controllers/home.rs
+++ b/src/controllers/home.rs
@@ -12,8 +12,9 @@ async fn index(
ViewEngine(v): ViewEngine,
State(ctx): State,
) -> Result {
- let products = shop::featured_products(&ctx, 8).await?;
- let c = guard::chrome(&ctx, &jar).await;
+ let user = guard::current_user(&ctx, &jar).await;
+ let products = shop::featured_products(&ctx, user.as_ref(), 8).await?;
+ let c = guard::chrome_from(&ctx, user.as_ref());
format::view(
&v,
diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs
index 5a917e3..e86bd55 100644
--- a/src/controllers/mod.rs
+++ b/src/controllers/mod.rs
@@ -3,6 +3,7 @@ pub mod auth;
pub mod auth_pages;
pub mod oauth2;
pub mod admin_categories;
+pub mod admin_customers;
pub mod admin_dashboard;
pub mod admin_discounts;
pub mod admin_form;
diff --git a/src/controllers/shop.rs b/src/controllers/shop.rs
index d124d11..2899c92 100644
--- a/src/controllers/shop.rs
+++ b/src/controllers/shop.rs
@@ -8,17 +8,23 @@ use serde_json::json;
use crate::{
controllers::i18n::current_lang,
- shared::guard,
- models::{categories, product_images, products},
+ shared::{guard, pricing},
+ models::{categories, product_images, products, users},
views::shop as view,
};
-/// Shape a list of products into card rows, loading each one's primary image.
-async fn product_rows(ctx: &AppContext, list: Vec) -> Result> {
+/// Shape a list of products into card rows for `user` (None = public), pricing
+/// each via [`pricing::price_many`] and loading its primary image.
+async fn product_rows(
+ ctx: &AppContext,
+ user: Option<&users::Model>,
+ list: Vec,
+) -> Result> {
+ let priced = pricing::price_many(ctx, &list, user).await?;
let mut rows = Vec::with_capacity(list.len());
- for product in list {
+ for (product, priced) in list.iter().zip(priced.iter()) {
let image = product_images::first_for(ctx, product.id).await?;
- rows.push(view::product_card(&product, image, None));
+ rows.push(view::product_card(product, priced, image, None));
}
Ok(rows)
}
@@ -27,6 +33,7 @@ async fn product_rows(ctx: &AppContext, list: Vec) -> Result,
limit: u64,
) -> Result> {
let list = products::Entity::find()
@@ -35,7 +42,7 @@ pub(crate) async fn featured_products(
.limit(limit)
.all(&ctx.db)
.await?;
- product_rows(ctx, list).await
+ product_rows(ctx, user, list).await
}
/// The site-wide category sidebar, loaded lazily via htmx by the base layout so
@@ -69,12 +76,13 @@ async fn index(
.all(&ctx.db)
.await?;
- let c = guard::chrome(&ctx, &jar).await;
+ let user = guard::current_user(&ctx, &jar).await;
+ let c = guard::chrome_from(&ctx, user.as_ref());
format::view(
&v,
"shop/index.html",
json!({
- "products": product_rows(&ctx, list).await?,
+ "products": product_rows(&ctx, user.as_ref(), list).await?,
"logged_in_admin": c.logged_in_admin,
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
@@ -112,12 +120,14 @@ async fn show(
None => None,
};
- let c = guard::chrome(&ctx, &jar).await;
+ let user = guard::current_user(&ctx, &jar).await;
+ let priced = pricing::price_for(&ctx, &product, user.as_ref()).await?;
+ let c = guard::chrome_from(&ctx, user.as_ref());
format::view(
&v,
"shop/show.html",
json!({
- "product": view::product_card(&product, None, category.as_ref().map(|c| c.name.clone())),
+ "product": view::product_card(&product, &priced, None, category.as_ref().map(|c| c.name.clone())),
"images": images.iter().map(|i| i.image_id.clone()).collect::>(),
"category": category,
"logged_in_admin": c.logged_in_admin,
@@ -159,7 +169,8 @@ async fn category(
.all(&ctx.db)
.await?;
- let c = guard::chrome(&ctx, &jar).await;
+ let user = guard::current_user(&ctx, &jar).await;
+ let c = guard::chrome_from(&ctx, user.as_ref());
format::view(
&v,
"shop/category.html",
@@ -167,7 +178,7 @@ async fn category(
"category": category,
"breadcrumbs": breadcrumbs,
"children": children,
- "products": product_rows(&ctx, list).await?,
+ "products": product_rows(&ctx, user.as_ref(), list).await?,
"logged_in_admin": c.logged_in_admin,
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
diff --git a/src/models/_entities/account_product_prices.rs b/src/models/_entities/account_product_prices.rs
new file mode 100644
index 0000000..50ca349
--- /dev/null
+++ b/src/models/_entities/account_product_prices.rs
@@ -0,0 +1,49 @@
+//! `SeaORM` Entity for per-account negotiated product prices. Hand-written to
+//! match the `account_product_prices` migration (one row per (user, product)).
+
+use sea_orm::entity::prelude::*;
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
+#[sea_orm(table_name = "account_product_prices")]
+pub struct Model {
+ pub created_at: DateTimeWithTimeZone,
+ pub updated_at: DateTimeWithTimeZone,
+ #[sea_orm(primary_key)]
+ pub id: i32,
+ pub user_id: i32,
+ pub product_id: i32,
+ pub price_cents: i64,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {
+ #[sea_orm(
+ belongs_to = "super::users::Entity",
+ from = "Column::UserId",
+ to = "super::users::Column::Id",
+ on_update = "Cascade",
+ on_delete = "Cascade"
+ )]
+ Users,
+ #[sea_orm(
+ belongs_to = "super::products::Entity",
+ from = "Column::ProductId",
+ to = "super::products::Column::Id",
+ on_update = "Cascade",
+ on_delete = "Cascade"
+ )]
+ Products,
+}
+
+impl Related for Entity {
+ fn to() -> RelationDef {
+ Relation::Users.def()
+ }
+}
+
+impl Related for Entity {
+ fn to() -> RelationDef {
+ Relation::Products.def()
+ }
+}
diff --git a/src/models/_entities/mod.rs b/src/models/_entities/mod.rs
index 410f35b..a713c7b 100644
--- a/src/models/_entities/mod.rs
+++ b/src/models/_entities/mod.rs
@@ -2,6 +2,7 @@
pub mod prelude;
+pub mod account_product_prices;
pub mod audit_logs;
pub mod categories;
pub mod customer_profiles;
diff --git a/src/models/_entities/prelude.rs b/src/models/_entities/prelude.rs
index dde8846..38da3a5 100644
--- a/src/models/_entities/prelude.rs
+++ b/src/models/_entities/prelude.rs
@@ -1,5 +1,6 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
+pub use super::account_product_prices::Entity as AccountProductPrices;
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/account_product_prices.rs b/src/models/account_product_prices.rs
new file mode 100644
index 0000000..2baeb67
--- /dev/null
+++ b/src/models/account_product_prices.rs
@@ -0,0 +1,77 @@
+//! Per-account negotiated product prices: an admin-set price for one product,
+//! for one business account ("personal agreement"). One row per (user, product),
+//! kept unique by the index in the migration.
+
+pub use crate::models::_entities::account_product_prices::{ActiveModel, Column, Entity, Model};
+use sea_orm::entity::prelude::*;
+use sea_orm::{ActiveValue, IntoActiveModel, QueryFilter, TryIntoModel};
+
+pub type AccountProductPrices = Entity;
+
+#[async_trait::async_trait]
+impl ActiveModelBehavior for ActiveModel {
+ async fn before_save(self, _db: &C, insert: bool) -> std::result::Result
+ where
+ C: ConnectionTrait,
+ {
+ if !insert && self.updated_at.is_unchanged() {
+ let mut this = self;
+ this.updated_at = ActiveValue::set(chrono::Utc::now().into());
+ Ok(this)
+ } else {
+ Ok(self)
+ }
+ }
+}
+
+impl Model {
+ /// All negotiated prices for one account, as a `(product_id -> cents)` map.
+ pub async fn map_for_user(
+ db: &DatabaseConnection,
+ user_id: i32,
+ ) -> Result, DbErr> {
+ let rows = Entity::find()
+ .filter(Column::UserId.eq(user_id))
+ .all(db)
+ .await?;
+ Ok(rows.into_iter().map(|r| (r.product_id, r.price_cents)).collect())
+ }
+
+ /// Insert or update the negotiated price for `(user_id, product_id)`.
+ pub async fn upsert(
+ db: &DatabaseConnection,
+ user_id: i32,
+ product_id: i32,
+ price_cents: i64,
+ ) -> Result {
+ let existing = Entity::find()
+ .filter(Column::UserId.eq(user_id))
+ .filter(Column::ProductId.eq(product_id))
+ .one(db)
+ .await?;
+ let mut active = match existing {
+ Some(row) => row.into_active_model(),
+ None => ActiveModel {
+ user_id: ActiveValue::set(user_id),
+ product_id: ActiveValue::set(product_id),
+ ..Default::default()
+ },
+ };
+ active.price_cents = ActiveValue::set(price_cents);
+ active.save(db).await?.try_into_model()
+ }
+
+ /// Remove the negotiated price for `(user_id, product_id)`, if any.
+ pub async fn clear(
+ db: &DatabaseConnection,
+ user_id: i32,
+ product_id: i32,
+ ) -> Result<(), DbErr> {
+ Entity::delete_many()
+ .filter(Column::UserId.eq(user_id))
+ .filter(Column::ProductId.eq(product_id))
+ .exec(db)
+ .await?;
+ Ok(())
+ }
+}
diff --git a/src/models/mod.rs b/src/models/mod.rs
index 4f38ed9..e3faff1 100644
--- a/src/models/mod.rs
+++ b/src/models/mod.rs
@@ -6,6 +6,7 @@
pub mod _entities;
+pub mod account_product_prices;
pub mod audit_logs;
pub mod categories;
pub mod customer_profiles;
diff --git a/src/models/orders.rs b/src/models/orders.rs
index a66a2e0..f255c2d 100644
--- a/src/models/orders.rs
+++ b/src/models/orders.rs
@@ -4,6 +4,8 @@ use sea_orm::{Set, TransactionTrait};
use uuid::Uuid;
use crate::models::_entities::{order_items, products, shipping_methods};
+use crate::models::users;
+use crate::shared::pricing;
pub use crate::models::_entities::orders::{ActiveModel, Column, Entity, Model};
pub type Orders = Entity;
@@ -42,7 +44,12 @@ fn generate_order_number() -> String {
/// snapshot each product's price/name, decrement stock (re-checking inside the
/// transaction so an item can't oversell between cart and pay), then write the
/// order and its line items. Returns the persisted order.
-pub async fn place(ctx: &AppContext, items: &[(i32, i32)], details: Checkout) -> Result {
+pub async fn place(
+ ctx: &AppContext,
+ items: &[(i32, i32)],
+ details: Checkout,
+ user: Option<&users::Model>,
+) -> Result {
let txn = ctx.db.begin().await?;
let mut subtotal: i64 = 0;
@@ -61,8 +68,10 @@ pub async fn place(ctx: &AppContext, items: &[(i32, i32)], details: Checkout) ->
)));
}
currency = product.currency.clone();
- // Snapshot the effective price (honouring any active discount).
- let unit_price_cents = product.effective_price_cents();
+ // Snapshot the price the buyer actually pays — public sale or, for a
+ // business account, their negotiated/lowest price (same resolver the
+ // cart and storefront use).
+ let unit_price_cents = pricing::price_for(ctx, &product, user).await?.price_cents;
subtotal += unit_price_cents * i64::from(*qty);
let mut active = product.clone().into_active_model();
diff --git a/src/shared/guard.rs b/src/shared/guard.rs
index e24fe1e..ce6d109 100644
--- a/src/shared/guard.rs
+++ b/src/shared/guard.rs
@@ -59,15 +59,21 @@ pub struct Chrome {
}
pub async fn chrome(ctx: &AppContext, jar: &CookieJar) -> Chrome {
- match current_user(ctx, jar).await {
- Some(user) if is_admin(ctx, &user) => Chrome {
+ chrome_from(ctx, current_user(ctx, jar).await.as_ref())
+}
+
+/// Same as [`chrome`] but from an already-loaded user, so a handler that needs
+/// the user model (e.g. for pricing) can resolve chrome without a second lookup.
+pub fn chrome_from(ctx: &AppContext, user: Option<&users::Model>) -> Chrome {
+ match user {
+ Some(user) if is_admin(ctx, user) => Chrome {
logged_in_admin: true,
..Default::default()
},
Some(user) => Chrome {
logged_in_customer: true,
- customer_name: Some(user.name),
- customer_account_type: Some(user.account_type),
+ customer_name: Some(user.name.clone()),
+ customer_account_type: Some(user.account_type.clone()),
..Default::default()
},
None => Chrome::default(),
diff --git a/src/shared/mod.rs b/src/shared/mod.rs
index 0060742..7bc2493 100644
--- a/src/shared/mod.rs
+++ b/src/shared/mod.rs
@@ -3,6 +3,7 @@
pub mod csrf;
pub mod guard;
pub mod money;
+pub mod pricing;
pub mod rbac;
pub mod settings;
pub mod slug;
diff --git a/src/shared/pricing.rs b/src/shared/pricing.rs
new file mode 100644
index 0000000..763397c
--- /dev/null
+++ b/src/shared/pricing.rs
@@ -0,0 +1,149 @@
+//! 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 and any admin-set negotiated price. (Phase 2 will add automated
+//! discount profiles as a further input to the same "lowest wins" rule.)
+
+use loco_rs::prelude::*;
+use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
+
+use crate::models::{account_product_prices, products, users};
+
+/// `account_type` value that unlocks business pricing.
+const COMPANY: &str = "company";
+
+/// The resolved price for one product and one viewer.
+#[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.
+ 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
+ }
+}
+
+/// Is this viewer a business (company) account?
+fn is_company(user: Option<&users::Model>) -> bool {
+ matches!(user, Some(u) if u.account_type == COMPANY)
+}
+
+/// The public (non-business) price for a product.
+fn public_priced(product: &products::Model) -> PricedProduct {
+ PricedProduct {
+ price_cents: product.effective_price_cents(),
+ regular_cents: product.price_cents,
+ is_business: false,
+ }
+}
+
+/// Combine the public price with the business layers (Phase 1: the manual
+/// negotiated price), lowest wins. Pure so it can be unit-tested in isolation.
+fn combine(regular_cents: i64, public_cents: i64, manual: Option) -> PricedProduct {
+ match manual {
+ Some(m) if m <= public_cents => PricedProduct {
+ price_cents: m,
+ regular_cents,
+ is_business: true,
+ },
+ _ => PricedProduct {
+ price_cents: public_cents,
+ regular_cents,
+ is_business: false,
+ },
+ }
+}
+
+fn resolve(product: &products::Model, manual: Option) -> PricedProduct {
+ combine(product.price_cents, product.effective_price_cents(), manual)
+}
+
+/// Price one product for `user` (`None` = anonymous/public).
+pub async fn price_for(
+ ctx: &AppContext,
+ product: &products::Model,
+ user: Option<&users::Model>,
+) -> Result {
+ if !is_company(user) {
+ return Ok(public_priced(product));
+ }
+ let user = user.expect("is_company implies Some");
+ let manual = account_product_prices::Entity::find()
+ .filter(account_product_prices::Column::UserId.eq(user.id))
+ .filter(account_product_prices::Column::ProductId.eq(product.id))
+ .one(&ctx.db)
+ .await?
+ .map(|row| row.price_cents);
+ Ok(resolve(product, manual))
+}
+
+/// Price many products for `user`, batching the per-account lookups to avoid
+/// N+1 queries on listing pages and the cart.
+pub async fn price_many(
+ ctx: &AppContext,
+ list: &[products::Model],
+ user: Option<&users::Model>,
+) -> Result> {
+ if !is_company(user) {
+ return Ok(list.iter().map(public_priced).collect());
+ }
+ let user = user.expect("is_company implies Some");
+ let manual = account_product_prices::Model::map_for_user(&ctx.db, user.id).await?;
+ Ok(list
+ .iter()
+ .map(|product| resolve(product, manual.get(&product.id).copied()))
+ .collect())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::combine;
+
+ // regular 100.00, no public sale, no negotiated price.
+ #[test]
+ fn public_only() {
+ let p = combine(10000, 10000, None);
+ assert_eq!(p.price_cents, 10000);
+ assert!(!p.is_reduced());
+ assert!(!p.is_business);
+ }
+
+ // A negotiated price below the public price wins and is flagged business.
+ #[test]
+ fn negotiated_lower_wins() {
+ let p = combine(10000, 10000, Some(9000));
+ assert_eq!(p.price_cents, 9000);
+ assert!(p.is_reduced());
+ assert!(p.is_business);
+ }
+
+ // A public sale below the negotiated price wins (lowest wins); not business.
+ #[test]
+ fn public_sale_beats_negotiated() {
+ // regular 100, public sale 80, negotiated 90 -> pay 80.
+ let p = combine(10000, 8000, Some(9000));
+ assert_eq!(p.price_cents, 8000);
+ assert!(p.is_reduced());
+ assert!(!p.is_business);
+ }
+
+ // A negotiated price equal to the public price is still treated as theirs.
+ #[test]
+ fn negotiated_equal_is_business() {
+ let p = combine(10000, 10000, Some(10000));
+ assert_eq!(p.price_cents, 10000);
+ assert!(!p.is_reduced());
+ assert!(p.is_business);
+ }
+}
diff --git a/src/views/shop.rs b/src/views/shop.rs
index cafaf2d..b3270ec 100644
--- a/src/views/shop.rs
+++ b/src/views/shop.rs
@@ -4,11 +4,16 @@ use serde_json::{json, Value};
use crate::models::_entities::{categories, products};
use crate::shared::money::format_price;
+use crate::shared::pricing::PricedProduct;
-/// Card/list shape for a product: model fields plus a formatted price, its
-/// optional primary image filename and category name.
+/// Card/list shape for a product: model fields plus the viewer's resolved price
+/// (from [`crate::shared::pricing`]), its optional primary image and category.
+/// `on_sale` means "render the price as reduced" — driven by the resolved price,
+/// so it covers both public sales and business deals; `is_business` flags the
+/// latter.
pub fn product_card(
product: &products::Model,
+ priced: &PricedProduct,
image: Option,
category_name: Option,
) -> Value {
@@ -17,9 +22,10 @@ pub fn product_card(
"name": product.name,
"slug": product.slug,
"description": product.description,
- "price": format_price(product.effective_price_cents()),
- "on_sale": product.on_sale(),
- "regular_price": format_price(product.price_cents),
+ "price": format_price(priced.price_cents),
+ "on_sale": priced.is_reduced(),
+ "is_business": priced.is_business,
+ "regular_price": format_price(priced.regular_cents),
"currency": product.currency,
"sku": product.sku,
"stock": product.stock,
diff --git a/tests/models/snapshots/can_create_with_password@users.snap.new b/tests/models/snapshots/can_create_with_password@users.snap.new
new file mode 100644
index 0000000..49b824f
--- /dev/null
+++ b/tests/models/snapshots/can_create_with_password@users.snap.new
@@ -0,0 +1,29 @@
+---
+source: tests/models/users.rs
+assertion_line: 61
+expression: res
+---
+Ok(
+ Model {
+ created_at: DATE,
+ updated_at: DATE,
+ id: ID
+ pid: PID,
+ email: "test@framework.com",
+ password: "PASSWORD",
+ api_key: "lo-PID",
+ name: "framework",
+ reset_token: None,
+ reset_sent_at: None,
+ email_verification_token: None,
+ email_verification_sent_at: None,
+ email_verified_at: None,
+ magic_link_token: None,
+ magic_link_expiration: None,
+ theme: "light",
+ account_type: "personal",
+ totp_secret: None,
+ totp_enabled_at: None,
+ totp_backup_codes: None,
+ },
+)
diff --git a/tests/models/snapshots/can_find_by_email@users.snap.new b/tests/models/snapshots/can_find_by_email@users.snap.new
new file mode 100644
index 0000000..32cdbf7
--- /dev/null
+++ b/tests/models/snapshots/can_find_by_email@users.snap.new
@@ -0,0 +1,29 @@
+---
+source: tests/models/users.rs
+assertion_line: 106
+expression: existing_user
+---
+Ok(
+ Model {
+ created_at: 2023-11-12T12:34:56.789+00:00,
+ updated_at: 2023-11-12T12:34:56.789+00:00,
+ id: 2,
+ pid: 11111111-1111-1111-1111-111111111111,
+ email: "user1@example.com",
+ password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc",
+ api_key: "lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758",
+ name: "user1",
+ reset_token: None,
+ reset_sent_at: None,
+ email_verification_token: None,
+ email_verification_sent_at: None,
+ email_verified_at: None,
+ magic_link_token: None,
+ magic_link_expiration: None,
+ theme: "light",
+ account_type: "personal",
+ totp_secret: None,
+ totp_enabled_at: None,
+ totp_backup_codes: None,
+ },
+)
diff --git a/tests/models/snapshots/can_find_by_pid@users.snap.new b/tests/models/snapshots/can_find_by_pid@users.snap.new
new file mode 100644
index 0000000..28f6bbf
--- /dev/null
+++ b/tests/models/snapshots/can_find_by_pid@users.snap.new
@@ -0,0 +1,29 @@
+---
+source: tests/models/users.rs
+assertion_line: 127
+expression: existing_user
+---
+Ok(
+ Model {
+ created_at: 2023-11-12T12:34:56.789+00:00,
+ updated_at: 2023-11-12T12:34:56.789+00:00,
+ id: 2,
+ pid: 11111111-1111-1111-1111-111111111111,
+ email: "user1@example.com",
+ password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc",
+ api_key: "lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758",
+ name: "user1",
+ reset_token: None,
+ reset_sent_at: None,
+ email_verification_token: None,
+ email_verification_sent_at: None,
+ email_verified_at: None,
+ magic_link_token: None,
+ magic_link_expiration: None,
+ theme: "light",
+ account_type: "personal",
+ totp_secret: None,
+ totp_enabled_at: None,
+ totp_backup_codes: None,
+ },
+)
diff --git a/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap.new b/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap.new
new file mode 100644
index 0000000..517c7ec
--- /dev/null
+++ b/tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap.new
@@ -0,0 +1,6 @@
+---
+source: tests/requests/auth.rs
+assertion_line: 365
+expression: magic_link_response.text()
+---
+"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"user1\",\"is_verified\":false,\"is_admin\":false}"
diff --git a/tests/requests/snapshots/can_get_current_user@auth_request.snap.new b/tests/requests/snapshots/can_get_current_user@auth_request.snap.new
new file mode 100644
index 0000000..70f1d13
--- /dev/null
+++ b/tests/requests/snapshots/can_get_current_user@auth_request.snap.new
@@ -0,0 +1,9 @@
+---
+source: tests/requests/auth.rs
+assertion_line: 309
+expression: "(response.status_code(), response.text())"
+---
+(
+ 200,
+ "{\"pid\":\"PID\",\"name\":\"loco\",\"email\":\"test@loco.com\",\"is_admin\":false}",
+)
diff --git a/tests/requests/snapshots/can_login_without_verify@auth_request.snap.new b/tests/requests/snapshots/can_login_without_verify@auth_request.snap.new
new file mode 100644
index 0000000..5bb5ea0
--- /dev/null
+++ b/tests/requests/snapshots/can_login_without_verify@auth_request.snap.new
@@ -0,0 +1,6 @@
+---
+source: tests/requests/auth.rs
+assertion_line: 189
+expression: login_response.text()
+---
+"{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":false,\"is_admin\":false}"
diff --git a/tests/requests/snapshots/can_register@auth_request.snap.new b/tests/requests/snapshots/can_register@auth_request.snap.new
new file mode 100644
index 0000000..242afea
--- /dev/null
+++ b/tests/requests/snapshots/can_register@auth_request.snap.new
@@ -0,0 +1,33 @@
+---
+source: tests/requests/auth.rs
+assertion_line: 44
+expression: saved_user
+---
+Ok(
+ Model {
+ created_at: DATE,
+ updated_at: DATE,
+ id: ID
+ pid: PID,
+ email: "test@loco.com",
+ password: "PASSWORD",
+ api_key: "lo-PID",
+ name: "loco",
+ reset_token: None,
+ reset_sent_at: None,
+ email_verification_token: Some(
+ "PID",
+ ),
+ email_verification_sent_at: Some(
+ DATE,
+ ),
+ email_verified_at: None,
+ magic_link_token: None,
+ magic_link_expiration: None,
+ theme: "light",
+ account_type: "personal",
+ totp_secret: None,
+ totp_enabled_at: None,
+ totp_backup_codes: None,
+ },
+)
diff --git a/tests/requests/snapshots/login_with_valid_password@auth_request.snap.new b/tests/requests/snapshots/login_with_valid_password@auth_request.snap.new
new file mode 100644
index 0000000..cc21909
--- /dev/null
+++ b/tests/requests/snapshots/login_with_valid_password@auth_request.snap.new
@@ -0,0 +1,9 @@
+---
+source: tests/requests/auth.rs
+assertion_line: 118
+expression: "(response.status_code(), response.text())"
+---
+(
+ 200,
+ "{\"token\":\"TOKEN\",\"pid\":\"PID\",\"name\":\"loco\",\"is_verified\":true,\"is_admin\":false}",
+)
diff --git a/tests/requests/snapshots/resend_verification_user@auth_request.snap.new b/tests/requests/snapshots/resend_verification_user@auth_request.snap.new
new file mode 100644
index 0000000..dcb90ed
--- /dev/null
+++ b/tests/requests/snapshots/resend_verification_user@auth_request.snap.new
@@ -0,0 +1,31 @@
+---
+source: tests/requests/auth.rs
+assertion_line: 454
+expression: user
+---
+Model {
+ created_at: DATE,
+ updated_at: DATE,
+ id: ID
+ pid: PID,
+ email: "test@loco.com",
+ password: "PASSWORD",
+ api_key: "lo-PID",
+ name: "loco",
+ reset_token: None,
+ reset_sent_at: None,
+ email_verification_token: Some(
+ "PID",
+ ),
+ email_verification_sent_at: Some(
+ DATE,
+ ),
+ email_verified_at: None,
+ magic_link_token: None,
+ magic_link_expiration: None,
+ theme: "light",
+ account_type: "personal",
+ totp_secret: None,
+ totp_enabled_at: None,
+ totp_backup_codes: None,
+}
|