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
+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,