This commit is contained in:
Priec
2026-06-28 21:06:10 +02:00
parent e5c84e631f
commit 1bde553f79
8 changed files with 490 additions and 211 deletions

View File

@@ -1,12 +1,20 @@
//! Public checkout flow: the checkout form, placing an order, and the order
//! confirmation page.
//! Public checkout flow, split across pages: the basket is `/cart`, then a
//! two-step wizard — `/checkout/info` (contact + addresses + company details)
//! followed by `/checkout/payment` (carrier + payment, which places the order)
//! — and finally the order confirmation page.
//!
//! The info step is persisted between pages in a short-lived, base64-encoded
//! JSON cookie (`checkout_info`); the payment step reads it back, places the
//! order, and clears both it and the cart cookie.
use axum::extract::Query;
use axum_extra::extract::cookie::CookieJar;
use axum::{extract::Query, response::Redirect};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::json;
use time::Duration as TimeDuration;
use crate::{
controllers::cart::{self, resolve_cart},
mailers::auth::AuthMailer,
@@ -20,8 +28,37 @@ use crate::{
views::checkout as view,
};
const INFO_COOKIE: &str = "checkout_info";
const INFO_MAX_AGE_HOURS: i64 = 2;
/// The contact + address details captured on `/checkout/info`, carried to the
/// `/checkout/payment` step via the `checkout_info` cookie. `phone` is the local
/// number only; it is combined with `phone_prefix` when the order is placed.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CheckoutInfo {
email: String,
phone_prefix: String,
phone: String,
customer_name: String,
account_type: String,
company_name: Option<String>,
company_id: Option<String>,
tax_id: Option<String>,
vat_id: Option<String>,
residence_address: String,
residence_city: String,
residence_zip: String,
residence_country: String,
delivery_same: bool,
address: String,
city: String,
zip: String,
country: String,
}
/// Step 1 form (`POST /checkout/info`).
#[derive(Debug, Deserialize)]
struct CheckoutForm {
struct InfoForm {
email: String,
phone_prefix: String,
phone: String,
@@ -40,6 +77,11 @@ struct CheckoutForm {
city: Option<String>,
zip: Option<String>,
country: Option<String>,
}
/// Step 2 form (`POST /checkout/payment`).
#[derive(Debug, Deserialize)]
struct PaymentForm {
note: Option<String>,
payment_method: String,
carrier_code: String,
@@ -56,6 +98,33 @@ fn trimmed(value: &str) -> Option<String> {
(!value.is_empty()).then(|| value.to_string())
}
fn info_cookie(value: String) -> Cookie<'static> {
Cookie::build((INFO_COOKIE, value))
.path("/")
.same_site(SameSite::Lax)
.http_only(true)
.max_age(TimeDuration::hours(INFO_MAX_AGE_HOURS))
.build()
}
fn cleared_info_cookie() -> Cookie<'static> {
Cookie::build((INFO_COOKIE, ""))
.path("/")
.same_site(SameSite::Lax)
.max_age(TimeDuration::seconds(0))
.build()
}
fn encode_info(info: &CheckoutInfo) -> String {
URL_SAFE_NO_PAD.encode(serde_json::to_vec(info).unwrap_or_default())
}
fn decode_info(jar: &CookieJar) -> Option<CheckoutInfo> {
let raw = jar.get(INFO_COOKIE)?;
let bytes = URL_SAFE_NO_PAD.decode(raw.value()).ok()?;
serde_json::from_slice(&bytes).ok()
}
async fn enabled_shipping_methods(ctx: &AppContext) -> Result<Vec<shipping_methods::Model>> {
shipping_rules::disable_packeta_if_unconfigured(ctx).await?;
let packeta_ready = shipping_rules::packeta_ready(ctx);
@@ -73,8 +142,17 @@ async fn enabled_payment_methods(ctx: &AppContext) -> Result<Vec<payment_methods
Ok(payment_methods::Entity::enabled(&ctx.db).await?)
}
/// `/checkout` lands on the first wizard step.
#[debug_handler]
async fn checkout_page(
async fn checkout_redirect() -> Result<Response> {
format::redirect("/checkout/info")
}
/// Step 1 page: contact, residence/delivery addresses and (for companies)
/// invoicing details. Prefilled from a returning customer's profile, or from
/// the `checkout_info` cookie when the buyer steps back from the payment page.
#[debug_handler]
async fn info_page(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
@@ -86,6 +164,180 @@ async fn checkout_page(
return format::redirect("/cart");
}
// 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<String>| {
profile.as_ref().and_then(get)
};
// A previously entered info step (back navigation from the payment page)
// takes precedence over the profile defaults.
let saved = decode_info(&jar);
let s = |get: fn(&CheckoutInfo) -> String| saved.as_ref().map(get);
let s_opt = |get: fn(&CheckoutInfo) -> Option<String>| saved.as_ref().and_then(get);
let prefill_account_type = if is_customer {
user.as_ref().map_or("personal", |u| u.account_type.as_str()).to_string()
} else {
saved.as_ref().map_or_else(|| "personal".to_string(), |s| s.account_type.clone())
};
format::view(
&v,
"shop/checkout_info.html",
json!({
"items": lines,
"subtotal": format_price(subtotal),
"subtotal_cents": subtotal,
"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()),
// A logged-in customer's account type is fixed; only guests pick it.
"account_fixed": is_customer,
"prefill_email": s(|x| x.email.clone()).or_else(|| user.as_ref().filter(|_| is_customer).map(|u| u.email.clone())),
"prefill_name": s(|x| x.customer_name.clone()).or_else(|| user.as_ref().filter(|_| is_customer).map(|u| u.name.clone())),
"prefill_account_type": prefill_account_type,
"prefill_company_name": s_opt(|x| x.company_name.clone()).or_else(|| p(|x| x.company_name.clone())),
"prefill_company_id": s_opt(|x| x.company_id.clone()).or_else(|| p(|x| x.company_id.clone())),
"prefill_tax_id": s_opt(|x| x.tax_id.clone()).or_else(|| p(|x| x.tax_id.clone())),
"prefill_vat_id": s_opt(|x| x.vat_id.clone()).or_else(|| p(|x| x.vat_id.clone())),
"prefill_phone_prefix": s(|x| x.phone_prefix.clone()).or_else(|| p(|x| x.phone_prefix.clone())),
"prefill_phone": s(|x| x.phone.clone()).or_else(|| p(|x| x.phone.clone())),
"prefill_residence_address": s(|x| x.residence_address.clone()).or_else(|| p(|x| x.address.clone())),
"prefill_residence_city": s(|x| x.residence_city.clone()).or_else(|| p(|x| x.city.clone())),
"prefill_residence_zip": s(|x| x.residence_zip.clone()).or_else(|| p(|x| x.zip.clone())),
"prefill_residence_country": s(|x| x.residence_country.clone()).or_else(|| p(|x| x.country.clone())),
"prefill_delivery_same": saved.as_ref().is_some_and(|x| x.delivery_same),
"prefill_delivery_address": s(|x| x.address.clone()),
"prefill_delivery_city": s(|x| x.city.clone()),
"prefill_delivery_zip": s(|x| x.zip.clone()),
"prefill_delivery_country": s(|x| x.country.clone()),
"lang": current_lang(&jar),
}),
)
}
/// Validate step 1, stash it in the `checkout_info` cookie and advance to the
/// payment step.
#[debug_handler]
async fn submit_info(
jar: CookieJar,
State(ctx): State<AppContext>,
Form(form): Form<InfoForm>,
) -> Result<Response> {
let (_lines, valid, _total) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if valid.is_empty() {
return format::redirect("/cart");
}
let require = |value: &str, field: &str| -> Result<String> {
trimmed(value).ok_or_else(|| Error::BadRequest(format!("{field} is required")))
};
let require_opt = |value: Option<&str>, field: &str| -> Result<String> {
value
.and_then(trimmed)
.ok_or_else(|| Error::BadRequest(format!("{field} is required")))
};
let email = require(&form.email, "email")?;
let number = require(&form.phone, "phone")?;
let customer_name = require(&form.customer_name, "name")?;
let residence_address = require(&form.residence_address, "residence address")?;
let residence_city = require(&form.residence_city, "residence city")?;
let residence_zip = require(&form.residence_zip, "residence zip")?;
let residence_country = require(&form.residence_country, "residence country")?;
let delivery_same = form.delivery_same_as_residence.is_some();
let (address, city, zip, country) = if delivery_same {
(
residence_address.clone(),
residence_city.clone(),
residence_zip.clone(),
residence_country.clone(),
)
} else {
(
require_opt(form.address.as_deref(), "delivery address")?,
require_opt(form.city.as_deref(), "delivery city")?,
require_opt(form.zip.as_deref(), "delivery zip")?,
require_opt(form.country.as_deref(), "delivery 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 guests.
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)
};
let info = CheckoutInfo {
email,
phone_prefix: trimmed(&form.phone_prefix).unwrap_or_default(),
phone: number,
customer_name,
account_type,
company_name,
company_id,
tax_id,
vat_id,
residence_address,
residence_city,
residence_zip,
residence_country,
delivery_same,
address,
city,
zip,
country,
};
let jar = jar.add(info_cookie(encode_info(&info)));
Ok((jar, Redirect::to("/checkout/payment")).into_response())
}
/// Step 2 page: carrier (with optional pickup point) and payment method, plus
/// the order summary. Requires the info step to have been completed.
#[debug_handler]
async fn payment_page(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if lines.is_empty() {
return format::redirect("/cart");
}
if decode_info(&jar).is_none() {
return format::redirect("/checkout/info");
}
let methods: Vec<serde_json::Value> = enabled_shipping_methods(&ctx)
.await?
.iter()
@@ -110,8 +362,6 @@ async fn checkout_page(
})
.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;
@@ -119,19 +369,15 @@ async fn checkout_page(
(Some(u), true) => customer_profiles::Model::find_for_user(&ctx.db, u.id).await?,
_ => None,
};
let p = |get: fn(&customer_profiles::Model) -> Option<String>| {
profile.as_ref().and_then(get)
};
// Whether the customer already has a residence 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.
// the "save this address to my profile" opt-in is pointless, so it's hidden.
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",
"shop/checkout_payment.html",
json!({
"items": lines,
"subtotal": format_price(subtotal),
@@ -141,29 +387,11 @@ async fn checkout_page(
"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_residence_address": p(|x| x.address.clone()),
"prefill_residence_city": p(|x| x.city.clone()),
"prefill_residence_zip": p(|x| x.zip.clone()),
"prefill_residence_country": p(|x| x.country.clone()),
"lang": current_lang(&jar),
}),
)
@@ -173,75 +401,41 @@ async fn checkout_page(
async fn place_order(
jar: CookieJar,
State(ctx): State<AppContext>,
Form(form): Form<CheckoutForm>,
Form(form): Form<PaymentForm>,
) -> Result<Response> {
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(),
let Some(info) = decode_info(&jar) else {
return format::redirect("/checkout/info");
};
// Contact and residence-address fields are mandatory (also enforced in the
// browser via `required`).
let require = |value: &str, field: &str| -> Result<String> {
trimmed(value).ok_or_else(|| Error::BadRequest(format!("{field} is required")))
};
let require_opt = |value: Option<&str>, field: &str| -> Result<String> {
value
.and_then(trimmed)
.ok_or_else(|| Error::BadRequest(format!("{field} is required")))
};
let customer_name = require(&form.customer_name, "name")?;
let residence_address = require(&form.residence_address, "residence address")?;
let residence_city = require(&form.residence_city, "residence city")?;
let residence_zip = require(&form.residence_zip, "residence zip")?;
let residence_country = require(&form.residence_country, "residence country")?;
let same_address = form.delivery_same_as_residence.is_some();
let (address, city, zip, country) = if same_address {
(
residence_address.clone(),
residence_city.clone(),
residence_zip.clone(),
residence_country.clone(),
)
let email = info.email.clone();
let customer_name = info.customer_name.clone();
let number = info.phone.clone();
// Combine the dialling-code prefix with the local number into one E.164-ish
// value (e.g. "+421 900123456").
let phone = if info.phone_prefix.is_empty() {
number.clone()
} else {
(
require_opt(form.address.as_deref(), "delivery address")?,
require_opt(form.city.as_deref(), "delivery city")?,
require_opt(form.zip.as_deref(), "delivery zip")?,
require_opt(form.country.as_deref(), "delivery country")?,
)
format!("{} {}", info.phone_prefix, number)
};
// 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.
// account, never the cookie); a guest's choice rides in the info cookie.
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 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()),
None => info.account_type.clone(),
};
// 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),
info.company_name.clone(),
info.company_id.clone(),
info.tax_id.clone(),
info.vat_id.clone(),
)
} else {
(None, None, None, None)
@@ -274,19 +468,19 @@ async fn place_order(
(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).
// The address/contact captured in the info step, 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_prefix: trimmed(&info.phone_prefix),
phone: Some(number.clone()),
address: Some(residence_address.clone()),
city: Some(residence_city.clone()),
zip: Some(residence_zip.clone()),
country: Some(residence_country.clone()),
address: Some(info.residence_address.clone()),
city: Some(info.residence_city.clone()),
zip: Some(info.residence_zip.clone()),
country: Some(info.residence_country.clone()),
};
// Resolve the account that will own this order. A logged-in customer always
@@ -355,14 +549,14 @@ async fn place_order(
company_id,
tax_id,
vat_id,
residence_address: Some(residence_address),
residence_city: Some(residence_city),
residence_zip: Some(residence_zip),
residence_country: Some(residence_country),
address: Some(address),
city: Some(city),
zip: Some(zip),
country: Some(country),
residence_address: Some(info.residence_address.clone()),
residence_city: Some(info.residence_city.clone()),
residence_zip: Some(info.residence_zip.clone()),
residence_country: Some(info.residence_country.clone()),
address: Some(info.address.clone()),
city: Some(info.city.clone()),
zip: Some(info.zip.clone()),
country: Some(info.country.clone()),
note: form.note.as_deref().and_then(trimmed),
payment_method: form.payment_method,
method,
@@ -382,7 +576,7 @@ async fn place_order(
cart::clear_account_cart(&ctx, user.id).await?;
}
format::render()
.cookies(&[cart::cleared_cart_cookie()])?
.cookies(&[cart::cleared_cart_cookie(), cleared_info_cookie()])?
.redirect(&target)
}
@@ -430,7 +624,10 @@ async fn order_confirmation(
pub fn routes() -> Routes {
Routes::new()
.add("/checkout", get(checkout_page))
.add("/checkout", post(place_order))
.add("/checkout", get(checkout_redirect))
.add("/checkout/info", get(info_page))
.add("/checkout/info", post(submit_info))
.add("/checkout/payment", get(payment_page))
.add("/checkout/payment", post(place_order))
.add("/orders/{order_number}", get(order_confirmation))
}