price change while ordering
This commit is contained in:
@@ -12,7 +12,7 @@ use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use time::Duration as TimeDuration;
|
||||
use crate::{
|
||||
@@ -29,6 +29,7 @@ use crate::{
|
||||
};
|
||||
|
||||
const INFO_COOKIE: &str = "checkout_info";
|
||||
const QUOTE_COOKIE: &str = "checkout_quote";
|
||||
const INFO_MAX_AGE_HOURS: i64 = 2;
|
||||
|
||||
/// The contact + address details captured on `/checkout/info`, carried to the
|
||||
@@ -56,6 +57,18 @@ struct CheckoutInfo {
|
||||
country: String,
|
||||
}
|
||||
|
||||
/// A snapshot of the prices the buyer was shown on the payment page, stashed in
|
||||
/// the `checkout_quote` cookie when that page renders. On submit the order total
|
||||
/// is re-checked against this so a price (item *or* shipping) that changed in the
|
||||
/// meantime can't be charged silently — the whole guard is server-side, with
|
||||
/// nothing price-related trusted from the form. `shipping` maps carrier code to
|
||||
/// the price shown for it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CheckoutQuote {
|
||||
subtotal_cents: i64,
|
||||
shipping: std::collections::HashMap<String, i64>,
|
||||
}
|
||||
|
||||
/// Step 1 form (`POST /checkout/info`).
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct InfoForm {
|
||||
@@ -98,8 +111,11 @@ fn trimmed(value: &str) -> Option<String> {
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
|
||||
fn info_cookie(value: String) -> Cookie<'static> {
|
||||
Cookie::build((INFO_COOKIE, value))
|
||||
/// A short-lived, base64-encoded JSON state cookie used to carry checkout state
|
||||
/// (the info step, the price quote) between the wizard's pages.
|
||||
fn state_cookie<T: Serialize>(name: &'static str, value: &T) -> Cookie<'static> {
|
||||
let encoded = URL_SAFE_NO_PAD.encode(serde_json::to_vec(value).unwrap_or_default());
|
||||
Cookie::build((name, encoded))
|
||||
.path("/")
|
||||
.same_site(SameSite::Lax)
|
||||
.http_only(true)
|
||||
@@ -107,20 +123,16 @@ fn info_cookie(value: String) -> Cookie<'static> {
|
||||
.build()
|
||||
}
|
||||
|
||||
fn cleared_info_cookie() -> Cookie<'static> {
|
||||
Cookie::build((INFO_COOKIE, ""))
|
||||
fn cleared_cookie(name: &'static str) -> Cookie<'static> {
|
||||
Cookie::build((name, ""))
|
||||
.path("/")
|
||||
.same_site(SameSite::Lax)
|
||||
.max_age(TimeDuration::seconds(0))
|
||||
.build()
|
||||
}
|
||||
|
||||
fn encode_info(info: &CheckoutInfo) -> String {
|
||||
URL_SAFE_NO_PAD.encode(serde_json::to_vec(info).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn decode_info(jar: &CookieJar) -> Option<CheckoutInfo> {
|
||||
let raw = jar.get(INFO_COOKIE)?;
|
||||
fn decode_state<T: DeserializeOwned>(jar: &CookieJar, name: &str) -> Option<T> {
|
||||
let raw = jar.get(name)?;
|
||||
let bytes = URL_SAFE_NO_PAD.decode(raw.value()).ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
@@ -179,7 +191,7 @@ async fn info_page(
|
||||
|
||||
// A previously entered info step (back navigation from the payment page)
|
||||
// takes precedence over the profile defaults.
|
||||
let saved = decode_info(&jar);
|
||||
let saved = decode_state::<CheckoutInfo>(&jar, INFO_COOKIE);
|
||||
let s = |get: fn(&CheckoutInfo) -> String| saved.as_ref().map(get);
|
||||
let s_opt = |get: fn(&CheckoutInfo) -> Option<String>| saved.as_ref().and_then(get);
|
||||
|
||||
@@ -318,7 +330,7 @@ async fn submit_info(
|
||||
country,
|
||||
};
|
||||
|
||||
let jar = jar.add(info_cookie(encode_info(&info)));
|
||||
let jar = jar.add(state_cookie(INFO_COOKIE, &info));
|
||||
Ok((jar, Redirect::to("/checkout/payment")).into_response())
|
||||
}
|
||||
|
||||
@@ -328,18 +340,19 @@ async fn submit_info(
|
||||
async fn payment_page(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
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() {
|
||||
return format::redirect("/cart");
|
||||
}
|
||||
if decode_info(&jar).is_none() {
|
||||
if decode_state::<CheckoutInfo>(&jar, INFO_COOKIE).is_none() {
|
||||
return format::redirect("/checkout/info");
|
||||
}
|
||||
|
||||
let methods: Vec<serde_json::Value> = enabled_shipping_methods(&ctx)
|
||||
.await?
|
||||
let shipping_models = enabled_shipping_methods(&ctx).await?;
|
||||
let methods: Vec<serde_json::Value> = shipping_models
|
||||
.iter()
|
||||
.map(|m| {
|
||||
json!({
|
||||
@@ -351,6 +364,16 @@ async fn payment_page(
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Snapshot exactly what we're about to show the buyer (item subtotal + each
|
||||
// carrier's price) so the submit handler can detect a price change entirely
|
||||
// server-side, without trusting any figure from the form.
|
||||
let quote = CheckoutQuote {
|
||||
subtotal_cents: subtotal,
|
||||
shipping: shipping_models
|
||||
.iter()
|
||||
.map(|m| (m.code.clone(), m.price_cents))
|
||||
.collect(),
|
||||
};
|
||||
let payments: Vec<serde_json::Value> = enabled_payment_methods(&ctx)
|
||||
.await?
|
||||
.iter()
|
||||
@@ -375,7 +398,7 @@ async fn payment_page(
|
||||
.as_ref()
|
||||
.is_some_and(|pr| pr.address.is_some() && pr.city.is_some() && pr.zip.is_some());
|
||||
|
||||
format::view(
|
||||
let response = format::view(
|
||||
&v,
|
||||
"shop/checkout_payment.html",
|
||||
json!({
|
||||
@@ -392,9 +415,15 @@ async fn payment_page(
|
||||
"customer_avatar": user.as_ref().filter(|_| is_customer).and_then(|u| u.avatar_id.clone()),
|
||||
"profile_filled": profile_filled,
|
||||
"can_create_account": user.is_none(),
|
||||
// Set when the buyer was bounced back because a price changed between
|
||||
// viewing this page and submitting; shows a re-confirm notice.
|
||||
"price_changed": params.contains_key("price_changed"),
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
)?;
|
||||
// Carry the price snapshot to the submit step.
|
||||
let jar = jar.add(state_cookie(QUOTE_COOKIE, "e));
|
||||
Ok((jar, response).into_response())
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
@@ -403,13 +432,18 @@ async fn place_order(
|
||||
State(ctx): State<AppContext>,
|
||||
Form(form): Form<PaymentForm>,
|
||||
) -> Result<Response> {
|
||||
let (_lines, valid, _total) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
|
||||
let (_lines, valid, subtotal) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
|
||||
if valid.is_empty() {
|
||||
return format::redirect("/cart");
|
||||
}
|
||||
let Some(info) = decode_info(&jar) else {
|
||||
let Some(info) = decode_state::<CheckoutInfo>(&jar, INFO_COOKIE) else {
|
||||
return format::redirect("/checkout/info");
|
||||
};
|
||||
// The prices the buyer was shown on the payment page. Without it we can't
|
||||
// verify the total, so send them back to re-render (and re-quote).
|
||||
let Some(quote) = decode_state::<CheckoutQuote>(&jar, QUOTE_COOKIE) else {
|
||||
return format::redirect("/checkout/payment");
|
||||
};
|
||||
|
||||
let email = info.email.clone();
|
||||
let customer_name = info.customer_name.clone();
|
||||
@@ -468,6 +502,22 @@ async fn place_order(
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// The total the buyer confirmed: the snapshotted item subtotal plus the price
|
||||
// shown for the carrier they chose. If that no longer matches what we'd charge
|
||||
// now (current subtotal + current carrier price), a price moved since they
|
||||
// looked — bounce back to re-confirm, before any side effects (account
|
||||
// creation, emails). `orders::place` re-checks the same figure against the
|
||||
// exact prices it charges (the authoritative guard); this just avoids the side
|
||||
// effects in the common case. Everything is server-side: nothing price-related
|
||||
// is trusted from the form.
|
||||
let expected_total = match quote.shipping.get(&form.carrier_code) {
|
||||
Some(shown) => quote.subtotal_cents + shown,
|
||||
None => return format::redirect("/checkout/payment?price_changed=1"),
|
||||
};
|
||||
if subtotal + method.price_cents != expected_total {
|
||||
return format::redirect("/checkout/payment?price_changed=1");
|
||||
}
|
||||
|
||||
// The address/contact captured in the info step, ready to seed a profile (for
|
||||
// the logged-in "save my address" opt-in or a freshly created guest account).
|
||||
let entered_profile = || ProfileFields {
|
||||
@@ -564,9 +614,16 @@ async fn place_order(
|
||||
pickup_point_name,
|
||||
},
|
||||
logged_in_customer,
|
||||
Some(expected_total),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// A price moved between the buyer confirming and the order being written:
|
||||
// nothing was charged or placed; send them back to re-confirm the new total.
|
||||
let Some(order) = order else {
|
||||
return format::redirect("/checkout/payment?price_changed=1");
|
||||
};
|
||||
|
||||
let target = if account_created {
|
||||
format!("/orders/{}?account_created=1", order.order_number)
|
||||
} else {
|
||||
@@ -576,7 +633,11 @@ async fn place_order(
|
||||
cart::clear_account_cart(&ctx, user.id).await?;
|
||||
}
|
||||
format::render()
|
||||
.cookies(&[cart::cleared_cart_cookie(), cleared_info_cookie()])?
|
||||
.cookies(&[
|
||||
cart::cleared_cart_cookie(),
|
||||
cleared_cookie(INFO_COOKIE),
|
||||
cleared_cookie(QUOTE_COOKIE),
|
||||
])?
|
||||
.redirect(&target)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user