price change while ordering

This commit is contained in:
Priec
2026-06-28 23:37:40 +02:00
parent 1168da8f11
commit 4c12972422
6 changed files with 118 additions and 26 deletions

View File

@@ -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, &quote));
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)
}