more webshop

This commit is contained in:
Priec
2026-06-16 19:27:29 +02:00
parent baf7522273
commit f0a6f97609
25 changed files with 583 additions and 33 deletions

View File

@@ -53,7 +53,7 @@ fn normalize_empty(value: Option<String>) -> Option<String> {
/// Parse a price typed in major units ("12", "12.5", "12.34") into integer
/// minor units (cents). Rejects negatives and more than two decimals.
fn parse_price_to_cents(value: &str) -> Result<i64> {
pub(crate) fn parse_price_to_cents(value: &str) -> Result<i64> {
let value = value.trim().replace(',', ".");
let invalid = || Error::BadRequest("invalid price".to_string());
let (whole, frac) = match value.split_once('.') {

View File

@@ -5,7 +5,7 @@ use crate::{
catalog::format_price,
i18n::current_lang,
},
models::_entities::{order_items, orders, products},
models::_entities::{order_items, orders, products, shipping_methods},
};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use loco_rs::prelude::*;
@@ -18,6 +18,7 @@ use time::Duration as TimeDuration;
use uuid::Uuid;
const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"];
const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"];
#[derive(Debug, Deserialize)]
struct CheckoutForm {
@@ -28,6 +29,26 @@ struct CheckoutForm {
zip: String,
country: String,
note: Option<String>,
payment_method: String,
carrier_code: String,
pickup_point_id: Option<String>,
pickup_point_name: Option<String>,
}
fn setting<'a>(ctx: &'a AppContext, key: &str) -> Option<&'a str> {
ctx.config
.settings
.as_ref()
.and_then(|settings| settings.get(key))
.and_then(|value| value.as_str())
}
async fn enabled_shipping_methods(ctx: &AppContext) -> Result<Vec<shipping_methods::Model>> {
Ok(shipping_methods::Entity::find()
.filter(shipping_methods::Column::Enabled.eq(true))
.order_by_asc(shipping_methods::Column::Position)
.all(&ctx.db)
.await?)
}
#[derive(Debug, Deserialize)]
@@ -59,7 +80,7 @@ async fn checkout_page(
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let (lines, _valid, total) = resolve_cart(&ctx, &jar).await?;
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar).await?;
if lines.is_empty() {
return format::redirect("/cart");
}
@@ -69,13 +90,30 @@ async fn checkout_page(
.unwrap_or("EUR")
.to_string();
let methods: Vec<serde_json::Value> = 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,
"total": format_price(total),
"subtotal": format_price(subtotal),
"subtotal_cents": subtotal,
"currency": currency,
"shipping_methods": methods,
"packeta_api_key": setting(&ctx, "packeta_api_key").unwrap_or(""),
"lang": current_lang(&jar),
}),
)
@@ -94,11 +132,35 @@ async fn place_order(
let email = trimmed(&form.email)
.ok_or_else(|| Error::BadRequest("email is required".to_string()))?;
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 txn = ctx.db.begin().await?;
// Snapshot prices/names and decrement stock atomically. Re-checking stock
// inside the transaction guards against it selling out between cart and pay.
let mut total: i64 = 0;
let mut subtotal: i64 = 0;
let mut currency = "EUR".to_string();
let mut snapshots = Vec::new();
for (product_id, qty) in &valid {
@@ -115,7 +177,7 @@ async fn place_order(
}
currency = product.currency.clone();
let line_total = product.price_cents * i64::from(*qty);
total += line_total;
subtotal += line_total;
let mut active = product.clone().into_active_model();
active.stock = Set(product.stock - *qty);
@@ -129,13 +191,19 @@ async fn place_order(
email: Set(email),
customer_name: Set(trimmed(&form.customer_name)),
status: Set("pending".to_string()),
total_cents: Set(total),
total_cents: Set(subtotal + method.price_cents),
currency: Set(currency),
address: Set(trimmed(&form.address)),
city: Set(trimmed(&form.city)),
zip: Set(trimmed(&form.zip)),
country: Set(trimmed(&form.country)),
note: Set(form.note.as_deref().and_then(trimmed)),
payment_method: Set(Some(form.payment_method.clone())),
carrier_code: Set(Some(method.code.clone())),
carrier_name: Set(Some(method.name.clone())),
shipping_cents: Set(method.price_cents),
pickup_point_id: Set(pickup_point_id),
pickup_point_name: Set(pickup_point_name),
..Default::default()
}
.insert(&txn)
@@ -186,6 +254,8 @@ async fn order_with_items(
"email": order.email,
"customer_name": order.customer_name,
"status": order.status,
"subtotal": format_price(order.total_cents - order.shipping_cents),
"shipping": format_price(order.shipping_cents),
"total": format_price(order.total_cents),
"currency": order.currency,
"address": order.address,
@@ -193,6 +263,13 @@ async fn order_with_items(
"zip": order.zip,
"country": order.country,
"note": order.note,
"payment_method": order.payment_method,
"carrier_name": order.carrier_name,
"pickup_point_name": order.pickup_point_name,
// Numeric, sequential order id doubles as the bank variable symbol.
"variable_symbol": order.id,
"bank_iban": setting(ctx, "bank_iban").unwrap_or(""),
"bank_account_name": setting(ctx, "bank_account_name").unwrap_or(""),
"created_at": order.created_at.to_rfc3339(),
});
Ok((order_json, items_json))
@@ -283,6 +360,66 @@ async fn admin_order_show(
)
}
#[debug_handler]
async fn admin_shipping(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let methods = shipping_methods::Entity::find()
.order_by_asc(shipping_methods::Column::Position)
.all(&ctx.db)
.await?;
let rows: Vec<serde_json::Value> = methods
.iter()
.map(|m| {
json!({
"id": m.id,
"code": m.code,
"name": m.name,
"price": format_price(m.price_cents),
"requires_pickup_point": m.requires_pickup_point,
"enabled": m.enabled,
})
})
.collect();
format::view(
&v,
"admin/shipping/index.html",
json!({ "methods": rows, "lang": current_lang(&jar) }),
)
}
#[derive(Debug, Deserialize)]
struct ShippingForm {
price: String,
enabled: Option<String>,
}
#[debug_handler]
async fn admin_shipping_update(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Form(form): Form<ShippingForm>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let method = shipping_methods::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let mut active = method.into_active_model();
active.price_cents = Set(crate::controllers::catalog::parse_price_to_cents(&form.price)?);
active.enabled = Set(matches!(
form.enabled.as_deref(),
Some("on" | "true" | "1")
));
active.update(&ctx.db).await?;
format::redirect("/admin/shipping")
}
#[debug_handler]
async fn admin_order_status(
auth: auth::JWT,
@@ -313,4 +450,6 @@ pub fn routes() -> Routes {
.add("/admin/orders", get(admin_orders))
.add("/admin/orders/{id}", get(admin_order_show))
.add("/admin/orders/{id}/status", post(admin_order_status))
.add("/admin/shipping", get(admin_shipping))
.add("/admin/shipping/{id}", post(admin_shipping_update))
}