feat(api): flash sales resolved server-side at checkout

Shop-owned timed sessions carry SKU activity items with their own reserved
inventory and per-customer limit. Checkout resolves eligibility on the server,
locks candidate items by primary key after the SKU locks, and splits a cart line
into an activity-priced item plus a standard-priced remainder, so every unit
price snapshot is honest and a customer cannot exceed the limit.

Reserved activity stock and SKU stock decrement together under guards, and a
pending-payment cancellation restores both plus any redeemed coupon. Coupons are
rejected on a shop order that applied activity pricing, and a SKU cannot join
two overlapping enabled sessions.

The overlap check against group buying is present but dormant: that capability
lands later, so the check activates only once its table exists.

Surfaces (shop-admin, mall) and seeding follow.
This commit is contained in:
2026-09-18 12:47:46 +00:00
parent 82c1e8e4bb
commit 393e7f7209
12 changed files with 1521 additions and 36 deletions
+42
View File
@@ -0,0 +1,42 @@
-- Flash sales: a shop-owned timed session with SKU-level activity items that
-- hold their own reserved inventory and per-customer limit. An order line that
-- received activity pricing points at the item it consumed, so cancellation can
-- restore both the SKU stock and the reserved activity stock.
CREATE TABLE flash_sale_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
shop_id UUID NOT NULL REFERENCES shops (id) ON DELETE CASCADE,
label JSONB NOT NULL,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT flash_sale_sessions_window CHECK (ends_at >= starts_at)
);
CREATE INDEX flash_sale_sessions_shop_idx ON flash_sale_sessions (shop_id);
CREATE TABLE flash_sale_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES flash_sale_sessions (id) ON DELETE CASCADE,
sku_id UUID NOT NULL REFERENCES skus (id) ON DELETE CASCADE,
sale_price_minor BIGINT NOT NULL CHECK (sale_price_minor > 0),
currency CHAR(3) NOT NULL REFERENCES currencies (code),
-- Remaining activity inventory; decremented conditionally at checkout.
reserved_stock INT NOT NULL CHECK (reserved_stock >= 0),
sold_count INT NOT NULL DEFAULT 0 CHECK (sold_count >= 0),
per_customer_limit INT NOT NULL CHECK (per_customer_limit > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX flash_sale_items_session_sku_idx ON flash_sale_items (session_id, sku_id);
CREATE INDEX flash_sale_items_sku_idx ON flash_sale_items (sku_id);
-- Nullable: standard-priced lines keep NULL, and deleting an activity item
-- leaves the order line intact.
ALTER TABLE order_items
ADD COLUMN flash_sale_item_id UUID REFERENCES flash_sale_items (id) ON DELETE SET NULL;
CREATE INDEX idx_order_items_flash ON order_items (flash_sale_item_id);
+36
View File
@@ -208,6 +208,8 @@ pub struct OrderItem {
pub image: Option<String>,
pub unit_price_minor: i64,
pub qty: i32,
/// Set when this line received flash-sale pricing.
pub flash_sale_item_id: Option<Uuid>,
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
@@ -402,6 +404,40 @@ pub struct IntegralOrderItem {
pub const INTEGRAL_ORDER_ITEM_COLUMNS: &str =
"id, order_id, product_id, name, image, points_price, qty";
/// Shop-owned timed flash-sale session.
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct FlashSaleSession {
pub id: Uuid,
pub shop_id: Uuid,
pub label: serde_json::Value,
pub starts_at: DateTime<Utc>,
pub ends_at: DateTime<Utc>,
pub enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub const FLASH_SALE_SESSION_COLUMNS: &str =
"id, shop_id, label, starts_at, ends_at, enabled, created_at, updated_at";
/// SKU-level activity item with its own reserved inventory and customer limit.
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct FlashSaleItem {
pub id: Uuid,
pub session_id: Uuid,
pub sku_id: Uuid,
pub sale_price_minor: i64,
pub currency: String,
pub reserved_stock: i32,
pub sold_count: i32,
pub per_customer_limit: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub const FLASH_SALE_ITEM_COLUMNS: &str = "id, session_id, sku_id, sale_price_minor, currency, \
reserved_stock, sold_count, per_customer_limit, created_at, updated_at";
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct AddressBookEntry {
pub id: Uuid,
+86
View File
@@ -0,0 +1,86 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::models::FlashSaleSession;
#[derive(Deserialize)]
pub struct SessionInput {
pub label: serde_json::Value,
pub starts_at: DateTime<Utc>,
pub ends_at: DateTime<Utc>,
#[serde(default = "default_enabled")]
pub enabled: bool,
}
#[derive(Deserialize)]
pub struct ItemInput {
pub sku_id: Uuid,
pub sale_price_minor: i64,
pub currency: String,
pub reserved_stock: i32,
pub per_customer_limit: i32,
}
fn default_enabled() -> bool {
true
}
/// Shop-side item joined with the SKU and catalog price it discounts.
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct ShopItemView {
pub id: Uuid,
pub session_id: Uuid,
pub sku_id: Uuid,
pub sku_code: String,
pub product_name: serde_json::Value,
pub sale_price_minor: i64,
pub currency: String,
pub original_price_minor: i64,
pub original_currency: String,
pub reserved_stock: i32,
pub sold_count: i32,
pub per_customer_limit: i32,
}
#[derive(Debug, Serialize)]
pub struct ShopSessionView {
#[serde(flatten)]
pub session: FlashSaleSession,
pub items: Vec<ShopItemView>,
}
/// Public discovery item: everything a shopper needs to add it to the cart.
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct PublicItemView {
pub id: Uuid,
pub session_id: Uuid,
pub sku_id: Uuid,
pub product_id: Uuid,
pub product_slug: String,
pub product_name: serde_json::Value,
pub image: Option<String>,
pub sku_code: String,
pub sale_price_minor: i64,
pub currency: String,
pub original_price_minor: i64,
pub original_currency: String,
pub reserved_stock: i32,
pub sold_count: i32,
pub per_customer_limit: i32,
}
#[derive(Debug, Serialize)]
pub struct PublicSessionView {
#[serde(flatten)]
pub session: FlashSaleSession,
pub items: Vec<PublicItemView>,
}
/// The part of a cart line that received activity pricing.
#[derive(Debug, Clone, Copy)]
pub struct LineActivity {
pub item_id: Uuid,
pub unit_price_minor: i64,
pub qty: i32,
}
+114
View File
@@ -0,0 +1,114 @@
use axum::{
extract::{Path, State},
http::StatusCode,
routing::{get, post, put},
Json, Router,
};
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::error::ApiResult;
use crate::models::{FlashSaleItem, FlashSaleSession};
use crate::state::AppState;
use super::dto::{ItemInput, PublicSessionView, SessionInput, ShopSessionView};
use super::service;
pub fn router() -> Router<AppState> {
Router::new()
// Public: active sessions and their purchasable items.
.route("/flash-sales", get(public_active))
.route("/shop/flash-sales", get(shop_list).post(shop_create))
.route(
"/shop/flash-sales/{id}",
put(shop_update).delete(shop_delete),
)
.route("/shop/flash-sales/{id}/items", post(shop_add_item))
.route(
"/shop/flash-sale-items/{id}",
put(shop_update_item).delete(shop_delete_item),
)
}
async fn public_active(
State(state): State<AppState>,
) -> ApiResult<Json<Vec<PublicSessionView>>> {
Ok(Json(service::public_active(&state).await?))
}
async fn shop_list(
State(state): State<AppState>,
auth: AuthUser,
) -> ApiResult<Json<Vec<ShopSessionView>>> {
let shop_id = auth.require_shop()?;
Ok(Json(service::list_for_shop(&state, shop_id).await?))
}
async fn shop_create(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<SessionInput>,
) -> ApiResult<(StatusCode, Json<FlashSaleSession>)> {
let shop_id = auth.require_shop()?;
Ok((
StatusCode::CREATED,
Json(service::create_session(&state, shop_id, body).await?),
))
}
async fn shop_update(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<SessionInput>,
) -> ApiResult<Json<FlashSaleSession>> {
let shop_id = auth.require_shop()?;
Ok(Json(
service::update_session(&state, shop_id, id, body).await?,
))
}
async fn shop_delete(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<StatusCode> {
let shop_id = auth.require_shop()?;
service::delete_session(&state, shop_id, id).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn shop_add_item(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<ItemInput>,
) -> ApiResult<(StatusCode, Json<FlashSaleItem>)> {
let shop_id = auth.require_shop()?;
Ok((
StatusCode::CREATED,
Json(service::add_item(&state, shop_id, id, body).await?),
))
}
async fn shop_update_item(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<ItemInput>,
) -> ApiResult<Json<FlashSaleItem>> {
let shop_id = auth.require_shop()?;
Ok(Json(
service::update_item(&state, shop_id, id, body).await?,
))
}
async fn shop_delete_item(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<StatusCode> {
let shop_id = auth.require_shop()?;
service::delete_item(&state, shop_id, id).await?;
Ok(StatusCode::NO_CONTENT)
}
+12
View File
@@ -0,0 +1,12 @@
mod dto;
mod handlers;
mod repo;
pub mod service;
use axum::Router;
use crate::state::AppState;
pub fn router() -> Router<AppState> {
handlers::router()
}
+409
View File
@@ -0,0 +1,409 @@
use sqlx::{PgConnection, PgExecutor};
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::models::{
FlashSaleItem, FlashSaleSession, FLASH_SALE_ITEM_COLUMNS, FLASH_SALE_SESSION_COLUMNS,
};
use super::dto::{ItemInput, PublicItemView, SessionInput, ShopItemView};
/// Qualified item columns for queries that join `flash_sale_sessions`, whose
/// own `id` would otherwise make bare column names ambiguous.
const ITEM_COLS_Q: &str = "i.id, i.session_id, i.sku_id, i.sale_price_minor, i.currency, \
i.reserved_stock, i.sold_count, i.per_customer_limit, i.created_at, i.updated_at";
// ---- sessions ----
pub async fn currency_enabled<'e, E: PgExecutor<'e>>(exec: E, code: &str) -> ApiResult<bool> {
Ok(sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM currencies WHERE code = $1 AND enabled = TRUE)",
)
.bind(code)
.fetch_one(exec)
.await?)
}
pub async fn list_sessions_for_shop<'e, E: PgExecutor<'e>>(
exec: E,
shop_id: Uuid,
) -> ApiResult<Vec<FlashSaleSession>> {
Ok(sqlx::query_as::<_, FlashSaleSession>(&format!(
"SELECT {FLASH_SALE_SESSION_COLUMNS} FROM flash_sale_sessions
WHERE shop_id = $1 ORDER BY starts_at DESC"
))
.bind(shop_id)
.fetch_all(exec)
.await?)
}
pub async fn get_session_own<'e, E: PgExecutor<'e>>(
exec: E,
shop_id: Uuid,
id: Uuid,
) -> ApiResult<FlashSaleSession> {
sqlx::query_as::<_, FlashSaleSession>(&format!(
"SELECT {FLASH_SALE_SESSION_COLUMNS} FROM flash_sale_sessions
WHERE id = $1 AND shop_id = $2"
))
.bind(id)
.bind(shop_id)
.fetch_optional(exec)
.await?
.ok_or_else(|| ApiError::NotFound("flash-sale session".into()))
}
pub async fn insert_session(
tx: &mut PgConnection,
shop_id: Uuid,
body: &SessionInput,
) -> ApiResult<FlashSaleSession> {
Ok(sqlx::query_as::<_, FlashSaleSession>(&format!(
"INSERT INTO flash_sale_sessions (shop_id, label, starts_at, ends_at, enabled)
VALUES ($1, $2, $3, $4, $5)
RETURNING {FLASH_SALE_SESSION_COLUMNS}"
))
.bind(shop_id)
.bind(&body.label)
.bind(body.starts_at)
.bind(body.ends_at)
.bind(body.enabled)
.fetch_one(&mut *tx)
.await?)
}
pub async fn update_session(
tx: &mut PgConnection,
shop_id: Uuid,
id: Uuid,
body: &SessionInput,
) -> ApiResult<FlashSaleSession> {
sqlx::query_as::<_, FlashSaleSession>(&format!(
"UPDATE flash_sale_sessions
SET label = $3, starts_at = $4, ends_at = $5, enabled = $6, updated_at = now()
WHERE id = $1 AND shop_id = $2
RETURNING {FLASH_SALE_SESSION_COLUMNS}"
))
.bind(id)
.bind(shop_id)
.bind(&body.label)
.bind(body.starts_at)
.bind(body.ends_at)
.bind(body.enabled)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| ApiError::NotFound("flash-sale session".into()))
}
pub async fn delete_session(tx: &mut PgConnection, shop_id: Uuid, id: Uuid) -> ApiResult<()> {
let result = sqlx::query("DELETE FROM flash_sale_sessions WHERE id = $1 AND shop_id = $2")
.bind(id)
.bind(shop_id)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::NotFound("flash-sale session".into()));
}
Ok(())
}
// ---- activity items ----
pub async fn list_items_for_shop<'e, E: PgExecutor<'e>>(
exec: E,
shop_id: Uuid,
) -> ApiResult<Vec<ShopItemView>> {
Ok(sqlx::query_as::<_, ShopItemView>(
"SELECT i.id, i.session_id, i.sku_id, s.sku_code, p.name AS product_name,
i.sale_price_minor, i.currency,
s.price_minor AS original_price_minor, s.currency AS original_currency,
i.reserved_stock, i.sold_count, i.per_customer_limit
FROM flash_sale_items i
JOIN flash_sale_sessions fs ON fs.id = i.session_id
JOIN skus s ON s.id = i.sku_id
JOIN products p ON p.id = s.product_id
WHERE fs.shop_id = $1
ORDER BY i.created_at",
)
.bind(shop_id)
.fetch_all(exec)
.await?)
}
pub async fn get_item_own<'e, E: PgExecutor<'e>>(
exec: E,
shop_id: Uuid,
id: Uuid,
) -> ApiResult<FlashSaleItem> {
sqlx::query_as::<_, FlashSaleItem>(&format!(
"SELECT {ITEM_COLS_Q} FROM flash_sale_items i
JOIN flash_sale_sessions s ON s.id = i.session_id
WHERE i.id = $1 AND s.shop_id = $2"
))
.bind(id)
.bind(shop_id)
.fetch_optional(exec)
.await?
.ok_or_else(|| ApiError::NotFound("flash-sale item".into()))
}
pub async fn insert_item(
tx: &mut PgConnection,
session_id: Uuid,
body: &ItemInput,
) -> Result<FlashSaleItem, sqlx::Error> {
sqlx::query_as::<_, FlashSaleItem>(&format!(
"INSERT INTO flash_sale_items
(session_id, sku_id, sale_price_minor, currency, reserved_stock, per_customer_limit)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING {FLASH_SALE_ITEM_COLUMNS}"
))
.bind(session_id)
.bind(body.sku_id)
.bind(body.sale_price_minor)
.bind(body.currency.to_uppercase())
.bind(body.reserved_stock)
.bind(body.per_customer_limit)
.fetch_one(&mut *tx)
.await
}
pub async fn update_item(
tx: &mut PgConnection,
shop_id: Uuid,
id: Uuid,
body: &ItemInput,
) -> ApiResult<FlashSaleItem> {
sqlx::query_as::<_, FlashSaleItem>(
"UPDATE flash_sale_items i
SET sku_id = $3, sale_price_minor = $4, currency = $5,
reserved_stock = $6, per_customer_limit = $7, updated_at = now()
FROM flash_sale_sessions s
WHERE i.id = $1 AND i.session_id = s.id AND s.shop_id = $2
RETURNING i.id, i.session_id, i.sku_id, i.sale_price_minor, i.currency,
i.reserved_stock, i.sold_count, i.per_customer_limit, i.created_at, i.updated_at",
)
.bind(id)
.bind(shop_id)
.bind(body.sku_id)
.bind(body.sale_price_minor)
.bind(body.currency.to_uppercase())
.bind(body.reserved_stock)
.bind(body.per_customer_limit)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| ApiError::NotFound("flash-sale item".into()))
}
pub async fn delete_item(tx: &mut PgConnection, shop_id: Uuid, id: Uuid) -> ApiResult<()> {
let result = sqlx::query(
"DELETE FROM flash_sale_items i
USING flash_sale_sessions s
WHERE i.id = $1 AND i.session_id = s.id AND s.shop_id = $2",
)
.bind(id)
.bind(shop_id)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::NotFound("flash-sale item".into()));
}
Ok(())
}
/// The SKU must be active, published, and owned by the session's shop.
pub async fn sku_is_own_active<'e, E: PgExecutor<'e>>(
exec: E,
shop_id: Uuid,
sku_id: Uuid,
) -> ApiResult<bool> {
Ok(sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM skus s
JOIN products p ON p.id = s.product_id
WHERE s.id = $1 AND p.shop_id = $2
AND s.active = TRUE AND p.status = 'published'
)",
)
.bind(sku_id)
.bind(shop_id)
.fetch_one(exec)
.await?)
}
/// Another enabled session whose window overlaps and already lists this SKU.
pub async fn overlapping_sale_exists<'e, E: PgExecutor<'e>>(
exec: E,
sku_id: Uuid,
session_id: Uuid,
starts_at: chrono::DateTime<chrono::Utc>,
ends_at: chrono::DateTime<chrono::Utc>,
) -> ApiResult<bool> {
Ok(sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM flash_sale_items i
JOIN flash_sale_sessions s ON s.id = i.session_id
WHERE i.sku_id = $1 AND s.id <> $2 AND s.enabled = TRUE
AND s.starts_at <= $4 AND s.ends_at >= $3
)",
)
.bind(sku_id)
.bind(session_id)
.bind(starts_at)
.bind(ends_at)
.fetch_one(exec)
.await?)
}
/// The group-buying capability lands after this one, so its overlap check is
/// dormant until its table exists.
pub async fn group_buying_table_exists<'e, E: PgExecutor<'e>>(exec: E) -> ApiResult<bool> {
Ok(sqlx::query_scalar("SELECT to_regclass('public.group_buying_activities') IS NOT NULL")
.fetch_one(exec)
.await?)
}
pub async fn overlapping_group_buying_exists(
tx: &mut PgConnection,
sku_id: Uuid,
starts_at: chrono::DateTime<chrono::Utc>,
ends_at: chrono::DateTime<chrono::Utc>,
) -> ApiResult<bool> {
Ok(sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM group_buying_activities a
WHERE a.sku_id = $1 AND a.enabled = TRUE
AND a.starts_at <= $3 AND a.ends_at >= $2
)",
)
.bind(sku_id)
.bind(starts_at)
.bind(ends_at)
.fetch_one(&mut *tx)
.await?)
}
// ---- public discovery ----
pub async fn list_active_sessions<'e, E: PgExecutor<'e>>(
exec: E,
) -> ApiResult<Vec<FlashSaleSession>> {
Ok(sqlx::query_as::<_, FlashSaleSession>(&format!(
"SELECT {FLASH_SALE_SESSION_COLUMNS} FROM flash_sale_sessions
WHERE enabled = TRUE AND now() BETWEEN starts_at AND ends_at
ORDER BY starts_at"
))
.fetch_all(exec)
.await?)
}
pub async fn list_public_items<'e, E: PgExecutor<'e>>(
exec: E,
session_ids: &[Uuid],
) -> ApiResult<Vec<PublicItemView>> {
Ok(sqlx::query_as::<_, PublicItemView>(
"SELECT i.id, i.session_id, i.sku_id, s.product_id, p.slug AS product_slug,
p.name AS product_name, (p.images ->> 0) AS image, s.sku_code,
i.sale_price_minor, i.currency,
s.price_minor AS original_price_minor, s.currency AS original_currency,
i.reserved_stock, i.sold_count, i.per_customer_limit
FROM flash_sale_items i
JOIN skus s ON s.id = i.sku_id
JOIN products p ON p.id = s.product_id
WHERE i.session_id = ANY($1)
AND s.active = TRUE AND p.status = 'published'
ORDER BY i.created_at",
)
.bind(session_ids)
.fetch_all(exec)
.await?)
}
// ---- checkout ----
/// Activity item ids that could apply to these cart SKUs right now.
pub async fn candidate_item_ids<'e, E: PgExecutor<'e>>(
exec: E,
sku_ids: &[Uuid],
) -> ApiResult<Vec<Uuid>> {
Ok(sqlx::query_scalar(
"SELECT i.id FROM flash_sale_items i
JOIN flash_sale_sessions s ON s.id = i.session_id
WHERE i.sku_id = ANY($1) AND s.enabled = TRUE
AND now() BETWEEN s.starts_at AND s.ends_at",
)
.bind(sku_ids)
.fetch_all(exec)
.await?)
}
/// Lock candidates in primary-key order and re-prove they are still active.
pub async fn lock_active_items(
tx: &mut PgConnection,
ids: &[Uuid],
) -> ApiResult<Vec<FlashSaleItem>> {
Ok(sqlx::query_as::<_, FlashSaleItem>(&format!(
"SELECT {ITEM_COLS_Q} FROM flash_sale_items i
JOIN flash_sale_sessions s ON s.id = i.session_id
WHERE i.id = ANY($1) AND s.enabled = TRUE
AND now() BETWEEN s.starts_at AND s.ends_at
ORDER BY i.id
FOR UPDATE OF i"
))
.bind(ids)
.fetch_all(&mut *tx)
.await?)
}
/// Activity units this customer already holds on non-cancelled orders.
pub async fn purchased_qty(
tx: &mut PgConnection,
user_id: Uuid,
item_id: Uuid,
) -> ApiResult<i64> {
Ok(sqlx::query_scalar(
"SELECT COALESCE(SUM(oi.qty), 0)
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.flash_sale_item_id = $1 AND o.user_id = $2
AND o.status <> 'cancelled'",
)
.bind(item_id)
.bind(user_id)
.fetch_one(&mut *tx)
.await?)
}
/// Guarded reserved-stock decrement; `sold_count` tracks sell-through.
pub async fn decrement_reserved(tx: &mut PgConnection, id: Uuid, qty: i32) -> ApiResult<()> {
let result = sqlx::query(
"UPDATE flash_sale_items
SET reserved_stock = reserved_stock - $2, sold_count = sold_count + $2,
updated_at = now()
WHERE id = $1 AND reserved_stock >= $2",
)
.bind(id)
.bind(qty)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::Conflict("insufficient flash-sale stock".into()));
}
Ok(())
}
/// Pending-payment cancellation returns reserved activity stock.
pub async fn restore_for_order(tx: &mut PgConnection, order_id: Uuid) -> ApiResult<()> {
sqlx::query(
"UPDATE flash_sale_items i
SET reserved_stock = i.reserved_stock + oi.qty,
sold_count = GREATEST(i.sold_count - oi.qty, 0),
updated_at = now()
FROM order_items oi
WHERE oi.order_id = $1 AND oi.flash_sale_item_id = i.id",
)
.bind(order_id)
.execute(&mut *tx)
.await?;
Ok(())
}
+283
View File
@@ -0,0 +1,283 @@
use std::collections::HashMap;
use serde_json::Value;
use sqlx::PgConnection;
use uuid::Uuid;
use crate::error::{unique_conflict, ApiError, ApiResult};
use crate::models::{Currency, FlashSaleItem, FlashSaleSession};
use crate::money::convert_minor;
use crate::state::AppState;
use super::dto::{
ItemInput, LineActivity, PublicSessionView, SessionInput, ShopItemView, ShopSessionView,
};
use super::repo;
// ---- shop management ----
pub async fn list_for_shop(state: &AppState, shop_id: Uuid) -> ApiResult<Vec<ShopSessionView>> {
let sessions = repo::list_sessions_for_shop(&state.db, shop_id).await?;
let items = repo::list_items_for_shop(&state.db, shop_id).await?;
let mut by_session: HashMap<Uuid, Vec<ShopItemView>> = HashMap::new();
for item in items {
by_session.entry(item.session_id).or_default().push(item);
}
Ok(sessions
.into_iter()
.map(|session| ShopSessionView {
items: by_session.remove(&session.id).unwrap_or_default(),
session,
})
.collect())
}
pub async fn create_session(
state: &AppState,
shop_id: Uuid,
body: SessionInput,
) -> ApiResult<FlashSaleSession> {
validate_session(&body)?;
let mut tx = state.db.begin().await?;
let session = repo::insert_session(&mut tx, shop_id, &body).await?;
tx.commit().await?;
Ok(session)
}
pub async fn update_session(
state: &AppState,
shop_id: Uuid,
id: Uuid,
body: SessionInput,
) -> ApiResult<FlashSaleSession> {
validate_session(&body)?;
let mut tx = state.db.begin().await?;
let session = repo::update_session(&mut tx, shop_id, id, &body).await?;
tx.commit().await?;
Ok(session)
}
pub async fn delete_session(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult<()> {
let mut tx = state.db.begin().await?;
repo::delete_session(&mut tx, shop_id, id).await?;
tx.commit().await?;
Ok(())
}
pub async fn add_item(
state: &AppState,
shop_id: Uuid,
session_id: Uuid,
body: ItemInput,
) -> ApiResult<FlashSaleItem> {
validate_item(&body)?;
let mut tx = state.db.begin().await?;
ensure_currency(&mut tx, &body.currency).await?;
let session = repo::get_session_own(&mut *tx, shop_id, session_id).await?;
ensure_activity_slot(&mut tx, shop_id, &session, body.sku_id).await?;
let item = repo::insert_item(&mut tx, session_id, &body)
.await
.map_err(|e| unique_conflict(e, "SKU is already in this session"))?;
tx.commit().await?;
Ok(item)
}
pub async fn update_item(
state: &AppState,
shop_id: Uuid,
id: Uuid,
body: ItemInput,
) -> ApiResult<FlashSaleItem> {
validate_item(&body)?;
let mut tx = state.db.begin().await?;
ensure_currency(&mut tx, &body.currency).await?;
let existing = repo::get_item_own(&mut *tx, shop_id, id).await?;
let session = repo::get_session_own(&mut *tx, shop_id, existing.session_id).await?;
ensure_activity_slot(&mut tx, shop_id, &session, body.sku_id).await?;
let item = repo::update_item(&mut tx, shop_id, id, &body).await?;
tx.commit().await?;
Ok(item)
}
pub async fn delete_item(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult<()> {
let mut tx = state.db.begin().await?;
repo::delete_item(&mut tx, shop_id, id).await?;
tx.commit().await?;
Ok(())
}
// ---- public discovery ----
pub async fn public_active(state: &AppState) -> ApiResult<Vec<PublicSessionView>> {
let sessions = repo::list_active_sessions(&state.db).await?;
let ids: Vec<Uuid> = sessions.iter().map(|s| s.id).collect();
let items = if ids.is_empty() {
Vec::new()
} else {
repo::list_public_items(&state.db, &ids).await?
};
let mut by_session: HashMap<Uuid, Vec<_>> = HashMap::new();
for item in items {
by_session.entry(item.session_id).or_default().push(item);
}
Ok(sessions
.into_iter()
.map(|session| PublicSessionView {
items: by_session.remove(&session.id).unwrap_or_default(),
session,
})
.collect())
}
// ---- checkout composition ----
/// Resolve the activity-priced part of each cart line server-side. Candidates
/// are locked in primary-key order, so concurrent checkouts cannot oversell the
/// reserved stock; the customer's remaining allowance caps the activity units.
pub async fn resolve_activities(
tx: &mut PgConnection,
user_id: Uuid,
lines: &[(Uuid, i32)],
target: &Currency,
currencies: &[Currency],
) -> ApiResult<HashMap<Uuid, LineActivity>> {
let sku_ids: Vec<Uuid> = lines.iter().map(|(sku_id, _)| *sku_id).collect();
let candidate_ids = repo::candidate_item_ids(&mut *tx, &sku_ids).await?;
if candidate_ids.is_empty() {
return Ok(HashMap::new());
}
let locked = repo::lock_active_items(tx, &candidate_ids).await?;
let mut by_sku: HashMap<Uuid, FlashSaleItem> = HashMap::new();
for item in locked {
by_sku.entry(item.sku_id).or_insert(item);
}
let mut resolved = HashMap::new();
for (sku_id, qty) in lines {
let Some(item) = by_sku.get(sku_id) else {
continue;
};
let bought = repo::purchased_qty(&mut *tx, user_id, item.id).await?;
let allowance = item.per_customer_limit as i64 - bought;
let activity_qty = (*qty as i64).min(allowance).min(item.reserved_stock as i64);
if activity_qty <= 0 {
continue;
}
let from = currencies
.iter()
.find(|c| c.code == item.currency)
.ok_or_else(|| {
ApiError::BadRequest(format!("currency {} is disabled", item.currency))
})?;
let unit_price_minor = convert_minor(item.sale_price_minor, from, target)?;
resolved.insert(
*sku_id,
LineActivity {
item_id: item.id,
unit_price_minor,
qty: activity_qty as i32,
},
);
}
Ok(resolved)
}
/// Guarded reserved-stock decrement for the activity units of a line.
pub async fn consume(tx: &mut PgConnection, item_id: Uuid, qty: i32) -> ApiResult<()> {
repo::decrement_reserved(tx, item_id, qty).await
}
/// Pending-payment cancellation returns reserved activity stock.
pub async fn restore_for_order(tx: &mut PgConnection, order_id: Uuid) -> ApiResult<()> {
repo::restore_for_order(tx, order_id).await
}
// ---- validation ----
async fn ensure_activity_slot(
tx: &mut PgConnection,
shop_id: Uuid,
session: &FlashSaleSession,
sku_id: Uuid,
) -> ApiResult<()> {
if !repo::sku_is_own_active(&mut *tx, shop_id, sku_id).await? {
return Err(ApiError::BadRequest(
"SKU is not an active published product of this shop".into(),
));
}
if repo::overlapping_sale_exists(
&mut *tx,
sku_id,
session.id,
session.starts_at,
session.ends_at,
)
.await?
{
return Err(ApiError::Conflict(
"SKU already has an overlapping flash sale".into(),
));
}
if repo::group_buying_table_exists(&mut *tx).await?
&& repo::overlapping_group_buying_exists(tx, sku_id, session.starts_at, session.ends_at)
.await?
{
return Err(ApiError::Conflict(
"SKU already has an overlapping group-buying activity".into(),
));
}
Ok(())
}
async fn ensure_currency(tx: &mut PgConnection, code: &str) -> ApiResult<()> {
if !repo::currency_enabled(&mut *tx, &code.to_uppercase()).await? {
return Err(ApiError::BadRequest(
"currency is unknown or disabled".into(),
));
}
Ok(())
}
fn validate_session(body: &SessionInput) -> ApiResult<()> {
bilingual(&body.label, "label")?;
if body.ends_at < body.starts_at {
return Err(ApiError::BadRequest(
"ends_at must not precede starts_at".into(),
));
}
Ok(())
}
fn validate_item(body: &ItemInput) -> ApiResult<()> {
if body.sale_price_minor <= 0 {
return Err(ApiError::BadRequest(
"sale_price_minor must be positive".into(),
));
}
if body.reserved_stock < 0 {
return Err(ApiError::BadRequest(
"reserved_stock must not be negative".into(),
));
}
if body.per_customer_limit <= 0 {
return Err(ApiError::BadRequest(
"per_customer_limit must be positive".into(),
));
}
Ok(())
}
fn bilingual(label: &Value, field: &str) -> ApiResult<()> {
let ok = ["en", "zh"].iter().all(|code| {
label
.get(code)
.and_then(Value::as_str)
.is_some_and(|s| !s.trim().is_empty())
});
if !ok {
return Err(ApiError::BadRequest(format!(
"{field} needs non-empty en and zh"
)));
}
Ok(())
}
+2
View File
@@ -6,6 +6,7 @@ pub mod catalog;
pub mod content;
pub mod coupon;
pub mod currency;
pub mod flash_sale;
pub mod fulfillment;
pub mod health;
pub mod identity;
@@ -28,6 +29,7 @@ pub fn api_router() -> Router<AppState> {
.merge(content::router())
.merge(cart::router())
.merge(coupon::router())
.merge(flash_sale::router())
.merge(order::router())
.merge(points::router())
.merge(shop::router())
+6 -3
View File
@@ -11,7 +11,7 @@ use super::dto::{OrderScope, OrderView};
const ORDER_COLS: &str = "id, order_no, shop_id, user_id, status, currency, total_minor,
discount_minor, coupon_id, shipping_address, created_at, updated_at";
const ORDER_ITEM_COLS: &str =
"id, order_id, sku_id, product_name, sku_code, image, unit_price_minor, qty";
"id, order_id, sku_id, product_name, sku_code, image, unit_price_minor, qty, flash_sale_item_id";
pub async fn attach_items(db: &PgPool, orders: Vec<Order>) -> ApiResult<Vec<OrderView>> {
let ids: Vec<Uuid> = orders.iter().map(|o| o.id).collect();
@@ -162,6 +162,7 @@ pub async fn insert_order(
.await?)
}
#[allow(clippy::too_many_arguments)]
pub async fn insert_item(
tx: &mut PgConnection,
order_id: Uuid,
@@ -171,10 +172,11 @@ pub async fn insert_item(
image: &Option<String>,
unit: i64,
qty: i32,
flash_sale_item_id: Option<Uuid>,
) -> ApiResult<()> {
sqlx::query(
"INSERT INTO order_items (order_id, sku_id, product_name, sku_code, image, unit_price_minor, qty)
VALUES ($1, $2, $3, $4, $5, $6, $7)",
"INSERT INTO order_items (order_id, sku_id, product_name, sku_code, image, unit_price_minor, qty, flash_sale_item_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
)
.bind(order_id)
.bind(sku_id)
@@ -183,6 +185,7 @@ pub async fn insert_item(
.bind(image)
.bind(unit)
.bind(qty)
.bind(flash_sale_item_id)
.execute(&mut *tx)
.await?;
Ok(())
+126 -27
View File
@@ -6,12 +6,34 @@ use crate::error::{ApiError, ApiResult};
use crate::http::{clamp_page, clamp_per_page, Paged};
use crate::models::OrderStatus;
use crate::money::convert_minor;
use crate::modules::{cart, coupon};
use crate::modules::{cart, coupon, flash_sale};
use crate::state::AppState;
use super::dto::{AddressBody, OrderScope, OrderView};
use super::repo;
/// One cart line after activity resolution: the standard-priced part and, when
/// the customer is eligible, the flash-sale part. A line can therefore produce
/// two order items, which keeps each unit-price snapshot honest.
struct CheckoutLine {
shop_id: Uuid,
sku_id: Uuid,
product_name: serde_json::Value,
sku_code: String,
image: Option<String>,
normal_unit: i64,
normal_qty: i32,
activity_item: Option<Uuid>,
activity_unit: i64,
activity_qty: i32,
}
impl CheckoutLine {
fn line_total(&self) -> i64 {
self.normal_unit * self.normal_qty as i64 + self.activity_unit * self.activity_qty as i64
}
}
pub async fn list(
state: &AppState,
scope: OrderScope,
@@ -101,18 +123,76 @@ pub async fn checkout(
}
}
// Per-shop merchandise subtotal in the buyer's currency.
let mut subtotal_by_shop: HashMap<Uuid, i64> = HashMap::new();
let mut line_prices: Vec<(Uuid, i64, i32)> = Vec::new();
// Normal unit prices in the buyer's currency, plus the quantities the
// activity resolver needs.
let mut normal_by_sku: HashMap<Uuid, i64> = HashMap::new();
let mut price_qty: Vec<(Uuid, i32)> = Vec::new();
for row in &rows {
let qty = qty_by_sku.get(&row.sku_id).copied().unwrap_or(0);
let from = all_currencies
.iter()
.find(|c| c.code == row.currency)
.ok_or_else(|| ApiError::BadRequest(format!("currency {} disabled", row.currency)))?;
let unit = convert_minor(row.price_minor, from, &target)?;
*subtotal_by_shop.entry(row.shop_id).or_insert(0) += unit * qty as i64;
line_prices.push((row.sku_id, unit, qty));
normal_by_sku.insert(row.sku_id, convert_minor(row.price_minor, from, &target)?);
price_qty.push((row.sku_id, qty));
}
// Flash-sale eligibility is resolved and its reserved stock locked before
// the coupon locks, so every checkout takes locks in the same order.
let activities = flash_sale::service::resolve_activities(
&mut tx,
user_id,
&price_qty,
&target,
&all_currencies,
)
.await?;
let mut lines: Vec<CheckoutLine> = Vec::with_capacity(rows.len());
for row in &rows {
let qty = qty_by_sku.get(&row.sku_id).copied().unwrap_or(0);
let normal_unit = normal_by_sku.get(&row.sku_id).copied().unwrap_or(0);
let (activity_item, activity_unit, activity_qty) = match activities.get(&row.sku_id) {
Some(activity) => {
let eligible = activity.qty.min(qty);
if eligible > 0 {
(Some(activity.item_id), activity.unit_price_minor, eligible)
} else {
(None, normal_unit, 0)
}
}
None => (None, normal_unit, 0),
};
lines.push(CheckoutLine {
shop_id: row.shop_id,
sku_id: row.sku_id,
product_name: row.product_name.clone(),
sku_code: row.sku_code.clone(),
image: row.image.clone(),
normal_unit,
normal_qty: qty - activity_qty,
activity_item,
activity_unit,
activity_qty,
});
}
// Activity pricing and coupons are exclusive per shop order.
for shop_id in coupon_by_shop.keys() {
if lines
.iter()
.any(|line| line.shop_id == *shop_id && line.activity_qty > 0)
{
return Err(ApiError::Conflict(
"a coupon cannot be combined with flash-sale pricing on one shop order".into(),
));
}
}
// Per-shop merchandise subtotal in the buyer's currency.
let mut subtotal_by_shop: HashMap<Uuid, i64> = HashMap::new();
for line in &lines {
*subtotal_by_shop.entry(line.shop_id).or_insert(0) += line.line_total();
}
// Lock every selected coupon in id order, then prove the customer owns it.
@@ -129,8 +209,8 @@ pub async fn checkout(
let address = serde_json::to_value(&shipping_address).map_err(ApiError::internal)?;
let mut created = Vec::new();
for shop_id in shop_order {
let shop_rows: Vec<&repo::CheckoutRow> =
rows.iter().filter(|r| r.shop_id == shop_id).collect();
let shop_lines: Vec<&CheckoutLine> =
lines.iter().filter(|line| line.shop_id == shop_id).collect();
let subtotal = subtotal_by_shop.get(&shop_id).copied().unwrap_or(0);
let selected_coupon = coupon_by_shop.get(&shop_id).copied();
// Eligibility and the discount are resolved server-side; the client
@@ -165,23 +245,40 @@ pub async fn checkout(
if let Some(id) = selected_coupon {
coupon::service::redeem(&mut tx, id, order.id).await?;
}
for row in &shop_rows {
let (_, unit, qty) = line_prices
.iter()
.find(|(sku, _, _)| *sku == row.sku_id)
.unwrap();
repo::insert_item(
&mut tx,
order.id,
row.sku_id,
&row.product_name,
&row.sku_code,
&row.image,
*unit,
*qty,
)
.await?;
repo::decrement_stock(&mut tx, row.sku_id, *qty).await?;
for line in shop_lines {
// Activity-priced units first, then the standard-priced remainder.
if let (Some(item_id), true) = (line.activity_item, line.activity_qty > 0) {
repo::insert_item(
&mut tx,
order.id,
line.sku_id,
&line.product_name,
&line.sku_code,
&line.image,
line.activity_unit,
line.activity_qty,
Some(item_id),
)
.await?;
flash_sale::service::consume(&mut tx, item_id, line.activity_qty).await?;
}
if line.normal_qty > 0 {
repo::insert_item(
&mut tx,
order.id,
line.sku_id,
&line.product_name,
&line.sku_code,
&line.image,
line.normal_unit,
line.normal_qty,
None,
)
.await?;
}
// The whole ordered quantity leaves SKU stock; only the activity
// portion also leaves reserved activity stock.
repo::decrement_stock(&mut tx, line.sku_id, line.normal_qty + line.activity_qty).await?;
}
created.push(order);
}
@@ -201,7 +298,9 @@ pub async fn cancel(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult<Orde
let mut tx = state.db.begin().await?;
let order = repo::cancel(&mut tx, user_id, id).await?;
repo::restore_stock(&mut tx, order.id).await?;
// Stock and the redeemed coupon come back in the same transaction.
// Reserved activity stock, SKU stock, and the redeemed coupon all come back
// in the same transaction.
flash_sale::service::restore_for_order(&mut tx, order.id).await?;
coupon::service::restore_for_order(&mut tx, order.id).await?;
tx.commit().await?;
let mut views = repo::attach_items(&state.db, vec![order]).await?;
+399
View File
@@ -0,0 +1,399 @@
mod common;
use std::collections::HashMap;
use common::{
add_to_cart, client, login_admin, register_customer, setup_sellable, spawn_app, TestApp,
};
use serial_test::serial;
use uuid::Uuid;
fn past_window() -> (String, String) {
("2020-01-01T00:00:00Z".into(), "2020-01-02T00:00:00Z".into())
}
fn active_window() -> (String, String) {
("2020-01-01T00:00:00Z".into(), "2999-01-01T00:00:00Z".into())
}
async fn create_session(app: &TestApp, owner: &str, starts: &str, ends: &str) -> String {
let res = client()
.post(app.url("/api/shop/flash-sales"))
.bearer_auth(owner)
.json(&serde_json::json!({
"label": {"en": "Flash", "zh": "秒杀"},
"starts_at": starts,
"ends_at": ends,
"enabled": true,
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "create session: {:?}", res.text().await);
res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string()
}
async fn add_item(
app: &TestApp,
owner: &str,
session_id: &str,
sku_id: &str,
sale_price: i64,
reserved: i32,
limit: i32,
) -> reqwest::Response {
client()
.post(app.url(&format!("/api/shop/flash-sales/{session_id}/items")))
.bearer_auth(owner)
.json(&serde_json::json!({
"sku_id": sku_id,
"sale_price_minor": sale_price,
"currency": "USD",
"reserved_stock": reserved,
"per_customer_limit": limit,
}))
.send()
.await
.unwrap()
}
async fn checkout_with(
app: &TestApp,
token: &str,
coupon_by_shop: HashMap<String, String>,
) -> reqwest::Response {
client()
.post(app.url("/api/orders/checkout"))
.bearer_auth(token)
.json(&serde_json::json!({
"shipping_address": {
"recipient": "Test", "phone": "1", "country": "US",
"region": "CA", "city": "SF", "line1": "1 Way", "postal_code": "94105"
},
"currency": "USD",
"coupon_by_shop": coupon_by_shop,
}))
.send()
.await
.unwrap()
}
async fn create_template(app: &TestApp, owner: &str, amount: i64, threshold: i64) -> String {
let res = client()
.post(app.url("/api/shop/coupon-templates"))
.bearer_auth(owner)
.json(&serde_json::json!({
"title": {"en": "Coupon", "zh": "优惠券"},
"amount_minor": amount,
"threshold_minor": threshold,
"currency": "USD",
"stock": 5,
"enabled": true,
"starts_at": "2020-01-01T00:00:00Z",
"ends_at": "2999-01-01T00:00:00Z",
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "create template: {:?}", res.text().await);
res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string()
}
async fn claim(app: &TestApp, token: &str, template_id: &str) -> String {
let res = client()
.post(app.url("/api/me/coupons"))
.bearer_auth(token)
.json(&serde_json::json!({ "template_id": template_id }))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201, "claim: {:?}", res.text().await);
res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string()
}
async fn flash_item_state(app: &TestApp, item_id: &str) -> (i32, i32) {
sqlx::query_as("SELECT reserved_stock, sold_count FROM flash_sale_items WHERE id = $1")
.bind(Uuid::parse_str(item_id).unwrap())
.fetch_one(&app.db)
.await
.unwrap()
}
async fn activity_lines(app: &TestApp, order_id: &str) -> i64 {
sqlx::query_scalar(
"SELECT count(*) FROM order_items
WHERE order_id = $1 AND flash_sale_item_id IS NOT NULL",
)
.bind(Uuid::parse_str(order_id).unwrap())
.fetch_one(&app.db)
.await
.unwrap()
}
#[tokio::test]
#[serial]
async fn inactive_window_falls_back_to_normal_price() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "fs-past", 1000, 5).await;
let (starts, ends) = past_window();
let session = create_session(&app, &owner, &starts, &ends).await;
let res = add_item(&app, &owner, &session, &sku, 500, 5, 3).await;
assert_eq!(res.status(), 201, "past sessions may still be configured");
// Not discoverable as active.
let active: Vec<serde_json::Value> = client()
.get(app.url("/api/flash-sales"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
assert!(active.is_empty(), "an elapsed session is not public");
let (token, _) = register_customer(&app, "fs-past").await;
add_to_cart(&app, &token, &sku, 1).await;
let res = checkout_with(&app, &token, HashMap::new()).await;
assert_eq!(res.status(), 201);
let orders: Vec<serde_json::Value> = res.json().await.unwrap();
let item = &orders[0]["items"][0];
assert_eq!(item["unit_price_minor"], 1000, "normal price applies");
assert!(item["flash_sale_item_id"].is_null());
assert_eq!(activity_lines(&app, orders[0]["id"].as_str().unwrap()).await, 0);
}
#[tokio::test]
#[serial]
async fn cross_shop_sku_and_overlapping_sessions_are_rejected() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (owner_a, shop_a, _product_a, sku_a) = setup_sellable(&app, &admin, "fs-a", 1000, 5).await;
let (_owner_b, _shop_b, _product_b, sku_b) = setup_sellable(&app, &admin, "fs-b", 1000, 5).await;
let (starts, ends) = active_window();
let session = create_session(&app, &owner_a, &starts, &ends).await;
let res = add_item(&app, &owner_a, &session, &sku_b, 500, 5, 3).await;
assert_eq!(res.status(), 400, "another shop's SKU is refused");
let res = add_item(&app, &owner_a, &session, &sku_a, 500, 5, 3).await;
assert_eq!(res.status(), 201);
// A second overlapping session cannot list the same SKU.
let session_two = create_session(&app, &owner_a, &starts, &ends).await;
let res = add_item(&app, &owner_a, &session_two, &sku_a, 400, 5, 3).await;
assert_eq!(res.status(), 409, "overlapping sale for the same SKU is refused");
let _ = shop_a;
}
#[tokio::test]
#[serial]
async fn overlapping_group_buying_is_rejected_once_that_capability_exists() {
let app = spawn_app().await;
let table_exists: bool = sqlx::query_scalar(
"SELECT to_regclass('public.group_buying_activities') IS NOT NULL",
)
.fetch_one(&app.db)
.await
.unwrap();
if !table_exists {
// add-group-buying lands after add-flash-sales, so this cross-capability
// scenario can only be exercised once its table exists.
eprintln!("skipped: group_buying_activities is not present yet");
return;
}
let admin = login_admin(&app).await;
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "fs-gb", 1000, 5).await;
let (starts, ends) = active_window();
sqlx::query(
"INSERT INTO group_buying_activities
(shop_id, sku_id, name, group_price_minor, currency, required_members,
starts_at, ends_at, group_lifetime_hours)
SELECT p.shop_id, $1, '{\"en\":\"g\",\"zh\":\"\"}', 100, 'USD', 2, $2, $3, 24
FROM skus s JOIN products p ON p.id = s.product_id WHERE s.id = $1",
)
.bind(Uuid::parse_str(&sku).unwrap())
.bind(chrono::DateTime::parse_from_rfc3339(&starts).unwrap())
.bind(chrono::DateTime::parse_from_rfc3339(&ends).unwrap())
.execute(&app.db)
.await
.unwrap();
let session = create_session(&app, &owner, &starts, &ends).await;
let res = add_item(&app, &owner, &session, &sku, 500, 5, 3).await;
assert_eq!(res.status(), 409, "overlapping group-buy SKU is refused");
}
#[tokio::test]
#[serial]
async fn reserved_stock_admits_one_activity_price() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "fs-race", 1000, 10).await;
let (starts, ends) = active_window();
let session = create_session(&app, &owner, &starts, &ends).await;
let res = add_item(&app, &owner, &session, &sku, 400, 1, 5).await;
assert_eq!(res.status(), 201);
let item_id = res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let (token_a, _) = register_customer(&app, "fs-race-a").await;
let (token_b, _) = register_customer(&app, "fs-race-b").await;
add_to_cart(&app, &token_a, &sku, 1).await;
add_to_cart(&app, &token_b, &sku, 1).await;
let (a, b) = tokio::join!(
checkout_with(&app, &token_a, HashMap::new()),
checkout_with(&app, &token_b, HashMap::new()),
);
assert!(a.status().is_success() && b.status().is_success());
let (reserved, sold) = flash_item_state(&app, &item_id).await;
assert_eq!(reserved, 0, "the single reserved unit is consumed");
assert_eq!(sold, 1);
let activity: i64 = sqlx::query_scalar(
"SELECT count(*) FROM order_items WHERE flash_sale_item_id = $1",
)
.bind(Uuid::parse_str(&item_id).unwrap())
.fetch_one(&app.db)
.await
.unwrap();
assert_eq!(activity, 1, "exactly one line got the activity price");
}
#[tokio::test]
#[serial]
async fn per_customer_limit_splits_the_line() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "fs-limit", 1000, 10).await;
let (starts, ends) = active_window();
let session = create_session(&app, &owner, &starts, &ends).await;
let res = add_item(&app, &owner, &session, &sku, 400, 10, 1).await;
assert_eq!(res.status(), 201);
let item_id = res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let (token, _) = register_customer(&app, "fs-limit").await;
add_to_cart(&app, &token, &sku, 3).await;
let res = checkout_with(&app, &token, HashMap::new()).await;
assert_eq!(res.status(), 201);
let orders: Vec<serde_json::Value> = res.json().await.unwrap();
let items = orders[0]["items"].as_array().unwrap();
assert_eq!(items.len(), 2, "one activity line and one standard line");
let activity: Vec<&serde_json::Value> = items
.iter()
.filter(|i| !i["flash_sale_item_id"].is_null())
.collect();
let standard: Vec<&serde_json::Value> = items
.iter()
.filter(|i| i["flash_sale_item_id"].is_null())
.collect();
assert_eq!(activity.len(), 1);
assert_eq!(activity[0]["qty"], 1, "only the allowance gets the activity price");
assert_eq!(activity[0]["unit_price_minor"], 400);
assert_eq!(standard.len(), 1);
assert_eq!(standard[0]["qty"], 2);
assert_eq!(standard[0]["unit_price_minor"], 1000);
assert_eq!(orders[0]["total_minor"], 400 + 2000);
// The allowance is spent, so a second checkout is standard-priced.
add_to_cart(&app, &token, &sku, 1).await;
let res = checkout_with(&app, &token, HashMap::new()).await;
let orders: Vec<serde_json::Value> = res.json().await.unwrap();
assert!(orders[0]["items"][0]["flash_sale_item_id"].is_null());
assert_eq!(activity_lines(&app, orders[0]["id"].as_str().unwrap()).await, 0);
let (_, sold) = flash_item_state(&app, &item_id).await;
assert_eq!(sold, 1);
}
#[tokio::test]
#[serial]
async fn coupon_is_rejected_on_a_flash_priced_shop_order() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (owner, shop, _product, sku) = setup_sellable(&app, &admin, "fs-cp", 1000, 5).await;
let (starts, ends) = active_window();
let session = create_session(&app, &owner, &starts, &ends).await;
assert_eq!(add_item(&app, &owner, &session, &sku, 400, 5, 5).await.status(), 201);
let template = create_template(&app, &owner, 100, 0).await;
let (token, _) = register_customer(&app, "fs-cp").await;
let coupon = claim(&app, &token, &template).await;
add_to_cart(&app, &token, &sku, 1).await;
let res = checkout_with(&app, &token, HashMap::from([(shop, coupon)])).await;
assert_eq!(res.status(), 409, "activity pricing and coupons are exclusive");
}
#[tokio::test]
#[serial]
async fn cancel_restores_reserved_stock_and_coupon() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (flash_owner, _flash_shop, _p1, flash_sku) =
setup_sellable(&app, &admin, "fs-cancel-a", 1000, 5).await;
let (owner_b, shop_b, _p2, sku_b) = setup_sellable(&app, &admin, "fs-cancel-b", 2000, 5).await;
let (starts, ends) = active_window();
let session = create_session(&app, &flash_owner, &starts, &ends).await;
let res = add_item(&app, &flash_owner, &session, &flash_sku, 400, 3, 5).await;
assert_eq!(res.status(), 201);
let item_id = res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let template = create_template(&app, &owner_b, 100, 0).await;
let (token, _) = register_customer(&app, "fs-cancel").await;
let coupon = claim(&app, &token, &template).await;
add_to_cart(&app, &token, &flash_sku, 2).await;
add_to_cart(&app, &token, &sku_b, 1).await;
let res = checkout_with(&app, &token, HashMap::from([(shop_b, coupon.clone())])).await;
assert_eq!(res.status(), 201, "checkout: {:?}", res.text().await);
let orders: Vec<serde_json::Value> = res.json().await.unwrap();
assert_eq!(orders.len(), 2);
let (reserved_after, sold_after) = flash_item_state(&app, &item_id).await;
assert_eq!(reserved_after, 1, "2 of 3 reserved units consumed");
assert_eq!(sold_after, 2);
for order in &orders {
let res = client()
.post(app.url(&format!("/api/orders/{}/cancel", order["id"].as_str().unwrap())))
.bearer_auth(&token)
.send()
.await
.unwrap();
assert_eq!(res.status(), 200);
}
let (reserved, sold) = flash_item_state(&app, &item_id).await;
assert_eq!(reserved, 3, "reserved activity stock is restored");
assert_eq!(sold, 0);
let (status, order_ref): (String, Option<Uuid>) =
sqlx::query_as("SELECT status::text, order_id FROM coupons WHERE id = $1")
.bind(Uuid::parse_str(&coupon).unwrap())
.fetch_one(&app.db)
.await
.unwrap();
assert_eq!(status, "claimed");
assert!(order_ref.is_none());
}
+6 -6
View File
@@ -1,14 +1,14 @@
## 1. Flash-sale domain and contract
- [ ] 1.1 Add additive migrations for shop flash-sale sessions, SKU activity items, reserved stock, per-customer limits, and order-item activity snapshots.
- [ ] 1.2 Implement the Rust flash-sale module with shop-scoped session/item management and public active-session discovery.
- [x] 1.1 Add additive migrations for shop flash-sale sessions, SKU activity items, reserved stock, per-customer limits, and order-item activity snapshots.
- [x] 1.2 Implement the Rust flash-sale module with shop-scoped session/item management and public active-session discovery.
- [ ] 1.3 Add shared flash-sale types, client methods, localized strings, and fixed-data adapter parity.
## 2. Checkout price and inventory resolution
- [ ] 2.1 Resolve active eligible activity items server-side during checkout and snapshot final line pricing and activity identity. Reject coupon selection on any shop order that applied flash pricing; restate coupon persistence for non-flash shop orders.
- [ ] 2.2 Conditionally decrement and restore activity inventory with SKU inventory in deterministic lock order; enforce the per-customer limit. Pending-payment cancellation restores both inventories and any redeemed coupon.
- [ ] 2.3 Add integration tests for inactive-window fallback, cross-shop configuration rejection, overlapping group-buy SKU rejection, final reserved-stock contention, customer limits, coupon rejection on flash-priced shop orders, coupon restore on cancel, and cancellation restoration.
- [x] 2.1 Resolve active eligible activity items server-side during checkout and snapshot final line pricing and activity identity. Reject coupon selection on any shop order that applied flash pricing; restate coupon persistence for non-flash shop orders.
- [x] 2.2 Conditionally decrement and restore activity inventory with SKU inventory in deterministic lock order; enforce the per-customer limit. Pending-payment cancellation restores both inventories and any redeemed coupon.
- [x] 2.3 Add integration tests for inactive-window fallback, cross-shop configuration rejection, overlapping group-buy SKU rejection, final reserved-stock contention, customer limits, coupon rejection on flash-priced shop orders, coupon restore on cancel, and cancellation restoration.
## 3. Merchant and mall surfaces
@@ -19,4 +19,4 @@
## 4. Verification and specification
- [ ] 4.1 Seed an active deterministic flash sale and browser-smoke its discovery and checkout pricing path.
- [ ] 4.2 Run cargo test for vmall-api, builds for mall and shop-admin, and strict validation for this OpenSpec change.
- [ ] 4.2 Run cargo test for vmall-api, builds for mall and shop-admin, and strict validation for this OpenSpec change.