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,6 +53,7 @@ impl Hooks for App {
Ok(vec![
Box::new(initializers::view_engine::ViewEngineInitializer),
Box::new(initializers::admin_seeder::AdminSeeder),
Box::new(initializers::shipping_seeder::ShippingSeeder),
])
}

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))
}

View File

@@ -1,2 +1,3 @@
pub mod admin_seeder;
pub mod shipping_seeder;
pub mod view_engine;

View File

@@ -0,0 +1,48 @@
use async_trait::async_trait;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use crate::models::_entities::shipping_methods;
/// (code, display name, price in cents, requires a pickup point)
const CARRIERS: [(&str, &str, i64, bool); 3] = [
("packeta", "Packeta", 300, true),
("dpd", "DPD", 450, false),
("dhl", "DHL", 500, false),
];
pub struct ShippingSeeder;
#[async_trait]
impl Initializer for ShippingSeeder {
fn name(&self) -> String {
"shipping-seeder".to_string()
}
async fn before_run(&self, ctx: &AppContext) -> Result<()> {
for (position, (code, name, price_cents, requires_pickup_point)) in
CARRIERS.iter().enumerate()
{
let exists = shipping_methods::Entity::find()
.filter(shipping_methods::Column::Code.eq(*code))
.one(&ctx.db)
.await?
.is_some();
if exists {
continue;
}
shipping_methods::ActiveModel {
code: Set((*code).to_string()),
name: Set((*name).to_string()),
price_cents: Set(*price_cents),
requires_pickup_point: Set(*requires_pickup_point),
enabled: Set(true),
position: Set(position as i32),
..Default::default()
}
.insert(&ctx.db)
.await?;
}
Ok(())
}
}

View File

@@ -10,4 +10,5 @@ pub mod product_images;
pub mod product_product_tags;
pub mod product_tags;
pub mod products;
pub mod shipping_methods;
pub mod users;

View File

@@ -23,6 +23,12 @@ pub struct Model {
pub country: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub note: Option<String>,
pub payment_method: Option<String>,
pub carrier_code: Option<String>,
pub carrier_name: Option<String>,
pub shipping_cents: i64,
pub pickup_point_id: Option<String>,
pub pickup_point_name: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -8,4 +8,5 @@ pub use super::product_images::Entity as ProductImages;
pub use super::product_product_tags::Entity as ProductProductTags;
pub use super::product_tags::Entity as ProductTags;
pub use super::products::Entity as Products;
pub use super::shipping_methods::Entity as ShippingMethods;
pub use super::users::Entity as Users;

View File

@@ -0,0 +1,23 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "shipping_methods")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub code: String,
pub name: String,
pub price_cents: i64,
pub requires_pickup_point: bool,
pub enabled: bool,
pub position: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

View File

@@ -8,3 +8,4 @@ pub mod product_tags;
pub mod product_product_tags;
pub mod orders;
pub mod order_items;
pub mod shipping_methods;

View File

@@ -0,0 +1,28 @@
use sea_orm::entity::prelude::*;
pub use super::_entities::shipping_methods::{ActiveModel, Model, Entity};
pub type ShippingMethods = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
where
C: ConnectionTrait,
{
if !insert && self.updated_at.is_unchanged() {
let mut this = self;
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
Ok(this)
} else {
Ok(self)
}
}
}
// implement your read-oriented logic here
impl Model {}
// implement your write-oriented logic here
impl ActiveModel {}
// implement your custom finders, selectors oriented logic here
impl Entity {}