//! Admin management of business (company) accounts and their pricing. //! //! Per company the admin can: assign reusable discount profiles (the automated //! layer), resolve per-product collisions when two assigned profiles cover the //! same product, and set a manually negotiated price per product. The effective //! price the business pays is always resolved by [`crate::shared::pricing`] //! (lowest of public / automated / negotiated), shown here for reference. use std::collections::{HashMap, HashSet}; use axum_extra::extract::cookie::CookieJar; use loco_rs::prelude::*; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, QueryOrder, Set, TransactionTrait, }; use serde::Deserialize; use serde_json::json; use crate::{ controllers::i18n::current_lang, models::{ account_discount_profiles, account_product_prices, account_product_resolutions, categories, discount_profiles, product_variants, products, _entities::users, }, shared::{ guard, money::{format_bp, format_price, parse_price_to_cents}, pricing, }, views::shop as view, }; const COMPANY: &str = "company"; const BUSINESS_AUDIENCE: &str = "business"; #[derive(Debug, Deserialize)] struct PriceForm { price: String, } #[derive(Debug, Deserialize)] struct ResolutionForm { profile_id: i32, } 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)?; if user.account_type != COMPANY { return Err(Error::NotFound); } Ok(user) } async fn assigned_profile_ids(ctx: &AppContext, user_id: i32) -> Result> { Ok(account_discount_profiles::Entity::find() .filter(account_discount_profiles::Column::UserId.eq(user_id)) .all(&ctx.db) .await? .into_iter() .map(|a| a.discount_profile_id) .collect()) } #[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?; // All profiles (for the assignment section + name lookup) and which are // assigned to this company. let all_profiles = discount_profiles::Entity::find() .order_by_asc(discount_profiles::Column::Name) .all(&ctx.db) .await?; let assigned = assigned_profile_ids(&ctx, company.id).await?; let profiles_json: Vec = all_profiles .iter() .map(|p| { json!({ "id": p.id, "name": p.name, "percent": format_bp(p.percent_bp), "scope_type": p.scope_type, "assigned": assigned.contains(&p.id), }) }) .collect(); let all_categories = categories::Entity::find() .order_by_asc(categories::Column::Position) .order_by_asc(categories::Column::Name) .all(&ctx.db) .await?; // Optional text search (drafts included), otherwise the whole catalog by // name. Reuses the storefront's hybrid full-text + fuzzy product search. let query = params.get("q").map(String::as_str).unwrap_or("").trim().to_string(); let list = if query.is_empty() { products::Entity::find() .order_by_asc(products::Column::Name) .all(&ctx.db) .await? } else { products::Entity::search(&ctx.db, &query, 1000, false).await? }; // Category sidebar tree (counts over the full, unfiltered product list) plus // the active `?category=` filter applied to the rows. let category_ids: Vec> = list.iter().map(|p| p.category_id).collect(); let category_groups = view::admin_category_groups(&all_categories, &category_ids); let selected_category = params .get("category") .map(String::as_str) .unwrap_or("all") .to_string(); let filter = view::category_filter_ids(&all_categories, &selected_category); // Pricing is per variant. Flatten the (filtered) products into their variants // in product-name then variant-position order, carrying each variant's // product for the row's display name. let product_ids: Vec = list.iter().map(|p| p.id).collect(); let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &product_ids).await?; let mut variant_rows: Vec<(&products::Model, product_variants::Model)> = Vec::new(); for product in &list { if !view::category_filter_keep(&filter, product.category_id) { continue; } if let Some(variants) = grouped.get(&product.id) { for variant in variants { variant_rows.push((product, variant.clone())); } } } // Two prices per variant: // - the generic business price a freshly-registered company sees (business // baseline + business-audience profiles, no per-company deals), and // - this company's effective price (its negotiated price + assigned profiles). // The effective price is highlighted only when it differs from the generic one. let variants_only: Vec = variant_rows.iter().map(|(_, v)| v.clone()).collect(); let business = pricing::audience_price_variants(&ctx, &variants_only, BUSINESS_AUDIENCE).await?; let details = pricing::detail_variants(&ctx, &variants_only, Some(&company)).await?; let rows: Vec = variant_rows .iter() .zip(business.iter()) .zip(details.iter()) .map(|(((product, variant), b), d)| { json!({ "product_id": product.id, "variant_id": variant.id, "name": product.name, "variant_label": variant.label, "currency": product.currency, "regular_price": format_price(d.regular_cents), "business_price": format_price(b.price_cents), "business_reduced": b.price_cents < d.regular_cents, "has_negotiated": d.manual_cents.is_some(), "collision": d.collision, "effective_price": format_price(d.price_cents), "effective_differs": d.price_cents != b.price_cents, }) }) .collect(); format::view( &v, "admin/customers/show.html", json!({ "customer": { "id": company.id, "name": company.name, "email": company.email }, "profiles": profiles_json, "products": rows, "category_groups": category_groups, "selected_category": selected_category, "query": query, "total_count": list.len(), "uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(), "error": params.get("error"), "lang": current_lang(&jar), }), ) } /// Dedicated per-product page for the negotiated price (and, when two assigned /// profiles collide, the resolution selector). Mirrors the catalog "Set discount" /// page but for a single company. #[debug_handler] async fn price_edit( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, Path((id, variant_id)): Path<(i32, i32)>, Query(params): Query>, State(ctx): State, ) -> Result { guard::current_admin(auth, &ctx).await?; let company = company_by_id(&ctx, id).await?; let variant = product_variants::Entity::find_by_id(variant_id) .one(&ctx.db) .await? .ok_or_else(|| Error::NotFound)?; let product = products::Entity::find_by_id(variant.product_id) .one(&ctx.db) .await? .ok_or_else(|| Error::NotFound)?; let business = pricing::audience_price_variants(&ctx, std::slice::from_ref(&variant), BUSINESS_AUDIENCE) .await?; let business_cents = business[0].price_cents; let detail = pricing::detail_variants(&ctx, std::slice::from_ref(&variant), Some(&company)).await?; let d = &detail[0]; // Names for the covering profiles, used by the collision resolution selector. let covering: Vec = if d.covering_profile_ids.is_empty() { Vec::new() } else { let profiles = discount_profiles::Entity::find() .filter(discount_profiles::Column::Id.is_in(d.covering_profile_ids.clone())) .all(&ctx.db) .await?; let name: HashMap = profiles.iter().map(|p| (p.id, p.name.clone())).collect(); d.covering_profile_ids .iter() .map(|pid| json!({ "id": pid, "name": name.get(pid) })) .collect() }; format::view( &v, "admin/customers/price_form.html", json!({ "customer": { "id": company.id, "name": company.name }, "product": { "id": product.id, "variant_id": variant.id, "name": product.name, "variant_label": variant.label, "currency": product.currency, "regular_price": format_price(d.regular_cents), "regular_cents": d.regular_cents, "business_price": format_price(business_cents), "business_reduced": business_cents < d.regular_cents, "effective_price": format_price(d.price_cents), "effective_differs": d.price_cents != business_cents, }, "negotiated": d.manual_cents.map(format_price).unwrap_or_default(), "has_negotiated": d.manual_cents.is_some(), "collision": d.collision, "covering": covering, "auto_profile_id": d.auto_profile_id, "error": params.get("error"), "lang": current_lang(&jar), }), ) } #[debug_handler] async fn set_price( auth: auth::JWT, Path((id, variant_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(); if entered.is_empty() { account_product_prices::Model::clear(&ctx.db, company.id, variant_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}/prices/{variant_id}/edit?error=discount-must-be-positive" )) } }; account_product_prices::Model::upsert(&ctx.db, company.id, variant_id, cents).await?; format::redirect(&format!("/admin/customers/{id}")) } #[debug_handler] async fn remove_price( auth: auth::JWT, Path((id, variant_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, variant_id).await?; format::redirect(&format!("/admin/customers/{id}")) } /// Replace the company's assigned profiles with the submitted set of checkboxes /// (`profile_ids`, a repeated field axum `Form` can't collect, parsed directly). #[debug_handler] async fn sync_profiles( auth: auth::JWT, Path(id): Path, State(ctx): State, body: String, ) -> Result { guard::current_admin(auth, &ctx).await?; let company = company_by_id(&ctx, id).await?; let profile_ids: Vec = form_urlencoded::parse(body.as_bytes()) .filter(|(k, _)| k == "profile_ids") .filter_map(|(_, v)| v.parse::().ok()) .collect(); let txn = ctx.db.begin().await?; account_discount_profiles::Entity::delete_many() .filter(account_discount_profiles::Column::UserId.eq(company.id)) .exec(&txn) .await?; for profile_id in profile_ids { account_discount_profiles::ActiveModel { user_id: Set(company.id), discount_profile_id: Set(profile_id), ..Default::default() } .insert(&txn) .await?; } txn.commit().await?; format::redirect(&format!("/admin/customers/{id}")) } /// Record the admin's chosen winning profile for a colliding product. #[debug_handler] async fn set_resolution( auth: auth::JWT, Path((id, variant_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 existing = account_product_resolutions::Entity::find() .filter(account_product_resolutions::Column::UserId.eq(company.id)) .filter(account_product_resolutions::Column::VariantId.eq(variant_id)) .one(&ctx.db) .await?; let mut active = match existing { Some(row) => row.into_active_model(), None => account_product_resolutions::ActiveModel { user_id: Set(company.id), variant_id: Set(variant_id), ..Default::default() }, }; active.discount_profile_id = Set(form.profile_id); active.save(&ctx.db).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}/profiles", post(sync_profiles)) .add( "/admin/customers/{id}/prices/{variant_id}/edit", get(price_edit), ) .add("/admin/customers/{id}/prices/{variant_id}", post(set_price)) .add( "/admin/customers/{id}/prices/{variant_id}/remove", post(remove_price), ) .add( "/admin/customers/{id}/resolutions/{variant_id}", post(set_resolution), ) }