From c713627a2cfb0b1c592fadf8c8cf6250efdb594a Mon Sep 17 00:00:00 2001 From: Priec Date: Sun, 21 Jun 2026 23:21:24 +0200 Subject: [PATCH] personal discounts to businesses done --- assets/i18n/en/main.ftl | 11 ++ assets/i18n/sk/main.ftl | 11 ++ assets/views/admin/base.html | 4 + assets/views/admin/customers/index.html | 45 +++++ assets/views/admin/customers/show.html | 68 +++++++ assets/views/shop/_cart_body.html | 9 +- migration/src/lib.rs | 2 + ...m20260621_000002_account_product_prices.rs | 40 ++++ src/app.rs | 6 +- src/controllers/admin_customers.rs | 175 ++++++++++++++++++ src/controllers/admin_products.rs | 4 +- src/controllers/cart.rs | 27 ++- src/controllers/checkout.rs | 1 + src/controllers/home.rs | 5 +- src/controllers/mod.rs | 1 + src/controllers/shop.rs | 37 ++-- .../_entities/account_product_prices.rs | 49 +++++ src/models/_entities/mod.rs | 1 + src/models/_entities/prelude.rs | 1 + src/models/account_product_prices.rs | 77 ++++++++ src/models/mod.rs | 1 + src/models/orders.rs | 15 +- src/shared/guard.rs | 14 +- src/shared/mod.rs | 1 + src/shared/pricing.rs | 149 +++++++++++++++ src/views/shop.rs | 16 +- .../can_create_with_password@users.snap.new | 29 +++ .../can_find_by_email@users.snap.new | 29 +++ .../snapshots/can_find_by_pid@users.snap.new | 29 +++ ...auth_with_magic_link@auth_request.snap.new | 6 + ...can_get_current_user@auth_request.snap.new | 9 + ...login_without_verify@auth_request.snap.new | 6 + .../can_register@auth_request.snap.new | 33 ++++ ..._with_valid_password@auth_request.snap.new | 9 + ...nd_verification_user@auth_request.snap.new | 31 ++++ 35 files changed, 912 insertions(+), 39 deletions(-) create mode 100644 assets/views/admin/customers/index.html create mode 100644 assets/views/admin/customers/show.html create mode 100644 migration/src/m20260621_000002_account_product_prices.rs create mode 100644 src/controllers/admin_customers.rs create mode 100644 src/models/_entities/account_product_prices.rs create mode 100644 src/models/account_product_prices.rs create mode 100644 src/shared/pricing.rs create mode 100644 tests/models/snapshots/can_create_with_password@users.snap.new create mode 100644 tests/models/snapshots/can_find_by_email@users.snap.new create mode 100644 tests/models/snapshots/can_find_by_pid@users.snap.new create mode 100644 tests/requests/snapshots/can_auth_with_magic_link@auth_request.snap.new create mode 100644 tests/requests/snapshots/can_get_current_user@auth_request.snap.new create mode 100644 tests/requests/snapshots/can_login_without_verify@auth_request.snap.new create mode 100644 tests/requests/snapshots/can_register@auth_request.snap.new create mode 100644 tests/requests/snapshots/login_with_valid_password@auth_request.snap.new create mode 100644 tests/requests/snapshots/resend_verification_user@auth_request.snap.new 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 %} + + + + + + + {% endfor %} + +
{{ 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") }} +
+ {% 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 %} + + + + + + + {% endfor %} + +
{{ product.name }} + {% if product.on_public_sale %} + {{ product.public_price }} {{ product.currency }} + {{ product.regular_price }} + {% else %} + {{ product.public_price }} {{ product.currency }} + {% endif %} + +
+ {{ ui::csrf_field() }} + {{ ui::input(name="price", value=product.manual_price | default(value=""), placeholder="0.00", width="w-28", attrs='inputmode="decimal"') }} + {{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }} + {% if product.manual_price %} + {{ ui::button(variant="outline-danger", label=t(key="remove", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs", attrs='formaction="/admin/customers/' ~ customer.id ~ '/prices/' ~ product.product_id ~ '/remove"') }} + {% endif %} +
+
+ {{ product.effective_price }} {{ product.currency }} +
+ {% 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, +}