//! Public checkout flow: the checkout form, placing an order, and the order //! confirmation page. use axum::extract::Query; use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; use loco_rs::prelude::*; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; use serde::Deserialize; use serde_json::json; use time::Duration as TimeDuration; use crate::{ controllers::cart::{resolve_cart, CART_COOKIE}, mailers::auth::AuthMailer, models::{ customer_profiles::{self, ProfileFields}, order_items, orders, shipping_methods, users::{self, normalize_account_type}, }, controllers::i18n::current_lang, shared::{currency::Currency, guard, money::format_price, settings}, views::checkout as view, }; const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"]; #[derive(Debug, Deserialize)] struct CheckoutForm { email: String, phone_prefix: String, phone: String, customer_name: String, account_type: Option, company_name: Option, company_id: Option, tax_id: Option, vat_id: Option, address: String, city: String, zip: String, country: String, note: Option, payment_method: String, carrier_code: String, pickup_point_id: Option, pickup_point_name: Option, // Present (as "on") only when a logged-in customer ticks "save my address". save_profile: Option, // Present only when a guest ticks "create an account from this order". create_account: Option, } fn trimmed(value: &str) -> Option { let value = value.trim(); (!value.is_empty()).then(|| value.to_string()) } fn cleared_cart_cookie() -> Cookie<'static> { Cookie::build((CART_COOKIE, "")) .path("/") .same_site(SameSite::Lax) .max_age(TimeDuration::seconds(0)) .build() } async fn enabled_shipping_methods(ctx: &AppContext) -> Result> { Ok(shipping_methods::Entity::find() .filter(shipping_methods::Column::Enabled.eq(true)) .order_by_asc(shipping_methods::Column::Position) .all(&ctx.db) .await?) } #[debug_handler] async fn checkout_page( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { // Checkout and everything past it (orders, confirmation) stay in the EUR // base — the settlement currency — even when the buyer browsed in another. let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar, &Currency::eur()).await?; if lines.is_empty() { return format::redirect("/cart"); } let methods: Vec = enabled_shipping_methods(&ctx) .await? .iter() .map(|m| { json!({ "code": m.code, "name": m.name, "price_cents": m.price_cents, "price": format_price(m.price_cents), "requires_pickup_point": m.requires_pickup_point, }) }) .collect(); // Prefill the form for a logged-in customer: contact name/email come from // the user account, the address/phone from their saved profile (if any). let user = guard::current_user(&ctx, &jar).await; let is_admin = user.as_ref().is_some_and(|u| guard::is_admin(&ctx, u)); let is_customer = user.is_some() && !is_admin; let profile = match (&user, is_customer) { (Some(u), true) => customer_profiles::Model::find_for_user(&ctx.db, u.id).await?, _ => None, }; let p = |get: fn(&customer_profiles::Model) -> Option| { profile.as_ref().and_then(get) }; // Whether the customer already has a shipping address on file. When they do, // the "save this address to my profile" opt-in is pointless (the profile was // filled in advance), so it's hidden and the existing profile is left alone. let profile_filled = profile .as_ref() .is_some_and(|pr| pr.address.is_some() && pr.city.is_some() && pr.zip.is_some()); format::view( &v, "shop/checkout.html", json!({ "items": lines, "subtotal": format_price(subtotal), "subtotal_cents": subtotal, "shipping_methods": methods, "packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""), "logged_in_admin": is_admin, "logged_in_customer": is_customer, // Required by the navbar profile menu (base.html includes it whenever // logged_in_customer is true); None for admins/guests. "customer_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()), "customer_account_type": user.as_ref().filter(|_| is_customer).map(|u| u.account_type.clone()), "customer_avatar": user.as_ref().filter(|_| is_customer).and_then(|u| u.avatar_id.clone()), "profile_filled": profile_filled, // A logged-in customer's account type is fixed; only guests pick it // and may opt to create an account from the order. "account_fixed": is_customer, "can_create_account": user.is_none(), "prefill_email": user.as_ref().filter(|_| is_customer).map(|u| u.email.clone()), "prefill_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()), "prefill_account_type": user.as_ref().filter(|_| is_customer).map_or("personal", |u| u.account_type.as_str()), "prefill_company_name": p(|x| x.company_name.clone()), "prefill_company_id": p(|x| x.company_id.clone()), "prefill_tax_id": p(|x| x.tax_id.clone()), "prefill_vat_id": p(|x| x.vat_id.clone()), "prefill_phone_prefix": p(|x| x.phone_prefix.clone()), "prefill_phone": p(|x| x.phone.clone()), "prefill_address": p(|x| x.address.clone()), "prefill_city": p(|x| x.city.clone()), "prefill_zip": p(|x| x.zip.clone()), "prefill_country": p(|x| x.country.clone()), "lang": current_lang(&jar), }), ) } #[debug_handler] async fn place_order( jar: CookieJar, State(ctx): State, Form(form): Form, ) -> Result { let (_lines, valid, _total) = resolve_cart(&ctx, &jar, &Currency::eur()).await?; if valid.is_empty() { return format::redirect("/cart"); } let email = trimmed(&form.email).ok_or_else(|| Error::BadRequest("email is required".to_string()))?; // Combine the dialling-code prefix with the local number into one E.164-ish // value (e.g. "+421 900123456"). let number = trimmed(&form.phone).ok_or_else(|| Error::BadRequest("phone is required".to_string()))?; let phone = match trimmed(&form.phone_prefix) { Some(prefix) => format!("{prefix} {number}"), None => number.clone(), }; // Contact and shipping-address fields are mandatory (also enforced in the // browser via `required`). let require = |value: &str, field: &str| -> Result { trimmed(value).ok_or_else(|| Error::BadRequest(format!("{field} is required"))) }; let customer_name = require(&form.customer_name, "name")?; let address = require(&form.address, "address")?; let city = require(&form.city, "city")?; let zip = require(&form.zip, "zip")?; let country = require(&form.country, "country")?; // The account type is fixed for a logged-in customer (taken from their // account, never the form); a guest picks it on the form. Admins are treated // as guests here. let current_user = guard::current_user(&ctx, &jar).await; let logged_in_customer = current_user .as_ref() .filter(|u| !guard::is_admin(&ctx, u)); let account_type = match logged_in_customer { Some(u) => u.account_type.clone(), None => normalize_account_type(form.account_type.as_deref()), }; // Company purchases must carry the invoicing identifiers (IČO + DIČ // required, IČ DPH optional). Personal orders carry none. let (company_name, company_id, tax_id, vat_id) = if account_type == "company" { ( Some(require(form.company_name.as_deref().unwrap_or(""), "company name")?), Some(require(form.company_id.as_deref().unwrap_or(""), "IČO")?), Some(require(form.tax_id.as_deref().unwrap_or(""), "DIČ")?), form.vat_id.as_deref().and_then(trimmed), ) } else { (None, None, None, None) }; if !PAYMENT_METHODS.contains(&form.payment_method.as_str()) { return Err(Error::BadRequest("invalid payment method".to_string())); } // Resolve the chosen carrier from the enabled methods (price is taken from // the DB, never the form, so the customer can't pick their own fee). let method = shipping_methods::Entity::find() .filter(shipping_methods::Column::Code.eq(&form.carrier_code)) .filter(shipping_methods::Column::Enabled.eq(true)) .one(&ctx.db) .await? .ok_or_else(|| Error::BadRequest("invalid shipping method".to_string()))?; let (pickup_point_id, pickup_point_name) = if method.requires_pickup_point { let id = form .pickup_point_id .as_deref() .and_then(trimmed) .ok_or_else(|| Error::BadRequest("a pickup point is required".to_string()))?; (Some(id), form.pickup_point_name.as_deref().and_then(trimmed)) } else { (None, None) }; // The address/contact captured here, ready to seed a profile (for the // logged-in "save my address" opt-in or a freshly created guest account). let entered_profile = || ProfileFields { company_name: company_name.clone(), company_id: company_id.clone(), tax_id: tax_id.clone(), vat_id: vat_id.clone(), phone_prefix: trimmed(&form.phone_prefix), phone: Some(number.clone()), address: Some(address.clone()), city: Some(city.clone()), zip: Some(zip.clone()), country: Some(country.clone()), }; // Resolve the account that will own this order. A logged-in customer always // owns their orders. A guest may opt to create an account from the order; // the new account's type matches what they bought as, its profile is seeded // from the entered details, and a "set your password" link is emailed. If // the email already belongs to an account we silently fall back to a guest // order (no hijacking an existing account). let mut order_user_id = logged_in_customer.map(|u| u.id); let mut account_created = false; if order_user_id.is_none() && form.create_account.is_some() { match users::Model::create_guest_account(&ctx.db, &email, &customer_name, &account_type) .await { Ok(new_user) => { if let Err(err) = customer_profiles::Model::upsert(&ctx.db, new_user.id, entered_profile()).await { tracing::error!(error = %err, user_id = new_user.id, "failed to seed guest profile"); } let user_id = new_user.id; match new_user.into_active_model().set_forgot_password_sent(&ctx.db).await { Ok(user) => { if let Err(err) = AuthMailer::send_set_password(&ctx, &user).await { tracing::error!(error = %err, "failed to send set-password email"); } order_user_id = Some(user_id); account_created = true; } Err(err) => { tracing::error!(error = %err, "failed to issue set-password token"); order_user_id = Some(user_id); } } } Err(ModelError::EntityAlreadyExists {}) => { tracing::info!(email = %email, "checkout account-create skipped: email already registered"); } Err(err) => tracing::error!(error = %err, "failed to create checkout account"), } } // If a logged-in customer opted in, persist this address to their profile so // the next checkout is prefilled. Best-effort: a failure here is logged but // must not block the order. if form.save_profile.is_some() { if let Some(user) = logged_in_customer { if let Err(err) = customer_profiles::Model::upsert(&ctx.db, user.id, entered_profile()).await { tracing::error!(error = %err, user_id = user.id, "failed to save checkout profile"); } } } let order = orders::place( &ctx, &valid, orders::Checkout { email, phone, customer_name: Some(customer_name), user_id: order_user_id, account_type, company_name, company_id, tax_id, vat_id, address: Some(address), city: Some(city), zip: Some(zip), country: Some(country), note: form.note.as_deref().and_then(trimmed), payment_method: form.payment_method, method, pickup_point_id, pickup_point_name, }, logged_in_customer, ) .await?; let target = if account_created { format!("/orders/{}?account_created=1", order.order_number) } else { format!("/orders/{}", order.order_number) }; format::render() .cookies(&[cleared_cart_cookie()])? .redirect(&target) } #[debug_handler] async fn order_confirmation( jar: CookieJar, ViewEngine(v): ViewEngine, Path(order_number): Path, Query(params): Query>, State(ctx): State, ) -> Result { let order = orders::Entity::find() .filter(orders::Column::OrderNumber.eq(order_number)) .one(&ctx.db) .await? .ok_or_else(|| Error::NotFound)?; let items = order_items::Entity::find() .filter(order_items::Column::OrderId.eq(order.id)) .all(&ctx.db) .await?; let c = guard::chrome(&ctx, &jar).await; let account_created = params.contains_key("account_created"); format::view( &v, "shop/order_confirmed.html", json!({ "order": view::detail( &order, settings::get(&ctx, "bank_iban").unwrap_or(""), settings::get(&ctx, "bank_account_name").unwrap_or(""), ), "items": view::items(&items), "logged_in_admin": c.logged_in_admin, "logged_in_customer": c.logged_in_customer, "customer_name": c.customer_name, "customer_account_type": c.customer_account_type, "customer_avatar": c.customer_avatar, "account_created": account_created, "lang": current_lang(&jar), }), ) } pub fn routes() -> Routes { Routes::new() .add("/checkout", get(checkout_page)) .add("/checkout", post(place_order)) .add("/orders/{order_number}", get(order_confirmation)) }