basket bug fix
This commit is contained in:
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user