diff --git a/apps/api/migrations/0013_flash_sales.sql b/apps/api/migrations/0013_flash_sales.sql new file mode 100644 index 0000000..42384e7 --- /dev/null +++ b/apps/api/migrations/0013_flash_sales.sql @@ -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); diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index a127890..a02889d 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -208,6 +208,8 @@ pub struct OrderItem { pub image: Option, pub unit_price_minor: i64, pub qty: i32, + /// Set when this line received flash-sale pricing. + pub flash_sale_item_id: Option, } #[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, + pub ends_at: DateTime, + pub enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +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, + pub updated_at: DateTime, +} + +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, diff --git a/apps/api/src/modules/flash_sale/dto.rs b/apps/api/src/modules/flash_sale/dto.rs new file mode 100644 index 0000000..c39cbfc --- /dev/null +++ b/apps/api/src/modules/flash_sale/dto.rs @@ -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, + pub ends_at: DateTime, + #[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, +} + +/// 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, + 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, +} + +/// 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, +} diff --git a/apps/api/src/modules/flash_sale/handlers.rs b/apps/api/src/modules/flash_sale/handlers.rs new file mode 100644 index 0000000..02c055d --- /dev/null +++ b/apps/api/src/modules/flash_sale/handlers.rs @@ -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 { + 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, +) -> ApiResult>> { + Ok(Json(service::public_active(&state).await?)) +} + +async fn shop_list( + State(state): State, + auth: AuthUser, +) -> ApiResult>> { + let shop_id = auth.require_shop()?; + Ok(Json(service::list_for_shop(&state, shop_id).await?)) +} + +async fn shop_create( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + 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, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json( + service::update_session(&state, shop_id, id, body).await?, + )) +} + +async fn shop_delete( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult { + 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, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + 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, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + 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, + auth: AuthUser, + Path(id): Path, +) -> ApiResult { + let shop_id = auth.require_shop()?; + service::delete_item(&state, shop_id, id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/apps/api/src/modules/flash_sale/mod.rs b/apps/api/src/modules/flash_sale/mod.rs new file mode 100644 index 0000000..2cdf8fe --- /dev/null +++ b/apps/api/src/modules/flash_sale/mod.rs @@ -0,0 +1,12 @@ +mod dto; +mod handlers; +mod repo; +pub mod service; + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + handlers::router() +} diff --git a/apps/api/src/modules/flash_sale/repo.rs b/apps/api/src/modules/flash_sale/repo.rs new file mode 100644 index 0000000..8f3a31a --- /dev/null +++ b/apps/api/src/modules/flash_sale/repo.rs @@ -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 { + 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> { + 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 { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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, + ends_at: chrono::DateTime, +) -> ApiResult { + 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 { + 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, + ends_at: chrono::DateTime, +) -> ApiResult { + 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> { + 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> { + 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> { + 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> { + 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 { + 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(()) +} diff --git a/apps/api/src/modules/flash_sale/service.rs b/apps/api/src/modules/flash_sale/service.rs new file mode 100644 index 0000000..e5e4b21 --- /dev/null +++ b/apps/api/src/modules/flash_sale/service.rs @@ -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> { + 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> = 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 { + 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 { + 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 { + 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 { + 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> { + let sessions = repo::list_active_sessions(&state.db).await?; + let ids: Vec = 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> = 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> { + let sku_ids: Vec = 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 = 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(()) +} diff --git a/apps/api/src/modules/mod.rs b/apps/api/src/modules/mod.rs index aa8fc44..e037004 100644 --- a/apps/api/src/modules/mod.rs +++ b/apps/api/src/modules/mod.rs @@ -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 { .merge(content::router()) .merge(cart::router()) .merge(coupon::router()) + .merge(flash_sale::router()) .merge(order::router()) .merge(points::router()) .merge(shop::router()) diff --git a/apps/api/src/modules/order/repo.rs b/apps/api/src/modules/order/repo.rs index d2c496d..e6960a6 100644 --- a/apps/api/src/modules/order/repo.rs +++ b/apps/api/src/modules/order/repo.rs @@ -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) -> ApiResult> { let ids: Vec = 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, unit: i64, qty: i32, + flash_sale_item_id: Option, ) -> 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(()) diff --git a/apps/api/src/modules/order/service.rs b/apps/api/src/modules/order/service.rs index 6b759fe..b0bc39d 100644 --- a/apps/api/src/modules/order/service.rs +++ b/apps/api/src/modules/order/service.rs @@ -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, + normal_unit: i64, + normal_qty: i32, + activity_item: Option, + 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 = 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 = 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 = 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 = 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 (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::().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, +) -> 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::().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::().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 = 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 = 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::().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::().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 = 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 = 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::().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 = 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) = + 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()); +} diff --git a/openspec/changes/add-flash-sales/tasks.md b/openspec/changes/add-flash-sales/tasks.md index 3c78f08..f8a3928 100644 --- a/openspec/changes/add-flash-sales/tasks.md +++ b/openspec/changes/add-flash-sales/tasks.md @@ -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. \ No newline at end of file +- [ ] 4.2 Run cargo test for vmall-api, builds for mall and shop-admin, and strict validation for this OpenSpec change.