//! Public checkout flow: the checkout form, placing an order, and the order //! confirmation page. 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}, models::{order_items, orders, shipping_methods}, controllers::i18n::current_lang, shared::{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, address: String, city: String, zip: String, country: String, note: Option, payment_method: String, carrier_code: String, pickup_point_id: Option, pickup_point_name: 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 { let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar).await?; if lines.is_empty() { return format::redirect("/cart"); } let currency = lines .first() .and_then(|line| line["currency"].as_str()) .unwrap_or("EUR") .to_string(); 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(); format::view( &v, "shop/checkout.html", json!({ "items": lines, "subtotal": format_price(subtotal), "subtotal_cents": subtotal, "currency": currency, "shipping_methods": methods, "packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""), "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).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, }; // 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")?; 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) }; let order = orders::place( &ctx, &valid, orders::Checkout { email, phone, customer_name: Some(customer_name), 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, }, ) .await?; format::render() .cookies(&[cleared_cart_cookie()])? .redirect(&format!("/orders/{}", order.order_number)) } #[debug_handler] async fn order_confirmation( jar: CookieJar, ViewEngine(v): ViewEngine, Path(order_number): Path, 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?; 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), "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)) }