basket bug fix

This commit is contained in:
Priec
2026-06-29 00:34:31 +02:00
parent 6c341e9f28
commit 72293d8318
7 changed files with 108 additions and 39 deletions

View File

@@ -173,12 +173,19 @@ async fn add(
let user = guard::current_user(&ctx, &jar).await;
let mut items = stored_cart(&ctx, user.as_ref(), &jar).await?;
let add_qty = form.quantity.unwrap_or(1).max(1);
// Clamp to on-hand stock when tracked and in stock; an out-of-stock (stock 0)
// item is still added at the requested quantity so it isn't silently dropped —
// it shows in the cart as unavailable until restocked or removed.
let new_qty = |desired: i32| match variant.stock {
Some(0) => desired.max(1),
Some(s) => desired.clamp(1, s),
None => desired.max(1),
};
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) {
entry.1 = variant.cap(entry.1 + add_qty);
entry.1 = new_qty(entry.1 + add_qty);
} else {
items.push((variant.id, variant.cap(add_qty)));
items.push((variant.id, new_qty(add_qty)));
}
items.retain(|(_, qty)| *qty > 0);
let jar = persist_cart(&ctx, jar, user.as_ref(), &items).await?;
@@ -253,17 +260,18 @@ async fn cart_response(
}
let cur = currency::resolve(ctx, &jar).await;
let (lines, valid, total) = resolve_cart(ctx, &jar, &cur).await?;
// Persist the re-validated cookie (drops now-invalid lines).
let cart = resolve_cart(ctx, &jar, &cur).await?;
// Persist the re-validated cookie (keeps out-of-stock lines, drops gone ones).
let user = guard::current_user(ctx, &jar).await;
let jar = persist_cart(ctx, jar, user.as_ref(), &valid).await?;
let jar = persist_cart(ctx, jar, user.as_ref(), &cart.stored).await?;
let response = format::view(
v,
"shop/_cart_body.html",
json!({
"items": lines,
"total": cur.format(total),
"items": cart.lines,
"total": cur.format(cart.total),
"currency_symbol": cur.symbol,
"has_unavailable": cart.has_unavailable,
"lang": current_lang(&jar),
}),
)?;
@@ -273,12 +281,25 @@ async fn cart_response(
/// Resolve the active cart into priced line items, dropping anything that is no
/// longer purchasable and clamping quantities to current stock. Guests resolve
/// from the cookie; authenticated users resolve from their account cart.
/// The outcome of resolving the active cart. `lines` is for display and includes
/// out-of-stock items (flagged `available: false`); `purchasable` is the in-stock
/// subset used for the `total` and for checkout; `stored` is the full set written
/// back to the cart (so out-of-stock items are *kept*, not silently dropped);
/// `has_unavailable` is set when any line is currently out of stock.
pub(crate) struct ResolvedCart {
pub lines: Vec<serde_json::Value>,
pub purchasable: Vec<(i32, i32)>,
pub stored: Vec<(i32, i32)>,
pub total: i64,
pub has_unavailable: bool,
}
pub(crate) async fn resolve_cart(
ctx: &AppContext,
jar: &CookieJar,
cur: &Currency,
) -> Result<(Vec<serde_json::Value>, Vec<(i32, i32)>, i64)> {
// Resolve the cart entries to in-stock products first, then price them all
) -> Result<ResolvedCart> {
// Resolve the cart entries to their (published) products, then price them all
// for the current viewer in one batch (the price depends on who's logged in).
let user = guard::current_user(ctx, jar).await;
let mut items: Vec<(product_variants::Model, products::Model, i32)> = Vec::new();
@@ -286,8 +307,7 @@ pub(crate) async fn resolve_cart(
let Some((variant, product)) = published_variant(ctx, id).await? else {
continue;
};
let qty = variant.cap(qty);
if qty == 0 {
if qty <= 0 {
continue;
}
items.push((variant, product, qty));
@@ -297,13 +317,29 @@ pub(crate) async fn resolve_cart(
let priced = pricing::price_variants(ctx, &variants_only, user.as_ref()).await?;
let mut lines = Vec::new();
let mut valid = Vec::new();
let mut purchasable = Vec::new();
let mut stored = Vec::new();
let mut total: i64 = 0;
let mut has_unavailable = false;
for ((variant, product, qty), priced) in items.iter().zip(priced.iter()) {
// Out-of-stock (tracked stock of 0) items are kept in the cart, shown as
// unavailable and excluded from the total/checkout, instead of being
// silently removed. In-stock quantities are clamped to what's on hand.
let out_of_stock = matches!(variant.stock, Some(0));
let store_qty = if out_of_stock { *qty } else { variant.cap(*qty) };
if store_qty <= 0 {
continue;
}
stored.push((variant.id, store_qty));
let unit_price = priced.price_cents;
let line_total = unit_price * i64::from(*qty);
total += line_total;
valid.push((variant.id, *qty));
let line_total = unit_price * i64::from(store_qty);
if out_of_stock {
has_unavailable = true;
} else {
total += line_total;
purchasable.push((variant.id, store_qty));
}
lines.push(json!({
"id": variant.id,
"name": product.name,
@@ -312,17 +348,25 @@ pub(crate) async fn resolve_cart(
"price": cur.format(unit_price),
"regular_price": cur.format(priced.regular_cents),
"on_sale": priced.is_reduced(),
"quantity": qty,
"quantity": store_qty,
"stock": variant.stock,
"available": !out_of_stock,
"out_of_stock": out_of_stock,
"line_total": cur.format(line_total),
}));
}
if let Some(user) = user.as_ref() {
account_cart_items::Model::replace_for_user(&ctx.db, user.id, &valid).await?;
account_cart_items::Model::replace_for_user(&ctx.db, user.id, &stored).await?;
}
Ok((lines, valid, total))
Ok(ResolvedCart {
lines,
purchasable,
stored,
total,
has_unavailable,
})
}
#[debug_handler]
@@ -332,16 +376,17 @@ async fn show(
State(ctx): State<AppContext>,
) -> Result<Response> {
let cur = currency::resolve(&ctx, &jar).await;
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
let cart = resolve_cart(&ctx, &jar, &cur).await?;
let c = guard::chrome(&ctx, &jar).await;
let response = format::view(
&v,
"shop/cart.html",
json!({
"items": lines,
"total": cur.format(total),
"items": cart.lines,
"total": cur.format(cart.total),
"currency_symbol": cur.symbol,
"has_unavailable": cart.has_unavailable,
"logged_in_admin": c.logged_in_admin,
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
@@ -352,7 +397,7 @@ async fn show(
)?;
let user = guard::current_user(&ctx, &jar).await;
let jar = persist_cart(&ctx, jar, user.as_ref(), &valid).await?;
let jar = persist_cart(&ctx, jar, user.as_ref(), &cart.stored).await?;
Ok((jar, response).into_response())
}
@@ -365,19 +410,20 @@ async fn preview(
State(ctx): State<AppContext>,
) -> Result<Response> {
let cur = currency::resolve(&ctx, &jar).await;
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
let cart = resolve_cart(&ctx, &jar, &cur).await?;
let response = format::view(
&v,
"shop/_cart_preview.html",
json!({
"items": lines,
"total": cur.format(total),
"items": cart.lines,
"total": cur.format(cart.total),
"currency_symbol": cur.symbol,
"has_unavailable": cart.has_unavailable,
"lang": current_lang(&jar),
}),
)?;
let user = guard::current_user(&ctx, &jar).await;
let jar = persist_cart(&ctx, jar, user.as_ref(), &valid).await?;
let jar = persist_cart(&ctx, jar, user.as_ref(), &cart.stored).await?;
Ok((jar, response).into_response())
}

View File

@@ -171,10 +171,13 @@ async fn info_page(
) -> Result<Response> {
// Checkout and everything past it (orders, confirmation) stay in the EUR
// base — the settlement currency — even when the buyer browsed in another.
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if lines.is_empty() {
let cart = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
// No purchasable lines, or some line is out of stock: back to the basket to
// resolve it before checking out.
if cart.purchasable.is_empty() || cart.has_unavailable {
return format::redirect("/cart");
}
let (lines, subtotal) = (cart.lines, cart.total);
// 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).
@@ -248,8 +251,8 @@ async fn submit_info(
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() {
let cart = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if cart.purchasable.is_empty() || cart.has_unavailable {
return format::redirect("/cart");
}
@@ -343,10 +346,11 @@ async fn payment_page(
Query(params): Query<std::collections::HashMap<String, String>>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if lines.is_empty() {
let cart = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if cart.purchasable.is_empty() || cart.has_unavailable {
return format::redirect("/cart");
}
let (lines, subtotal) = (cart.lines, cart.total);
if decode_state::<CheckoutInfo>(&jar, INFO_COOKIE).is_none() {
return format::redirect("/checkout/info");
}
@@ -446,10 +450,11 @@ async fn place_order(
State(ctx): State<AppContext>,
Form(form): Form<PaymentForm>,
) -> Result<Response> {
let (_lines, valid, subtotal) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if valid.is_empty() {
let cart = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if cart.purchasable.is_empty() || cart.has_unavailable {
return format::redirect("/cart");
}
let (valid, subtotal) = (cart.purchasable, cart.total);
let Some(info) = decode_state::<CheckoutInfo>(&jar, INFO_COOKIE) else {
return format::redirect("/checkout/info");
};