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

@@ -386,6 +386,7 @@ field-optional = optional
checkout-place-order = Place order checkout-place-order = Place order
checkout-continue-payment = Continue checkout-continue-payment = Continue
checkout-back-info = Back to details checkout-back-info = Back to details
checkout-price-changed = The price of an item in your basket has changed since you opened this page. Please review the updated total below before placing your order.
checkout-summary = Order summary checkout-summary = Order summary
profile-title = My profile profile-title = My profile
profile-intro = We'll use these details to prefill checkout. profile-intro = We'll use these details to prefill checkout.

View File

@@ -386,6 +386,7 @@ field-optional = nepovinné
checkout-place-order = Odoslať objednávku checkout-place-order = Odoslať objednávku
checkout-continue-payment = Pokračovať checkout-continue-payment = Pokračovať
checkout-back-info = Späť na údaje checkout-back-info = Späť na údaje
checkout-price-changed = Cena niektorej položky v košíku sa od otvorenia tejto stránky zmenila. Pred odoslaním objednávky si prosím skontrolujte aktualizovanú sumu nižšie.
checkout-summary = Súhrn objednávky checkout-summary = Súhrn objednávky
profile-title = Môj profil profile-title = Môj profil
profile-intro = Tieto údaje použijeme na predvyplnenie pokladne. profile-intro = Tieto údaje použijeme na predvyplnenie pokladne.

File diff suppressed because one or more lines are too long

View File

@@ -12,6 +12,13 @@
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-title", lang=lang | default(value='sk')) }}</h1> <h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-title", lang=lang | default(value='sk')) }}</h1>
{% if price_changed %}
<div role="alert" class="mt-4 flex items-start gap-3 rounded-radius border border-warning/40 bg-warning/10 p-4 text-sm text-on-surface dark:text-on-surface-dark">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-5 shrink-0 text-warning" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" /></svg>
<span>{{ t(key="checkout-price-changed", lang=lang | default(value='sk')) }}</span>
</div>
{% endif %}
<form method="post" action="/checkout/payment" hx-boost="false" <form method="post" action="/checkout/payment" hx-boost="false"
x-data="{ x-data="{
paymentMethod: '', paymentMethod: '',
@@ -35,6 +42,7 @@
class="mt-6 grid gap-8 lg:grid-cols-3"> class="mt-6 grid gap-8 lg:grid-cols-3">
{{ ui::csrf_field() }} {{ ui::csrf_field() }}
<div class="space-y-6 lg:col-span-2"> <div class="space-y-6 lg:col-span-2">
<!-- carrier --> <!-- carrier -->
<fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt"> <fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">

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 base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use loco_rs::prelude::*; use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
use serde::{Deserialize, Serialize}; use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use time::Duration as TimeDuration; use time::Duration as TimeDuration;
use crate::{ use crate::{
@@ -29,6 +29,7 @@ use crate::{
}; };
const INFO_COOKIE: &str = "checkout_info"; const INFO_COOKIE: &str = "checkout_info";
const QUOTE_COOKIE: &str = "checkout_quote";
const INFO_MAX_AGE_HOURS: i64 = 2; const INFO_MAX_AGE_HOURS: i64 = 2;
/// The contact + address details captured on `/checkout/info`, carried to the /// The contact + address details captured on `/checkout/info`, carried to the
@@ -56,6 +57,18 @@ struct CheckoutInfo {
country: String, 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`). /// Step 1 form (`POST /checkout/info`).
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct InfoForm { struct InfoForm {
@@ -98,8 +111,11 @@ fn trimmed(value: &str) -> Option<String> {
(!value.is_empty()).then(|| value.to_string()) (!value.is_empty()).then(|| value.to_string())
} }
fn info_cookie(value: String) -> Cookie<'static> { /// A short-lived, base64-encoded JSON state cookie used to carry checkout state
Cookie::build((INFO_COOKIE, value)) /// (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("/") .path("/")
.same_site(SameSite::Lax) .same_site(SameSite::Lax)
.http_only(true) .http_only(true)
@@ -107,20 +123,16 @@ fn info_cookie(value: String) -> Cookie<'static> {
.build() .build()
} }
fn cleared_info_cookie() -> Cookie<'static> { fn cleared_cookie(name: &'static str) -> Cookie<'static> {
Cookie::build((INFO_COOKIE, "")) Cookie::build((name, ""))
.path("/") .path("/")
.same_site(SameSite::Lax) .same_site(SameSite::Lax)
.max_age(TimeDuration::seconds(0)) .max_age(TimeDuration::seconds(0))
.build() .build()
} }
fn encode_info(info: &CheckoutInfo) -> String { fn decode_state<T: DeserializeOwned>(jar: &CookieJar, name: &str) -> Option<T> {
URL_SAFE_NO_PAD.encode(serde_json::to_vec(info).unwrap_or_default()) let raw = jar.get(name)?;
}
fn decode_info(jar: &CookieJar) -> Option<CheckoutInfo> {
let raw = jar.get(INFO_COOKIE)?;
let bytes = URL_SAFE_NO_PAD.decode(raw.value()).ok()?; let bytes = URL_SAFE_NO_PAD.decode(raw.value()).ok()?;
serde_json::from_slice(&bytes).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) // A previously entered info step (back navigation from the payment page)
// takes precedence over the profile defaults. // 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 = |get: fn(&CheckoutInfo) -> String| saved.as_ref().map(get);
let s_opt = |get: fn(&CheckoutInfo) -> Option<String>| saved.as_ref().and_then(get); let s_opt = |get: fn(&CheckoutInfo) -> Option<String>| saved.as_ref().and_then(get);
@@ -318,7 +330,7 @@ async fn submit_info(
country, 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()) Ok((jar, Redirect::to("/checkout/payment")).into_response())
} }
@@ -328,18 +340,19 @@ async fn submit_info(
async fn payment_page( async fn payment_page(
jar: CookieJar, jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
Query(params): Query<std::collections::HashMap<String, String>>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar, &Currency::eur()).await?; let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if lines.is_empty() { if lines.is_empty() {
return format::redirect("/cart"); return format::redirect("/cart");
} }
if decode_info(&jar).is_none() { if decode_state::<CheckoutInfo>(&jar, INFO_COOKIE).is_none() {
return format::redirect("/checkout/info"); return format::redirect("/checkout/info");
} }
let methods: Vec<serde_json::Value> = enabled_shipping_methods(&ctx) let shipping_models = enabled_shipping_methods(&ctx).await?;
.await? let methods: Vec<serde_json::Value> = shipping_models
.iter() .iter()
.map(|m| { .map(|m| {
json!({ json!({
@@ -351,6 +364,16 @@ async fn payment_page(
}) })
}) })
.collect(); .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) let payments: Vec<serde_json::Value> = enabled_payment_methods(&ctx)
.await? .await?
.iter() .iter()
@@ -375,7 +398,7 @@ async fn payment_page(
.as_ref() .as_ref()
.is_some_and(|pr| pr.address.is_some() && pr.city.is_some() && pr.zip.is_some()); .is_some_and(|pr| pr.address.is_some() && pr.city.is_some() && pr.zip.is_some());
format::view( let response = format::view(
&v, &v,
"shop/checkout_payment.html", "shop/checkout_payment.html",
json!({ json!({
@@ -392,9 +415,15 @@ async fn payment_page(
"customer_avatar": user.as_ref().filter(|_| is_customer).and_then(|u| u.avatar_id.clone()), "customer_avatar": user.as_ref().filter(|_| is_customer).and_then(|u| u.avatar_id.clone()),
"profile_filled": profile_filled, "profile_filled": profile_filled,
"can_create_account": user.is_none(), "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), "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] #[debug_handler]
@@ -403,13 +432,18 @@ async fn place_order(
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
Form(form): Form<PaymentForm>, Form(form): Form<PaymentForm>,
) -> Result<Response> { ) -> 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() { if valid.is_empty() {
return format::redirect("/cart"); 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"); 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 email = info.email.clone();
let customer_name = info.customer_name.clone(); let customer_name = info.customer_name.clone();
@@ -468,6 +502,22 @@ async fn place_order(
(None, None) (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 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). // the logged-in "save my address" opt-in or a freshly created guest account).
let entered_profile = || ProfileFields { let entered_profile = || ProfileFields {
@@ -564,9 +614,16 @@ async fn place_order(
pickup_point_name, pickup_point_name,
}, },
logged_in_customer, logged_in_customer,
Some(expected_total),
) )
.await?; .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 { let target = if account_created {
format!("/orders/{}?account_created=1", order.order_number) format!("/orders/{}?account_created=1", order.order_number)
} else { } else {
@@ -576,7 +633,11 @@ async fn place_order(
cart::clear_account_cart(&ctx, user.id).await?; cart::clear_account_cart(&ctx, user.id).await?;
} }
format::render() 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) .redirect(&target)
} }

View File

@@ -47,13 +47,22 @@ fn generate_order_number() -> String {
/// Atomically place an order for the given `(variant_id, quantity)` lines: /// Atomically place an order for the given `(variant_id, quantity)` lines:
/// snapshot each variant's price/name/label, decrement its stock (re-checking /// snapshot each variant's price/name/label, decrement its stock (re-checking
/// inside the transaction so an item can't oversell between cart and pay), then /// inside the transaction so an item can't oversell between cart and pay), then
/// write the order and its line items. Returns the persisted order. /// write the order and its line items.
///
/// `expected_total_cents` is the order total (items + shipping) the buyer
/// confirmed on the payment page. When set, the total this call is about to
/// charge is compared against it *using the same prices that build the order*;
/// if they differ (an item or shipping price changed since the buyer looked),
/// the transaction is rolled back and `Ok(None)` is returned so the caller can
/// send the buyer back to re-confirm — they are never charged a stale or a
/// surprise price. `Ok(Some(order))` is the placed order.
pub async fn place( pub async fn place(
ctx: &AppContext, ctx: &AppContext,
items: &[(i32, i32)], items: &[(i32, i32)],
details: Checkout, details: Checkout,
user: Option<&users::Model>, user: Option<&users::Model>,
) -> Result<Model> { expected_total_cents: Option<i64>,
) -> Result<Option<Model>> {
// Resolve the price of every line *before* opening the transaction. Pricing // Resolve the price of every line *before* opening the transaction. Pricing
// loads its context from the connection pool; doing it while the order // loads its context from the connection pool; doing it while the order
// transaction holds a connection would acquire a second one and can exhaust // transaction holds a connection would acquire a second one and can exhaust
@@ -112,13 +121,25 @@ pub async fn place(
snapshots.push((product.id, variant.id, product.name, variant.label, unit_price_cents, *qty)); snapshots.push((product.id, variant.id, product.name, variant.label, unit_price_cents, *qty));
} }
let charged_total = subtotal + details.method.price_cents;
// If the buyer confirmed a total that no longer matches what we'd charge (an
// item or shipping price moved in the meantime), abort without placing. The
// transaction is dropped without committing, rolling back the stock
// decrements above.
if let Some(expected) = expected_total_cents {
if expected != charged_total {
return Ok(None);
}
}
let order = ActiveModel { let order = ActiveModel {
order_number: Set(generate_order_number()), order_number: Set(generate_order_number()),
email: Set(details.email), email: Set(details.email),
phone: Set(Some(details.phone)), phone: Set(Some(details.phone)),
customer_name: Set(details.customer_name), customer_name: Set(details.customer_name),
status: Set("pending".to_string()), status: Set("pending".to_string()),
total_cents: Set(subtotal + details.method.price_cents), total_cents: Set(charged_total),
user_id: Set(details.user_id), user_id: Set(details.user_id),
account_type: Set(details.account_type), account_type: Set(details.account_type),
company_name: Set(details.company_name), company_name: Set(details.company_name),
@@ -161,7 +182,7 @@ pub async fn place(
} }
txn.commit().await?; txn.commit().await?;
Ok(order) Ok(Some(order))
} }
#[async_trait::async_trait] #[async_trait::async_trait]