more webshop
This commit is contained in:
@@ -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('.') {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user