diff --git a/apps/api/migrations/0026_threshold_promotions.sql b/apps/api/migrations/0026_threshold_promotions.sql new file mode 100644 index 0000000..1f86a24 --- /dev/null +++ b/apps/api/migrations/0026_threshold_promotions.sql @@ -0,0 +1,36 @@ +-- Threshold promotions (满减): shop-owned automatic reduction rules. A rule is +-- priced at checkout, never claimed, so it carries no stock and cancellation +-- restores nothing. +-- +-- `orders` gains the rule reference; the realized amount continues to live in +-- the existing `discount_minor`. An order applies at most one of a coupon or +-- a threshold rule. + +CREATE TABLE threshold_promotions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + shop_id UUID NOT NULL REFERENCES shops (id) ON DELETE CASCADE, + name JSONB NOT NULL, + currency CHAR(3) NOT NULL REFERENCES currencies (code), + threshold_minor BIGINT NOT NULL CHECK (threshold_minor > 0), + reduction_minor BIGINT NOT NULL CHECK (reduction_minor > 0), + enabled BOOLEAN NOT NULL DEFAULT TRUE, + archived BOOLEAN NOT NULL DEFAULT FALSE, + display_order INT NOT NULL DEFAULT 0, + starts_at TIMESTAMPTZ NOT NULL, + ends_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT threshold_promotions_window CHECK (ends_at > starts_at), + CONSTRAINT threshold_promotions_amounts CHECK (reduction_minor < threshold_minor) +); + +CREATE INDEX threshold_promotions_shop_idx + ON threshold_promotions (shop_id, enabled, archived, starts_at, ends_at); + +ALTER TABLE orders + ADD COLUMN threshold_promotion_id UUID REFERENCES threshold_promotions (id) ON DELETE SET NULL; + +-- Exactly one price-adjustment source per order. +ALTER TABLE orders + ADD CONSTRAINT orders_one_promotion_source + CHECK (coupon_id IS NULL OR threshold_promotion_id IS NULL); diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index 8fbc246..df992fb 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -186,6 +186,9 @@ pub struct Order { pub payment_provenance: String, /// The coupon this order redeemed, if any. pub coupon_id: Option, + /// The threshold promotion this order applied, if any. Mutually exclusive + /// with `coupon_id` (enforced by `orders_one_promotion_source`). + pub threshold_promotion_id: Option, /// Group-buying activity this order joined, if any. pub group_activity_id: Option, /// Concrete group this order belongs to, if any. @@ -368,6 +371,28 @@ pub struct CouponTemplate { pub const COUPON_TEMPLATE_COLUMNS: &str = "id, shop_id, title, amount_minor, threshold_minor, \ currency, stock, enabled, starts_at, ends_at, created_at"; +/// Shop-owned automatic threshold reduction (满减). Priced at checkout, never +/// claimed: no stock, and cancellation restores nothing. +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct ThresholdPromotion { + pub id: Uuid, + pub shop_id: Uuid, + pub name: serde_json::Value, + pub currency: String, + pub threshold_minor: i64, + pub reduction_minor: i64, + pub enabled: bool, + pub archived: bool, + pub display_order: i32, + pub starts_at: DateTime, + pub ends_at: DateTime, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub const THRESHOLD_PROMOTION_COLUMNS: &str = "id, shop_id, name, currency, threshold_minor, \ + reduction_minor, enabled, archived, display_order, starts_at, ends_at, created_at, updated_at"; + /// Customer-owned snapshot of a template's terms at claim time. #[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub struct Coupon { diff --git a/apps/api/src/modules/mod.rs b/apps/api/src/modules/mod.rs index 9002bf5..fafc192 100644 --- a/apps/api/src/modules/mod.rs +++ b/apps/api/src/modules/mod.rs @@ -22,6 +22,7 @@ pub mod order; pub mod payment; pub mod points; pub mod product; +pub mod promotion; pub mod review; pub mod settlement; pub mod shop; @@ -52,6 +53,7 @@ pub fn api_router() -> Router { .merge(order::router()) .merge(payment::router()) .merge(points::router()) + .merge(promotion::router()) .merge(shop::router()) .merge(review::router()) .merge(fulfillment::router()) diff --git a/apps/api/src/modules/order/handlers.rs b/apps/api/src/modules/order/handlers.rs index 2ecf26a..ba7bf7d 100644 --- a/apps/api/src/modules/order/handlers.rs +++ b/apps/api/src/modules/order/handlers.rs @@ -77,6 +77,10 @@ struct CheckoutBody { /// choice, never a discount amount. #[serde(default)] coupon_by_shop: std::collections::HashMap, + /// At most one threshold rule per generated shop order, mutually exclusive + /// with a coupon on the same shop. The client sends the choice, never an amount. + #[serde(default)] + threshold_by_shop: std::collections::HashMap, /// Optional group-buying intent for one activity SKU at quantity 1. #[serde(default)] group_buy: Option, @@ -101,6 +105,7 @@ async fn checkout( body.shipping_address, body.currency, body.coupon_by_shop, + body.threshold_by_shop, body.group_buy, ) .await?, diff --git a/apps/api/src/modules/order/repo.rs b/apps/api/src/modules/order/repo.rs index 374bfc9..49ae674 100644 --- a/apps/api/src/modules/order/repo.rs +++ b/apps/api/src/modules/order/repo.rs @@ -10,7 +10,7 @@ use super::dto::{OrderScope, OrderView}; const ORDER_COLS: &str = "id, order_no, shop_id, user_id, status, currency, total_minor, discount_minor, refund_total_minor, shipping_fee_minor, payment_provenance, coupon_id, - group_activity_id, group_id, shipping_address, created_at, updated_at"; + threshold_promotion_id, group_activity_id, group_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, flash_sale_item_id, freight_template_id, freight_pricing_method"; @@ -189,15 +189,17 @@ pub async fn insert_order( discount_minor: i64, shipping_fee_minor: i64, coupon_id: Option, + threshold_promotion_id: Option, group_activity_id: Option, group_id: Option, address: &serde_json::Value, ) -> ApiResult { Ok(sqlx::query_as::<_, Order>(&format!( "INSERT INTO orders (order_no, shop_id, user_id, currency, total_minor, discount_minor, - shipping_fee_minor, coupon_id, group_activity_id, group_id, shipping_address) + shipping_fee_minor, coupon_id, threshold_promotion_id, + group_activity_id, group_id, shipping_address) VALUES ('VM' || to_char(now(), 'YYMMDD') || lpad(nextval('order_no_seq')::text, 6, '0'), - $1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING {ORDER_COLS}" )) .bind(shop_id) @@ -207,6 +209,7 @@ pub async fn insert_order( .bind(discount_minor) .bind(shipping_fee_minor) .bind(coupon_id) + .bind(threshold_promotion_id) .bind(group_activity_id) .bind(group_id) .bind(address) diff --git a/apps/api/src/modules/order/service.rs b/apps/api/src/modules/order/service.rs index e2a3f37..a851b94 100644 --- a/apps/api/src/modules/order/service.rs +++ b/apps/api/src/modules/order/service.rs @@ -77,6 +77,7 @@ pub async fn checkout( shipping_address: AddressBody, currency: String, coupon_by_shop: HashMap, + threshold_by_shop: HashMap, group_buy: Option, ) -> ApiResult { shipping_address.validate()?; @@ -91,6 +92,7 @@ pub async fn checkout( "shipping_address": serde_json::to_value(&shipping_address).map_err(ApiError::internal)?, "currency": target_currency, "coupon_by_shop": coupon_by_shop, + "threshold_by_shop": threshold_by_shop, "group_buy": group_buy, "cart": entries, })); @@ -98,6 +100,7 @@ pub async fn checkout( "shipping_address": serde_json::to_value(&shipping_address).map_err(ApiError::internal)?, "currency": target_currency, "coupon_by_shop": coupon_by_shop, + "threshold_by_shop": threshold_by_shop, "group_buy": group_buy, })); if let Some(replayed) = crate::modules::payment::service::checkout_replay( @@ -150,6 +153,18 @@ pub async fn checkout( )); } } + for shop_id in threshold_by_shop.keys() { + if !shop_order.contains(shop_id) { + return Err(ApiError::BadRequest( + "threshold promotion selected for a shop that is not in the cart".into(), + )); + } + if coupon_by_shop.contains_key(shop_id) { + return Err(ApiError::Conflict( + "choose either a coupon or a threshold promotion for one shop order".into(), + )); + } + } // Normal unit prices in the buyer's currency, plus the quantities the // activity resolver needs. @@ -254,6 +269,17 @@ pub async fn checkout( )); } } + // Activity pricing and threshold promotions are exclusive per shop order. + for shop_id in threshold_by_shop.keys() { + let flash_priced = lines + .iter() + .any(|line| line.shop_id == *shop_id && line.activity_qty > 0); + if flash_priced || group_by_shop.contains_key(shop_id) { + return Err(ApiError::Conflict( + "a threshold promotion cannot be combined with activity pricing on one shop order".into(), + )); + } + } // Per-shop merchandise subtotal in the buyer's currency. let mut subtotal_by_shop: HashMap = HashMap::new(); @@ -283,6 +309,7 @@ pub async fn checkout( shipping_fee: i64, order_total: i64, coupon_id: Option, + threshold_promotion_id: Option, } let mut planned: Vec = Vec::new(); for shop_id in shop_order { @@ -292,10 +319,11 @@ pub async fn checkout( .collect(); let subtotal = subtotal_by_shop.get(&shop_id).copied().unwrap_or(0); let selected_coupon = coupon_by_shop.get(&shop_id).copied(); + let selected_threshold = threshold_by_shop.get(&shop_id).copied(); // Eligibility and the discount are resolved server-side; the client - // only ever sends which coupon, never how much. - let discount = match selected_coupon { - Some(id) => { + // only ever sends which promotion, never how much. + let discount = match (selected_coupon, selected_threshold) { + (Some(id), None) => { let coupon = coupons .remove(&id) .ok_or_else(|| ApiError::Conflict("coupon cannot be redeemed".into()))?; @@ -307,7 +335,19 @@ pub async fn checkout( &all_currencies, )? } - None => 0, + (None, Some(id)) => { + crate::modules::promotion::service::checkout_reduction( + &mut tx, + id, + shop_id, + subtotal, + &target, + &all_currencies, + ) + .await? + } + // The same-shop conflict was rejected before any write above. + _ => 0, }; // Shipping is computed server-side from the destination; client @@ -338,6 +378,7 @@ pub async fn checkout( shipping_fee, order_total: subtotal - discount + shipping_fee, coupon_id: selected_coupon, + threshold_promotion_id: selected_threshold, }); } let payment_total: i64 = planned.iter().map(|p| p.order_total).sum(); @@ -365,6 +406,7 @@ pub async fn checkout( plan.discount, plan.shipping_fee, selected_coupon, + plan.threshold_promotion_id, group.map(|g| g.activity_id), group.map(|g| g.group_id), &address, diff --git a/apps/api/src/modules/promotion/dto.rs b/apps/api/src/modules/promotion/dto.rs new file mode 100644 index 0000000..1808983 --- /dev/null +++ b/apps/api/src/modules/promotion/dto.rs @@ -0,0 +1,43 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Shop-side create/update payload. All amounts are integer minor units in the +/// rule's own currency; the server converts at evaluation time. +#[derive(Debug, Deserialize)] +pub struct ThresholdPromotionInput { + pub name: serde_json::Value, + pub currency: String, + pub threshold_minor: i64, + pub reduction_minor: i64, + pub enabled: bool, + #[serde(default)] + pub display_order: i32, + pub starts_at: DateTime, + pub ends_at: DateTime, +} + +/// One eligible rule in the buyer's currency, for checkout preview display. +#[derive(Debug, Serialize)] +pub struct ThresholdRuleView { + pub id: Uuid, + pub shop_id: Uuid, + pub name: serde_json::Value, + pub currency: String, + pub threshold_minor: i64, + pub reduction_minor: i64, + /// Threshold and reduction converted into the preview currency. + pub converted_threshold_minor: i64, + pub converted_reduction_minor: i64, + pub ends_at: DateTime, +} + +/// Per-shop read-only preview for the current cart and currency. +#[derive(Debug, Serialize)] +pub struct ShopThresholdPreview { + pub shop_id: Uuid, + /// Merchandise subtotal in the preview currency (normal prices only). + pub subtotal_minor: i64, + /// Best eligible rule, if the subtotal reaches any threshold. + pub best: Option, +} diff --git a/apps/api/src/modules/promotion/handlers.rs b/apps/api/src/modules/promotion/handlers.rs new file mode 100644 index 0000000..6ae4e5b --- /dev/null +++ b/apps/api/src/modules/promotion/handlers.rs @@ -0,0 +1,85 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + routing::{get, put}, + Json, Router, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::models::ThresholdPromotion; +use crate::state::AppState; + +use super::dto::{ShopThresholdPreview, ThresholdPromotionInput}; +use super::service; + +pub fn router() -> Router { + Router::new() + .route("/checkout/threshold-preview", get(preview)) + .route( + "/shop/threshold-promotions", + get(shop_list).post(shop_create), + ) + .route( + "/shop/threshold-promotions/{id}", + put(shop_update).delete(shop_delete), + ) +} + +#[derive(Deserialize)] +struct PreviewQuery { + currency: String, +} + +async fn preview( + State(state): State, + auth: AuthUser, + Query(query): Query, +) -> ApiResult>> { + auth.require_customer()?; + Ok(Json( + service::preview(&state, auth.id, &query.currency.to_uppercase()).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(&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(&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(&state, shop_id, id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/apps/api/src/modules/promotion/mod.rs b/apps/api/src/modules/promotion/mod.rs new file mode 100644 index 0000000..2cdf8fe --- /dev/null +++ b/apps/api/src/modules/promotion/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/promotion/repo.rs b/apps/api/src/modules/promotion/repo.rs new file mode 100644 index 0000000..8ab3331 --- /dev/null +++ b/apps/api/src/modules/promotion/repo.rs @@ -0,0 +1,155 @@ +use sqlx::{PgConnection, PgExecutor}; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::models::{ThresholdPromotion, THRESHOLD_PROMOTION_COLUMNS}; + +use super::dto::ThresholdPromotionInput; + +pub async fn currency_enabled<'e, E: PgExecutor<'e>>(exec: E, code: &str) -> ApiResult { + Ok( + sqlx::query_scalar("SELECT enabled FROM currencies WHERE code = $1") + .bind(code) + .fetch_optional(exec) + .await? + .unwrap_or(false), + ) +} + +pub async fn list_for_shop<'e, E: PgExecutor<'e>>( + exec: E, + shop_id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_as::<_, ThresholdPromotion>(&format!( + "SELECT {THRESHOLD_PROMOTION_COLUMNS} FROM threshold_promotions + WHERE shop_id = $1 + ORDER BY display_order, created_at" + )) + .bind(shop_id) + .fetch_all(exec) + .await?) +} + +pub async fn get_for_shop<'e, E: PgExecutor<'e>>( + exec: E, + shop_id: Uuid, + id: Uuid, +) -> ApiResult { + sqlx::query_as::<_, ThresholdPromotion>(&format!( + "SELECT {THRESHOLD_PROMOTION_COLUMNS} FROM threshold_promotions + WHERE id = $1 AND shop_id = $2" + )) + .bind(id) + .bind(shop_id) + .fetch_optional(exec) + .await? + .ok_or_else(|| ApiError::NotFound("threshold promotion".into())) +} + +/// Active candidates for checkout evaluation: enabled, unarchived, in window. +pub async fn list_active_for_shop<'e, E: PgExecutor<'e>>( + exec: E, + shop_id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_as::<_, ThresholdPromotion>(&format!( + "SELECT {THRESHOLD_PROMOTION_COLUMNS} FROM threshold_promotions + WHERE shop_id = $1 AND enabled AND NOT archived + AND starts_at <= now() AND ends_at >= now() + ORDER BY display_order, created_at" + )) + .bind(shop_id) + .fetch_all(exec) + .await?) +} + +pub async fn insert( + tx: &mut PgConnection, + shop_id: Uuid, + body: &ThresholdPromotionInput, +) -> ApiResult { + Ok(sqlx::query_as::<_, ThresholdPromotion>(&format!( + "INSERT INTO threshold_promotions + (shop_id, name, currency, threshold_minor, reduction_minor, enabled, + display_order, starts_at, ends_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING {THRESHOLD_PROMOTION_COLUMNS}" + )) + .bind(shop_id) + .bind(&body.name) + .bind(body.currency.to_uppercase()) + .bind(body.threshold_minor) + .bind(body.reduction_minor) + .bind(body.enabled) + .bind(body.display_order) + .bind(body.starts_at) + .bind(body.ends_at) + .fetch_one(&mut *tx) + .await?) +} + +pub async fn update( + tx: &mut PgConnection, + shop_id: Uuid, + id: Uuid, + body: &ThresholdPromotionInput, +) -> ApiResult { + sqlx::query_as::<_, ThresholdPromotion>(&format!( + "UPDATE threshold_promotions + SET name = $3, currency = $4, threshold_minor = $5, reduction_minor = $6, + enabled = $7, display_order = $8, starts_at = $9, ends_at = $10, + updated_at = now() + WHERE id = $1 AND shop_id = $2 + RETURNING {THRESHOLD_PROMOTION_COLUMNS}" + )) + .bind(id) + .bind(shop_id) + .bind(&body.name) + .bind(body.currency.to_uppercase()) + .bind(body.threshold_minor) + .bind(body.reduction_minor) + .bind(body.enabled) + .bind(body.display_order) + .bind(body.starts_at) + .bind(body.ends_at) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::NotFound("threshold promotion".into())) +} + +pub async fn is_referenced(tx: &mut PgConnection, id: Uuid) -> ApiResult { + Ok( + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM orders WHERE threshold_promotion_id = $1)") + .bind(id) + .fetch_one(&mut *tx) + .await?, + ) +} + +pub async fn archive(tx: &mut PgConnection, shop_id: Uuid, id: Uuid) -> ApiResult<()> { + let rows = sqlx::query( + "UPDATE threshold_promotions SET archived = TRUE, enabled = FALSE, updated_at = now() + WHERE id = $1 AND shop_id = $2", + ) + .bind(id) + .bind(shop_id) + .execute(&mut *tx) + .await? + .rows_affected(); + if rows == 0 { + return Err(ApiError::NotFound("threshold promotion".into())); + } + Ok(()) +} + +pub async fn delete(tx: &mut PgConnection, shop_id: Uuid, id: Uuid) -> ApiResult<()> { + let rows = sqlx::query("DELETE FROM threshold_promotions WHERE id = $1 AND shop_id = $2") + .bind(id) + .bind(shop_id) + .execute(&mut *tx) + .await? + .rows_affected(); + if rows == 0 { + return Err(ApiError::NotFound("threshold promotion".into())); + } + Ok(()) +} diff --git a/apps/api/src/modules/promotion/service.rs b/apps/api/src/modules/promotion/service.rs new file mode 100644 index 0000000..f6b2b5d --- /dev/null +++ b/apps/api/src/modules/promotion/service.rs @@ -0,0 +1,240 @@ +use std::collections::HashMap; + +use chrono::Utc; +use serde_json::Value; +use sqlx::PgConnection; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::models::{Currency, ThresholdPromotion}; +use crate::money::convert_minor; +use crate::state::AppState; + +use super::dto::{ShopThresholdPreview, ThresholdPromotionInput, ThresholdRuleView}; +use super::repo; + +// ---- shop-side management ---- + +pub async fn list_for_shop(state: &AppState, shop_id: Uuid) -> ApiResult> { + repo::list_for_shop(&state.db, shop_id).await +} + +pub async fn create( + state: &AppState, + shop_id: Uuid, + body: ThresholdPromotionInput, +) -> ApiResult { + validate_input(&body)?; + let mut tx = state.db.begin().await?; + ensure_currency(&mut tx, &body.currency).await?; + let rule = repo::insert(&mut tx, shop_id, &body).await?; + tx.commit().await?; + Ok(rule) +} + +pub async fn update( + state: &AppState, + shop_id: Uuid, + id: Uuid, + body: ThresholdPromotionInput, +) -> ApiResult { + validate_input(&body)?; + let mut tx = state.db.begin().await?; + ensure_currency(&mut tx, &body.currency).await?; + let rule = repo::update(&mut tx, shop_id, id, &body).await?; + tx.commit().await?; + Ok(rule) +} + +/// Referenced rules keep their FK target: archive instead of delete. +pub async fn delete(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult<()> { + let mut tx = state.db.begin().await?; + repo::get_for_shop(&mut *tx, shop_id, id).await?; + if repo::is_referenced(&mut tx, id).await? { + repo::archive(&mut tx, shop_id, id).await?; + } else { + repo::delete(&mut tx, shop_id, id).await?; + } + tx.commit().await?; + Ok(()) +} + +// ---- eligibility and checkout composition ---- + +/// Convert and compare one rule against a subtotal in the target currency. +/// Returns the converted (threshold, reduction). +fn converted_terms( + rule: &ThresholdPromotion, + target: &Currency, + currencies: &[Currency], +) -> ApiResult<(i64, i64)> { + let from = currencies + .iter() + .find(|c| c.code == rule.currency) + .ok_or_else(|| ApiError::BadRequest(format!("currency {} disabled", rule.currency)))?; + Ok(( + convert_minor(rule.threshold_minor, from, target)?, + convert_minor(rule.reduction_minor, from, target)?, + )) +} + +/// Deterministic best eligible rule: highest converted reduction, ties broken +/// by display order then creation time (the repo lists in that order). +pub fn best_eligible<'a>( + rules: &'a [ThresholdPromotion], + subtotal_minor: i64, + target: &Currency, + currencies: &[Currency], +) -> ApiResult> { + let mut best: Option<(&ThresholdPromotion, i64)> = None; + for rule in rules { + let (threshold, reduction) = converted_terms(rule, target, currencies)?; + if subtotal_minor < threshold { + continue; + } + let better = match best { + Some((_, best_reduction)) => reduction > best_reduction, + None => true, + }; + if better { + best = Some((rule, reduction)); + } + } + Ok(best) +} + +/// Server-authoritative checkout validation and reduction. The client sends +/// only which rule; eligibility, window and the amount are re-derived here. +pub async fn checkout_reduction( + tx: &mut PgConnection, + rule_id: Uuid, + shop_id: Uuid, + subtotal_minor: i64, + target: &Currency, + currencies: &[Currency], +) -> ApiResult { + let rule = repo::get_for_shop(&mut *tx, shop_id, rule_id).await?; + let now = Utc::now(); + if !rule.enabled || rule.archived || now < rule.starts_at || now > rule.ends_at { + return Err(ApiError::Conflict( + "threshold promotion is not active".into(), + )); + } + let (threshold, reduction) = converted_terms(&rule, target, currencies)?; + if subtotal_minor < threshold { + return Err(ApiError::Conflict( + "order subtotal does not reach the promotion threshold".into(), + )); + } + // A reduction can never exceed the shop order it applies to. + Ok(reduction.min(subtotal_minor)) +} + +// ---- read-only cart preview ---- + +/// Per-shop best eligible rule for the caller's current cart in the chosen +/// currency. Normal prices only; activity pricing exclusivity is decided +/// authoritatively at checkout. +pub async fn preview( + state: &AppState, + user_id: Uuid, + currency: &str, +) -> ApiResult> { + let entries = crate::modules::cart::service::read_entries(state, user_id).await?; + let mut conn = state.db.acquire().await?; + let target = crate::modules::order::repo::enabled_currency(&mut conn, currency).await?; + let all_currencies = crate::modules::order::repo::all_enabled_currencies(&mut conn).await?; + if entries.is_empty() { + return Ok(Vec::new()); + } + let sku_ids: Vec = entries.iter().map(|(id, _)| *id).collect(); + let rows = crate::modules::order::repo::purchasable_skus(&state.db, &sku_ids).await?; + let qty_by_sku: HashMap = entries.iter().copied().collect(); + let mut subtotal_by_shop: HashMap = HashMap::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; + } + let mut out = Vec::with_capacity(subtotal_by_shop.len()); + for (shop_id, subtotal) in subtotal_by_shop { + let rules = repo::list_active_for_shop(&state.db, shop_id).await?; + let best = best_eligible(&rules, subtotal, &target, &all_currencies)?; + out.push(ShopThresholdPreview { + shop_id, + subtotal_minor: subtotal, + best: best.map(|(rule, converted_reduction)| { + let (converted_threshold, _) = + converted_terms(rule, &target, &all_currencies).unwrap_or((0, 0)); + ThresholdRuleView { + id: rule.id, + shop_id: rule.shop_id, + name: rule.name.clone(), + currency: rule.currency.clone(), + threshold_minor: rule.threshold_minor, + reduction_minor: rule.reduction_minor, + converted_threshold_minor: converted_threshold, + converted_reduction_minor: converted_reduction, + ends_at: rule.ends_at, + } + }), + }); + } + out.sort_by_key(|p| p.shop_id); + Ok(out) +} + +// ---- validation helpers ---- + +fn validate_input(body: &ThresholdPromotionInput) -> ApiResult<()> { + bilingual(&body.name, "name")?; + if body.threshold_minor <= 0 { + return Err(ApiError::BadRequest( + "threshold_minor must be positive".into(), + )); + } + if body.reduction_minor <= 0 { + return Err(ApiError::BadRequest( + "reduction_minor must be positive".into(), + )); + } + if body.reduction_minor >= body.threshold_minor { + return Err(ApiError::BadRequest( + "reduction_minor must be smaller than threshold_minor".into(), + )); + } + if body.ends_at <= body.starts_at { + return Err(ApiError::BadRequest( + "ends_at must follow starts_at".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 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/tests/order_service.rs b/apps/api/tests/order_service.rs index f8f5198..46b5b50 100644 --- a/apps/api/tests/order_service.rs +++ b/apps/api/tests/order_service.rs @@ -79,6 +79,7 @@ async fn checkout_rejects_empty_cart() { address(), "USD".into(), Default::default(), + Default::default(), None, ) .await @@ -121,6 +122,7 @@ async fn checkout_rejects_insufficient_stock() { address(), "USD".into(), Default::default(), + Default::default(), None, ) .await @@ -148,6 +150,7 @@ async fn checkout_splits_per_shop() { address(), "USD".into(), Default::default(), + Default::default(), None, ) .await @@ -177,6 +180,7 @@ async fn pay_and_cancel_require_pending_payment() { address(), "USD".into(), Default::default(), + Default::default(), None, ) .await diff --git a/apps/api/tests/promotions.rs b/apps/api/tests/promotions.rs new file mode 100644 index 0000000..922ba6d --- /dev/null +++ b/apps/api/tests/promotions.rs @@ -0,0 +1,501 @@ +mod common; + +use std::collections::HashMap; + +use common::{ + add_to_cart, client, create_shop, login_admin, register_customer, setup_sellable, spawn_app, + TestApp, +}; +use serial_test::serial; +use uuid::Uuid; + +fn rule_body(threshold: i64, reduction: i64) -> serde_json::Value { + serde_json::json!({ + "name": {"en": "Test threshold", "zh": "测试满减"}, + "currency": "USD", + "threshold_minor": threshold, + "reduction_minor": reduction, + "enabled": true, + "display_order": 0, + "starts_at": "2020-01-01T00:00:00Z", + "ends_at": "2999-01-01T00:00:00Z", + }) +} + +async fn create_rule(app: &TestApp, owner: &str, body: serde_json::Value) -> String { + let res = client() + .post(app.url("/api/shop/threshold-promotions")) + .bearer_auth(owner) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "create rule: {:?}", res.text().await); + res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string() +} + +async fn checkout_with( + app: &TestApp, + token: &str, + coupon_by_shop: HashMap, + threshold_by_shop: HashMap, + idempotency_key: &str, +) -> reqwest::Response { + client() + .post(app.url("/api/orders/checkout")) + .bearer_auth(token) + .header("Idempotency-Key", idempotency_key) + .json(&serde_json::json!({ + "shipping_address": { + "recipient": "Test Recipient", + "phone": "123456", + "country": "US", + "region": "CA", + "city": "San Jose", + "line1": "1 Test Way", + "postal_code": "95131" + }, + "currency": "USD", + "coupon_by_shop": coupon_by_shop, + "threshold_by_shop": threshold_by_shop, + })) + .send() + .await + .unwrap() +} + +#[tokio::test] +#[serial] +async fn shop_manages_only_its_own_rules() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let shop_a = create_shop(&app, &admin, "tp-own-a").await; + let shop_b = create_shop(&app, &admin, "tp-own-b").await; + let owner_a = common::make_shop_owner(&app, &admin, &shop_a).await; + let owner_b = common::make_shop_owner(&app, &admin, &shop_b).await; + + let rule = create_rule(&app, &owner_a, rule_body(9900, 800)).await; + + // Owner B cannot see, update or delete shop A's rule. + let res = client() + .put(app.url(&format!("/api/shop/threshold-promotions/{rule}"))) + .bearer_auth(&owner_b) + .json(&rule_body(5000, 400)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 404); + let res = client() + .delete(app.url(&format!("/api/shop/threshold-promotions/{rule}"))) + .bearer_auth(&owner_b) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 404); + + // Shop A's list contains the rule; shop B's does not. + let list: serde_json::Value = client() + .get(app.url("/api/shop/threshold-promotions")) + .bearer_auth(&owner_a) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(list.as_array().unwrap().len(), 1); + let list: serde_json::Value = client() + .get(app.url("/api/shop/threshold-promotions")) + .bearer_auth(&owner_b) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(list.as_array().unwrap().len(), 0); +} + +#[tokio::test] +#[serial] +async fn invalid_amounts_and_window_are_rejected() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let shop = create_shop(&app, &admin, "tp-invalid").await; + let owner = common::make_shop_owner(&app, &admin, &shop).await; + + // Reduction not smaller than threshold. + let res = client() + .post(app.url("/api/shop/threshold-promotions")) + .bearer_auth(&owner) + .json(&rule_body(1000, 1000)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 400); + // Non-positive threshold. + let res = client() + .post(app.url("/api/shop/threshold-promotions")) + .bearer_auth(&owner) + .json(&rule_body(0, 1)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 400); + // Incomplete bilingual name. + let res = client() + .post(app.url("/api/shop/threshold-promotions")) + .bearer_auth(&owner) + .json(&serde_json::json!({ + "name": {"en": "Only english"}, + "currency": "USD", + "threshold_minor": 1000, + "reduction_minor": 100, + "enabled": true, + "starts_at": "2020-01-01T00:00:00Z", + "ends_at": "2999-01-01T00:00:00Z", + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 400); + // Nothing was written for this shop. + let count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM threshold_promotions WHERE shop_id = $1::uuid", + ) + .bind(&shop) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(count, 0); +} + +#[tokio::test] +#[serial] +async fn threshold_reduces_the_shop_order_total() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (owner, shop_id, _, sku_id) = setup_sellable(&app, &admin, "tp-apply", 10000, 10).await; + let rule = create_rule(&app, &owner, rule_body(9900, 800)).await; + let (token, _) = register_customer(&app, "tp-apply").await; + add_to_cart(&app, &token, &sku_id, 1).await; + + let res = checkout_with( + &app, + &token, + HashMap::new(), + HashMap::from([(shop_id.clone(), rule.clone())]), + &Uuid::new_v4().to_string(), + ) + .await; + assert_eq!(res.status(), 201, "checkout: {:?}", res.text().await); + let body: serde_json::Value = res.json().await.unwrap(); + let order = &body["orders"][0]; + assert_eq!(order["discount_minor"], 800); + assert_eq!(order["threshold_promotion_id"], rule); + assert!(order["coupon_id"].is_null()); + // Subtotal 10000 − 800 + shipping, persisted server-side. + let total = order["total_minor"].as_i64().unwrap(); + let shipping = order["shipping_fee_minor"].as_i64().unwrap(); + assert_eq!(total, 10000 - 800 + shipping); + + // The payment created for the checkout charges exactly the reduced total. + let payment_id = body["payment_id"].as_str().unwrap(); + let charged: i64 = sqlx::query_scalar( + "SELECT total_minor FROM payments WHERE id = $1::uuid", + ) + .bind(payment_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(charged, total); +} + +#[tokio::test] +#[serial] +async fn below_threshold_and_coupon_conflict_are_rejected() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (owner, shop_id, _, sku_id) = setup_sellable(&app, &admin, "tp-reject", 10000, 10).await; + let high_rule = create_rule(&app, &owner, rule_body(20000, 1000)).await; + let rule = create_rule(&app, &owner, rule_body(9900, 800)).await; + // A claimable coupon for the same shop. + let template_res = client() + .post(app.url("/api/shop/coupon-templates")) + .bearer_auth(&owner) + .json(&serde_json::json!({ + "title": {"en": "Coupon", "zh": "优惠券"}, + "amount_minor": 500, + "threshold_minor": 0, + "currency": "USD", + "stock": 5, + "enabled": true, + "starts_at": "2020-01-01T00:00:00Z", + "ends_at": "2999-01-01T00:00:00Z", + })) + .send() + .await + .unwrap(); + let template_id = template_res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + let (token, user_id) = register_customer(&app, "tp-reject").await; + // Claim the coupon. + 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); + let coupon_id = res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + add_to_cart(&app, &token, &sku_id, 1).await; + + // Subtotal 10000 below the 20000 threshold → 409, no orders. + let res = checkout_with( + &app, + &token, + HashMap::new(), + HashMap::from([(shop_id.clone(), high_rule)]), + &Uuid::new_v4().to_string(), + ) + .await; + assert_eq!(res.status(), 409, "below threshold: {:?}", res.text().await); + + // Coupon and threshold together → 409, no orders. + let res = checkout_with( + &app, + &token, + HashMap::from([(shop_id.clone(), coupon_id)]), + HashMap::from([(shop_id.clone(), rule)]), + &Uuid::new_v4().to_string(), + ) + .await; + assert_eq!(res.status(), 409, "conflict: {:?}", res.text().await); + + let orders: i64 = sqlx::query_scalar("SELECT count(*) FROM orders WHERE user_id = $1::uuid") + .bind(&user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(orders, 0); +} + +#[tokio::test] +#[serial] +async fn activity_priced_shop_order_rejects_a_threshold() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (owner, shop_id, _, sku_id) = setup_sellable(&app, &admin, "tp-flash", 10000, 10).await; + let rule = create_rule(&app, &owner, rule_body(9900, 800)).await; + + // Put the SKU into an active flash sale so the shop order is activity priced. + let res = client() + .post(app.url("/api/shop/flash-sales")) + .bearer_auth(&owner) + .json(&serde_json::json!({ + "label": {"en": "Flash", "zh": "秒杀"}, + "starts_at": "2020-01-01T00:00:00Z", + "ends_at": "2999-01-01T00:00:00Z", + "enabled": true, + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201); + let session_id = res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + let res = 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": 8000, + "currency": "USD", + "reserved_stock": 5, + "per_customer_limit": 2, + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "add item: {:?}", res.text().await); + + let (token, user_id) = register_customer(&app, "tp-flash").await; + add_to_cart(&app, &token, &sku_id, 1).await; + + let res = checkout_with( + &app, + &token, + HashMap::new(), + HashMap::from([(shop_id.clone(), rule)]), + &Uuid::new_v4().to_string(), + ) + .await; + assert_eq!(res.status(), 409, "activity priced: {:?}", res.text().await); + + let orders: i64 = sqlx::query_scalar("SELECT count(*) FROM orders WHERE user_id = $1::uuid") + .bind(&user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(orders, 0); +} + +#[tokio::test] +#[serial] +async fn preview_reports_the_best_eligible_rule() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (owner, shop_id, _, sku_id) = setup_sellable(&app, &admin, "tp-preview", 10000, 10).await; + create_rule(&app, &owner, rule_body(9900, 800)).await; + let better = create_rule(&app, &owner, rule_body(9900, 1200)).await; + create_rule(&app, &owner, rule_body(50000, 9000)).await; + + let (token, _) = register_customer(&app, "tp-preview").await; + add_to_cart(&app, &token, &sku_id, 1).await; + + let res = client() + .get(app.url("/api/checkout/threshold-preview?currency=USD")) + .bearer_auth(&token) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "preview: {:?}", res.text().await); + let body: serde_json::Value = res.json().await.unwrap(); + let shops = body.as_array().unwrap(); + let entry = shops + .iter() + .find(|s| s["shop_id"] == shop_id) + .expect("preview covers the cart shop"); + assert_eq!(entry["subtotal_minor"], 10000); + // Highest eligible reduction wins; the 50000-threshold rule is ineligible. + assert_eq!(entry["best"]["id"], better); + assert_eq!(entry["best"]["converted_reduction_minor"], 1200); +} + +#[tokio::test] +#[serial] +async fn replay_and_changed_selection_follow_idempotency() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (owner, shop_id, _, sku_id) = setup_sellable(&app, &admin, "tp-replay", 10000, 10).await; + let rule = create_rule(&app, &owner, rule_body(9900, 800)).await; + let (token, user_id) = register_customer(&app, "tp-replay").await; + add_to_cart(&app, &token, &sku_id, 1).await; + + let key = Uuid::new_v4().to_string(); + let res = checkout_with( + &app, + &token, + HashMap::new(), + HashMap::from([(shop_id.clone(), rule.clone())]), + &key, + ) + .await; + assert_eq!(res.status(), 201); + let first: serde_json::Value = res.json().await.unwrap(); + + // Identical replay returns the durable result without new orders. + let res = checkout_with( + &app, + &token, + HashMap::new(), + HashMap::from([(shop_id.clone(), rule.clone())]), + &key, + ) + .await; + assert_eq!(res.status(), 201, "replay: {:?}", res.text().await); + let replayed: serde_json::Value = res.json().await.unwrap(); + assert_eq!(first["payment_id"], replayed["payment_id"]); + + // Changed selection with the same key is a 409. + let res = checkout_with(&app, &token, HashMap::new(), HashMap::new(), &key).await; + assert_eq!(res.status(), 409); + + let orders: i64 = sqlx::query_scalar("SELECT count(*) FROM orders WHERE user_id = $1::uuid") + .bind(&user_id) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(orders, 1); +} + +#[tokio::test] +#[serial] +async fn referenced_rule_is_archived_not_deleted() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (owner, shop_id, _, sku_id) = setup_sellable(&app, &admin, "tp-arch", 10000, 10).await; + let referenced = create_rule(&app, &owner, rule_body(9900, 800)).await; + let stray = create_rule(&app, &owner, rule_body(5000, 400)).await; + + let (token, _) = register_customer(&app, "tp-arch").await; + add_to_cart(&app, &token, &sku_id, 1).await; + let res = checkout_with( + &app, + &token, + HashMap::new(), + HashMap::from([(shop_id.clone(), referenced.clone())]), + &Uuid::new_v4().to_string(), + ) + .await; + assert_eq!(res.status(), 201); + + // Deleting a referenced rule archives it and disables it. + let res = client() + .delete(app.url(&format!("/api/shop/threshold-promotions/{referenced}"))) + .bearer_auth(&owner) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 204); + let (archived, enabled): (bool, bool) = + sqlx::query_as("SELECT archived, enabled FROM threshold_promotions WHERE id = $1::uuid") + .bind(&referenced) + .fetch_one(&app.db) + .await + .unwrap(); + assert!(archived && !enabled); + + // Deleting an unreferenced rule removes the row. + let res = client() + .delete(app.url(&format!("/api/shop/threshold-promotions/{stray}"))) + .bearer_auth(&owner) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 204); + let gone: bool = sqlx::query_scalar( + "SELECT NOT EXISTS(SELECT 1 FROM threshold_promotions WHERE id = $1::uuid)", + ) + .bind(&stray) + .fetch_one(&app.db) + .await + .unwrap(); + assert!(gone); + + // The archived rule no longer applies to a new checkout. + let (token2, _) = register_customer(&app, "tp-arch-2").await; + add_to_cart(&app, &token2, &sku_id, 1).await; + let res = checkout_with( + &app, + &token2, + HashMap::new(), + HashMap::from([(shop_id.clone(), referenced)]), + &Uuid::new_v4().to_string(), + ) + .await; + assert_eq!(res.status(), 409, "archived: {:?}", res.text().await); +} diff --git a/apps/mall/locales/checkout.ts b/apps/mall/locales/checkout.ts index 310e53f..8ffa6fa 100644 --- a/apps/mall/locales/checkout.ts +++ b/apps/mall/locales/checkout.ts @@ -26,6 +26,9 @@ export default { coupon: "Coupon", noCoupon: "No coupon", couponDiscount: "Coupon discount", + promotion: "Promotion", + noPromotion: "No promotion", + thresholdDiscount: "Threshold reduction", subtotal: "Goods subtotal", shippingFee: "Shipping fee", shippingTotal: "Shipping total", @@ -73,6 +76,9 @@ export default { coupon: "优惠券", noCoupon: "不使用优惠券", couponDiscount: "优惠券抵扣", + promotion: "优惠", + noPromotion: "不使用优惠", + thresholdDiscount: "满减优惠", subtotal: "商品小计", shippingFee: "运费", shippingTotal: "运费合计", diff --git a/apps/mall/mock/api.ts b/apps/mall/mock/api.ts index b91944e..7ef4add 100644 --- a/apps/mall/mock/api.ts +++ b/apps/mall/mock/api.ts @@ -98,6 +98,7 @@ import { MOCK_CATEGORIES, MOCK_COUPONS, MOCK_CURRENCIES, + MOCK_THRESHOLDS, MOCK_FAVORITES, INTEGRAL_PRODUCTS, MOCK_PROMOS, @@ -1709,6 +1710,7 @@ export function createMockApi(): ApiClient { shippingAddress: Address, currency: string, couponByShop: Record | undefined, + thresholdByShop: Record | undefined, groupBuy: GroupBuyIntent | undefined, idempotencyKey: string, ) => { @@ -1717,7 +1719,7 @@ export function createMockApi(): ApiClient { new ApiError(400, "BAD_REQUEST", "Idempotency-Key is required"), ); } - const payloadHash = hashPayload([shippingAddress, currency, couponByShop ?? {}, groupBuy ?? null]); + const payloadHash = hashPayload([shippingAddress, currency, couponByShop ?? {}, thresholdByShop ?? {}, groupBuy ?? null]); const prior = state.checkoutRequests.find((entry) => entry.key === idempotencyKey); if (prior) { if (prior.hash !== payloadHash) { @@ -1760,12 +1762,23 @@ export function createMockApi(): ApiClient { const subtotal = items.reduce((sum, i) => sum + i.unit_price_minor * i.qty, 0); const groupHere = activity && groupBuy && items.some((i) => i.sku_id === groupBuy.sku_id); const couponId = couponByShop?.[shopId]; + const thresholdId = thresholdByShop?.[shopId]; let discount = 0; + if (couponId && thresholdId) { + return Promise.reject( + new ApiError(409, "CONFLICT", "Choose either a coupon or a threshold promotion"), + ); + } if (couponId && groupHere) { return Promise.reject( new ApiError(409, "CONFLICT", "A coupon cannot be combined with group pricing"), ); } + if (thresholdId && groupHere) { + return Promise.reject( + new ApiError(409, "CONFLICT", "A threshold promotion cannot be combined with group pricing"), + ); + } if (couponId) { const coupon = state.coupons.find((c) => c.id === couponId); if (!coupon || coupon.status !== "claimed" || coupon.shop_id !== shopId) { @@ -1778,6 +1791,20 @@ export function createMockApi(): ApiClient { } discount = Math.min(coupon.amount_minor, subtotal); } + let appliedThreshold: string | null = null; + if (thresholdId) { + const rule = MOCK_THRESHOLDS.find((entry) => entry.id === thresholdId); + if (!rule) { + return Promise.reject(new ApiError(409, "CONFLICT", "Threshold promotion is not active")); + } + if (subtotal < rule.thresholdMinor) { + return Promise.reject( + new ApiError(409, "CONFLICT", "Order does not reach the promotion threshold"), + ); + } + discount = Math.min(rule.reductionMinor, subtotal); + appliedThreshold = rule.id; + } // Same deterministic rule as quoteShipping: flat fee under the free threshold. const shippingFee = subtotal - discount >= 20000 ? 0 : 599; const order: Order = { @@ -1792,6 +1819,7 @@ export function createMockApi(): ApiClient { shipping_fee_minor: shippingFee, refund_total_minor: 0, coupon_id: couponId ?? null, + threshold_promotion_id: appliedThreshold, group_activity_id: groupHere ? activity.id : null, group_id: groupHere ? `g-${Date.now()}` : null, items: items.map((i, k) => ({ @@ -2512,6 +2540,41 @@ export function createMockApi(): ApiClient { listShopCouponTemplates: (shopId: string) => Promise.resolve(MOCK_COUPONS.map((t) => mockTemplate(t, shopId))), + previewCheckoutThresholds: (_currency: string) => { + const byShop: Record = {}; + for (const item of state.cart) { + const product = productById(item.product_id); + const shopId = product?.shop_id ?? "unknown"; + byShop[shopId] = (byShop[shopId] ?? 0) + item.unit_price_minor * item.qty; + } + return Promise.resolve( + Object.entries(byShop).map(([shopId, subtotal]) => { + const eligible = MOCK_THRESHOLDS.filter((rule) => subtotal >= rule.thresholdMinor); + const best = eligible.reduce<(typeof MOCK_THRESHOLDS)[number] | null>( + (top, rule) => (top && top.reductionMinor >= rule.reductionMinor ? top : rule), + null, + ); + return { + shop_id: shopId, + subtotal_minor: subtotal, + best: best + ? { + id: best.id, + shop_id: shopId, + name: best.name, + currency: best.currency, + threshold_minor: best.thresholdMinor, + reduction_minor: best.reductionMinor, + converted_threshold_minor: best.thresholdMinor, + converted_reduction_minor: best.reductionMinor, + ends_at: best.endsAt, + } + : null, + }; + }), + ); + }, + listMyCoupons: () => Promise.resolve(state.coupons.map((c) => ({ ...c }))), claimCoupon: (templateId: string) => { @@ -2856,6 +2919,10 @@ export function createMockApi(): ApiClient { createCouponTemplate: (_body: CouponTemplateInput) => unsupported(), updateCouponTemplate: (_id: string, _body: CouponTemplateInput) => unsupported(), deleteCouponTemplate: (_id: string) => unsupported(), + listThresholdPromotions: () => unsupported(), + createThresholdPromotion: () => unsupported(), + updateThresholdPromotion: () => unsupported(), + deleteThresholdPromotion: () => unsupported(), listFlashSales: () => unsupported(), createFlashSale: (_body: FlashSaleSessionInput) => unsupported(), updateFlashSale: (_id: string, _body: FlashSaleSessionInput) => unsupported(), diff --git a/apps/mall/mock/data.ts b/apps/mall/mock/data.ts index a8a1ebd..24ad65c 100644 --- a/apps/mall/mock/data.ts +++ b/apps/mall/mock/data.ts @@ -60,6 +60,15 @@ export interface MockCoupon { expiresAt: string; } +export interface MockThreshold { + id: string; + name: LocalizedText; + thresholdMinor: number; + reductionMinor: number; + currency: string; + endsAt: string; +} + export interface MockAddress { id: string; recipient: string; @@ -945,6 +954,26 @@ export const MOCK_COUPONS: MockCoupon[] = [ }, ]; +/** Automatic threshold reductions (满减); mutually exclusive with a coupon. */ +export const MOCK_THRESHOLDS: MockThreshold[] = [ + { + id: "tp1", + name: L("$8 off over $99 (auto)", "满 99 减 8(自动)"), + thresholdMinor: 9900, + reductionMinor: 800, + currency: BASE_CURRENCY, + endsAt: "2026-12-31T23:59:59Z", + }, + { + id: "tp2", + name: L("$25 off over $299 (auto)", "满 299 减 25(自动)"), + thresholdMinor: 29900, + reductionMinor: 2500, + currency: BASE_CURRENCY, + endsAt: "2026-12-31T23:59:59Z", + }, +]; + // ---------- marketing ---------- export const SECKILL_SESSIONS: SeckillSession[] = [ @@ -1151,6 +1180,7 @@ export function seedOrders(userId: string): MockOrderSeed { refund_total_minor: 0, shipping_fee_minor: 599, coupon_id: null, + threshold_promotion_id: null, group_activity_id: null, group_id: null, items, diff --git a/apps/mall/pages/checkout/index.vue b/apps/mall/pages/checkout/index.vue index 2262992..a114ef2 100644 --- a/apps/mall/pages/checkout/index.vue +++ b/apps/mall/pages/checkout/index.vue @@ -8,6 +8,8 @@ import type { GroupBuyIntent, LocalizedText, ShippingQuote, + ShopThresholdPreview, + ThresholdRuleView, } from "@vmall/shared"; type CheckoutGroup = { @@ -33,7 +35,13 @@ const items = ref([]); const addresses = ref([]); /** Owned, still-claimable coupons, by shop. */ const coupons = ref([]); -const selectedCoupon = reactive>({}); +/** Server preview of the best eligible threshold rule per shop. */ +const thresholdPreviews = ref([]); +/** + * Explicit mutually exclusive per-shop promotion choice: "" (none), + * `coupon:` or `threshold:`. Never both on one shop. + */ +const promotionChoice = reactive>({}); const selectedAddressId = ref(""); const remark = ref(""); const loading = ref(true); @@ -105,8 +113,19 @@ const shippingTotalMinor = computed(() => shippingQuote.value?.total_minor ?? 0) const couponDiscountMinor = computed(() => groups.value.reduce((sum, group) => sum + (selectedCouponFor(group.shopId)?.amount_minor ?? 0), 0), ); +/** Threshold preview in the display currency, from the server preview. */ +const thresholdDiscountMinor = computed(() => + groups.value.reduce( + (sum, group) => sum + (selectedThresholdFor(group.shopId)?.converted_reduction_minor ?? 0), + 0, + ), +); const grandTotalMinor = computed( - () => totalMinor.value + shippingTotalMinor.value - couponDiscountMinor.value, + () => + totalMinor.value + + shippingTotalMinor.value - + couponDiscountMinor.value - + thresholdDiscountMinor.value, ); const shippingHint = computed(() => { if (quoteFailed.value) return t("checkout.shippingQuoteError"); @@ -123,12 +142,24 @@ function couponsForShop(shopId: string): Coupon[] { return coupons.value.filter((c) => c.shop_id === shopId && c.status === "claimed"); } +function bestThresholdFor(shopId: string): ThresholdRuleView | null { + return thresholdPreviews.value.find((p) => p.shop_id === shopId)?.best ?? null; +} + function selectedCouponFor(shopId: string): Coupon | null { - const id = selectedCoupon[shopId]; - if (!id) return null; + const choice = promotionChoice[shopId] ?? ""; + if (!choice.startsWith("coupon:")) return null; + const id = choice.slice("coupon:".length); return coupons.value.find((c) => c.id === id) ?? null; } +function selectedThresholdFor(shopId: string): ThresholdRuleView | null { + const choice = promotionChoice[shopId] ?? ""; + if (!choice.startsWith("threshold:")) return null; + const best = bestThresholdFor(shopId); + return best && choice === `threshold:${best.id}` ? best : null; +} + function selectedAddress(): Address | null { const saved = addresses.value.find((a) => a.id === selectedAddressId.value); if (saved) { @@ -185,15 +216,18 @@ async function loadCart(): Promise { loading.value = true; error.value = ""; try { - const [snapshot, list, couponList] = await Promise.all([ + const [snapshot, list, couponList, previews] = await Promise.all([ $api.getCart(), $api.listMyAddresses(), // Coupons are optional: a failure must not block checkout. $api.listMyCoupons().catch(() => [] as Coupon[]), + // Threshold preview is display-only; checkout revalidates authoritatively. + $api.previewCheckoutThresholds(currency.value).catch(() => [] as ShopThresholdPreview[]), ]); items.value = snapshot.items; addresses.value = list; coupons.value = couponList; + thresholdPreviews.value = previews; // A stashed group intent only applies while its SKU is still in the cart. const intent = groupIntent.value; if (intent && !items.value.some((item) => item.sku_id === intent.sku_id)) { @@ -220,13 +254,17 @@ async function submitOrder(): Promise { error.value = ""; try { const couponByShop: Record = {}; - for (const [shopId, couponId] of Object.entries(selectedCoupon)) { - if (couponId) couponByShop[shopId] = couponId; + const thresholdByShop: Record = {}; + for (const [shopId, choice] of Object.entries(promotionChoice)) { + if (choice.startsWith("coupon:")) couponByShop[shopId] = choice.slice("coupon:".length); + if (choice.startsWith("threshold:")) + thresholdByShop[shopId] = choice.slice("threshold:".length); } const result = await $api.checkout( address, currency.value, couponByShop, + thresholdByShop, groupIntent.value ?? undefined, getCheckoutIdempotencyKey(), ); @@ -376,29 +414,54 @@ onBeforeUnmount(() => { {{ shippingHint }}
- {{ t("checkout.coupon") }} - + {{ t("checkout.noPromotion") }} + + +
@@ -444,6 +507,12 @@ onBeforeUnmount(() => { >− +
+ {{ t("checkout.thresholdDiscount") }} + − +
{{ t("checkout.total") }} { {{ t("checkout.freeShipping") }}

- {{ t("checkout.couponDiscount") }}: + {{ order.threshold_promotion_id ? t("checkout.thresholdDiscount") : t("checkout.couponDiscount") }}:

diff --git a/apps/mall/plugins/api.ts b/apps/mall/plugins/api.ts index 4f8fced..5f7837c 100644 --- a/apps/mall/plugins/api.ts +++ b/apps/mall/plugins/api.ts @@ -90,6 +90,8 @@ const LIVE_PICKS = { listShopCouponTemplates: a.listShopCouponTemplates, listMyCoupons: a.listMyCoupons, claimCoupon: a.claimCoupon, + // Threshold promotions share the shop-marketing domain with coupons. + previewCheckoutThresholds: a.previewCheckoutThresholds, }), points: (a: ApiClient) => ({ listPointsProducts: a.listPointsProducts, diff --git a/apps/mobile/pages/checkout.vue b/apps/mobile/pages/checkout.vue index 7d9a511..589ac67 100644 --- a/apps/mobile/pages/checkout.vue +++ b/apps/mobile/pages/checkout.vue @@ -249,6 +249,7 @@ async function submitOrder(): Promise { shipping_address: address, currency: sourceCurrency.value, coupon_by_shop: couponSelection ?? {}, + threshold_by_shop: {}, group_buy: null, lines: items.value.map((item) => ({ sku_id: item.sku_id, qty: item.qty })), }); @@ -260,6 +261,7 @@ async function submitOrder(): Promise { sourceCurrency.value, couponSelection, undefined, + undefined, idempotencyKey, ); if (!result.payment_id || result.orders.length === 0) { diff --git a/apps/shop-admin/app.vue b/apps/shop-admin/app.vue index 4d55480..399db49 100644 --- a/apps/shop-admin/app.vue +++ b/apps/shop-admin/app.vue @@ -96,6 +96,12 @@ watchEffect(() => { class="text-muted hover:bg-bg rounded-md px-3 py-2 text-sm font-medium" >{{ $t("nav.coupons") }} + {{ $t("nav.promotions") }} +import type { Currency, ThresholdPromotion, ThresholdPromotionInput } from "@vmall/shared"; + +definePageMeta({ middleware: "auth" }); + +const { $api } = useNuxtApp(); +const { locale, t: translate } = useI18n(); +const { load: loadMoney, fmt } = useMoney(); + +const rules = ref([]); +const currencies = ref([]); +const loading = ref(true); +const saving = ref(false); +const actionId = ref(""); +const error = ref(""); +const message = ref(""); +const editingId = ref(""); + +const form = reactive({ + nameEn: "", + nameZh: "", + thresholdMinor: 0, + reductionMinor: 0, + currency: "USD", + displayOrder: 0, + enabled: true, + startsAt: "", + endsAt: "", +}); + +function toLocalInput(iso: string): string { + const d = new Date(iso); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +function resetForm(): void { + const now = new Date(); + const later = new Date(now.getTime() + 30 * 24 * 3600 * 1000); + editingId.value = ""; + form.nameEn = ""; + form.nameZh = ""; + form.thresholdMinor = 0; + form.reductionMinor = 0; + form.currency = + currencies.value.find((c) => c.is_base)?.code ?? currencies.value[0]?.code ?? "USD"; + form.displayOrder = 0; + form.enabled = true; + form.startsAt = toLocalInput(now.toISOString()); + form.endsAt = toLocalInput(later.toISOString()); +} + +async function loadRules(): Promise { + loading.value = true; + error.value = ""; + try { + rules.value = await $api.shop.listThresholdPromotions(); + } catch (err: unknown) { + error.value = err instanceof Error ? err.message : translate("common.error"); + } finally { + loading.value = false; + } +} + +async function loadCurrencies(): Promise { + try { + currencies.value = (await $api.listCurrencies()).filter((c) => c.enabled); + } catch { + currencies.value = []; + } +} + +function payload(): ThresholdPromotionInput { + return { + name: { en: form.nameEn.trim(), zh: form.nameZh.trim() }, + currency: form.currency, + threshold_minor: Number(form.thresholdMinor), + reduction_minor: Number(form.reductionMinor), + enabled: form.enabled, + display_order: Number(form.displayOrder), + starts_at: new Date(form.startsAt).toISOString(), + ends_at: new Date(form.endsAt).toISOString(), + }; +} + +async function submit(): Promise { + error.value = ""; + message.value = ""; + const body = payload(); + if (!body.name.en || !body.name.zh) { + error.value = translate("shop.promoNameRequired"); + return; + } + if (!(body.threshold_minor > 0)) { + error.value = translate("shop.promoThresholdInvalid"); + return; + } + if (!(body.reduction_minor > 0) || body.reduction_minor >= body.threshold_minor) { + error.value = translate("shop.promoReductionInvalid"); + return; + } + if (new Date(body.ends_at) <= new Date(body.starts_at)) { + error.value = translate("shop.couponWindowInvalid"); + return; + } + saving.value = true; + try { + if (editingId.value) await $api.shop.updateThresholdPromotion(editingId.value, body); + else await $api.shop.createThresholdPromotion(body); + message.value = translate("shop.promoSaved"); + resetForm(); + await loadRules(); + } catch (err: unknown) { + error.value = err instanceof Error ? err.message : translate("common.error"); + } finally { + saving.value = false; + } +} + +function edit(rule: ThresholdPromotion): void { + editingId.value = rule.id; + form.nameEn = rule.name.en ?? ""; + form.nameZh = rule.name.zh ?? ""; + form.thresholdMinor = rule.threshold_minor; + form.reductionMinor = rule.reduction_minor; + form.currency = rule.currency; + form.displayOrder = rule.display_order; + form.enabled = rule.enabled; + form.startsAt = toLocalInput(rule.starts_at); + form.endsAt = toLocalInput(rule.ends_at); +} + +async function toggleEnabled(rule: ThresholdPromotion): Promise { + actionId.value = rule.id; + error.value = ""; + try { + await $api.shop.updateThresholdPromotion(rule.id, { + name: rule.name, + currency: rule.currency, + threshold_minor: rule.threshold_minor, + reduction_minor: rule.reduction_minor, + enabled: !rule.enabled, + display_order: rule.display_order, + starts_at: rule.starts_at, + ends_at: rule.ends_at, + }); + await loadRules(); + } catch (err: unknown) { + error.value = err instanceof Error ? err.message : translate("common.error"); + } finally { + actionId.value = ""; + } +} + +// Referenced rules are archived server-side; the list keeps them visible. +async function remove(rule: ThresholdPromotion): Promise { + actionId.value = rule.id; + error.value = ""; + message.value = ""; + try { + await $api.shop.deleteThresholdPromotion(rule.id); + message.value = translate("shop.promoDeleted"); + await loadRules(); + } catch (err: unknown) { + error.value = err instanceof Error ? err.message : translate("common.error"); + } finally { + actionId.value = ""; + } +} + +onMounted(async () => { + loadMoney(); + await loadCurrencies(); + resetForm(); + await loadRules(); +}); + + + diff --git a/docs/TBD-marketing.md b/docs/TBD-marketing.md index 03c40cd..9b95400 100644 --- a/docs/TBD-marketing.md +++ b/docs/TBD-marketing.md @@ -97,10 +97,12 @@ These patterns are recorded for later product discovery only. They are not appro have no OpenSpec change, and must be redesigned under VMall's minor-unit, JSONB, RBAC, and transaction rules before implementation. -- [ ] **Threshold reductions / 满减** — merchant rules need a shop scope, localized name, - threshold and reduction in one currency, active window, and an explicit best-eligible rule. - Coupon vs flash/group is already exclusive (reject coupon on activity-priced shop orders). - Keep 满减 independent from coupons until a stacking policy with those shop coupons is specified. +- [x] **Threshold reductions / 满减** — implemented as `add-threshold-promotions`: shop-scoped + rules (bilingual name, one-currency integer threshold/reduction with `reduction < threshold`, + active window, display order), deterministic best-eligible evaluation with currency conversion, + explicit coupon/threshold/activity exclusivity per shop order, and checkout persistence on + `orders.discount_minor` + `threshold_promotion_id`. Stacking with coupons stays rejected; the + mall checkout presents the exclusive choice as one radio group per shop. - [x] **Favorites** — implemented as `add-favorites` with explicit nullable product/shop foreign keys, an exactly-one-target check, and one partial unique index per kind. Do not revisit a polymorphic `target_id`/type pair. diff --git a/docs/code_index/consoles.md b/docs/code_index/consoles.md index 99b21ed..eed6309 100644 --- a/docs/code_index/consoles.md +++ b/docs/code_index/consoles.md @@ -13,7 +13,7 @@ per page. Navigation is a static list in each `app.vue`. | `pages/shipments.vue` | shipment list, mark shipped | | `pages/aftersales/` | after-sale processing workspace | | `pages/reviews.vue` | review list + one-time reply | -| `pages/coupons.vue`, `flash-sales.vue`, `group-buying.vue` | merchant marketing | +| `pages/coupons.vue`, `promotions.vue`, `flash-sales.vue`, `group-buying.vue` | merchant marketing | | `pages/invoices.vue` | invoice issuing | | `pages/freight-templates.vue` | freight templates + region rules | | `pages/shop-profile.vue` | own shop profile self-edit | diff --git a/docs/code_index/marketing.md b/docs/code_index/marketing.md index dd21a7d..9620585 100644 --- a/docs/code_index/marketing.md +++ b/docs/code_index/marketing.md @@ -1,25 +1,28 @@ -# Index: Marketing (coupons, flash sales, group buying, points) +# Index: Marketing (coupons, threshold promotions, flash sales, group buying, points) Domain design: `docs/domains/marketing.md`. Specs: `openspec/specs/shop-coupons/`, -`flash-sales/`, `group-buying/`, `points-mall/`. +`threshold-promotions/`, `flash-sales/`, `group-buying/`, `points-mall/`. ## Backend (`apps/api`) | Module | Holds | |---|---| | `modules/coupon/` | template CRUD, claim, checkout discount + redemption | +| `modules/promotion/` | threshold rule CRUD (archive-vs-delete), best-eligible evaluation, checkout reduction, cart preview | | `modules/flash_sale/` | sessions/items CRUD, activity price resolution, reserved stock | | `modules/group_buying/` | activities, groups, seat claim at payment | | `modules/points/` | points catalog, redemption, fulfillment | Migrations: `0011_shop_coupons.sql`, `0012_points_mall.sql`, -`0013_flash_sales.sql`, `0014_group_buying.sql`. -Tests: `tests/coupons.rs`, `tests/flash_sales.rs`, `tests/group_buying.rs`, -`tests/points.rs`. +`0013_flash_sales.sql`, `0014_group_buying.sql`, `0026_threshold_promotions.sql`. +Tests: `tests/coupons.rs`, `tests/promotions.rs`, `tests/flash_sales.rs`, +`tests/group_buying.rs`, `tests/points.rs`. ## Frontends - Mall: `pages/seckill.vue`, `pages/collective.vue`, `pages/integral.vue`, - `pages/user/coupons.vue`; checkout coupon selection in `pages/checkout/index.vue` -- Shop-admin: `pages/coupons.vue`, `pages/flash-sales.vue`, `pages/group-buying.vue` + `pages/user/coupons.vue`; exclusive coupon/threshold radio in + `pages/checkout/index.vue` +- Shop-admin: `pages/coupons.vue`, `pages/promotions.vue`, + `pages/flash-sales.vue`, `pages/group-buying.vue` - Admin: `pages/points-products.vue`, `pages/points-orders.vue` diff --git a/docs/code_index/order.md b/docs/code_index/order.md index 1eb5553..58da6dd 100644 --- a/docs/code_index/order.md +++ b/docs/code_index/order.md @@ -27,6 +27,7 @@ Domain design: `docs/domains/orders.md`. Specs: `openspec/specs/order/`, | `migrations/0017_freight_templates.sql` | freight tables, `orders.shipping_fee_minor`, item snapshots, `skus.weight_grams` | | `migrations/0024_payments.sql` | payments domain, legacy-demo provenance, aftersale refund states | | `migrations/0025_checkout_request_header_hash.sql` | cart-independent header hash for checkout idempotency | +| `migrations/0026_threshold_promotions.sql` | threshold rules, `orders.threshold_promotion_id`, coupon/threshold exclusivity check | | `tests/orders.rs`, `tests/freight.rs`, `tests/payments.rs` | integration coverage | ## Shared contract (`packages/shared/src`) diff --git a/openspec/changes/archive/2026-10-19-add-threshold-promotions/.openspec.yaml b/openspec/changes/archive/2026-10-19-add-threshold-promotions/.openspec.yaml new file mode 100644 index 0000000..a216797 --- /dev/null +++ b/openspec/changes/archive/2026-10-19-add-threshold-promotions/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-15 diff --git a/openspec/changes/archive/2026-10-19-add-threshold-promotions/design.md b/openspec/changes/archive/2026-10-19-add-threshold-promotions/design.md new file mode 100644 index 0000000..d467201 --- /dev/null +++ b/openspec/changes/archive/2026-10-19-add-threshold-promotions/design.md @@ -0,0 +1,108 @@ +# Design: add-threshold-promotions + +## Context + +Checkout today applies at most one customer-selected shop coupon per shop order +(`coupon_by_shop`), persists the realized amount in `orders.discount_minor` + +`orders.coupon_id`, and rejects coupons on activity-priced (flash/group) shop +orders. Threshold promotions (满减) are the same shape minus the claim step: +the rule is shop-owned, active within a window, and applies automatically when +the merchandise subtotal reaches the threshold. The smallest coherent design +mirrors the coupon slice instead of introducing a promotion engine. + +## Decisions + +### D1. Shop-scoped rule table, no claim/inventory + +`threshold_promotions`: `id`, `shop_id`, `name {en,zh} JSONB`, `currency`, +`threshold_minor`, `reduction_minor`, `starts_at`, `ends_at`, `enabled`, +`archived`, `display_order`, timestamps. Checks: `threshold_minor > 0`, +`reduction_minor > 0`, `reduction_minor < threshold_minor` (keeps totals +positive; zero-payment checkouts stay impossible by construction), +`ends_at > starts_at`. No stock column: nothing is claimed, so cancellation +restores nothing. + +Rationale: 满减 is a price computation, not an inventory artifact. Every +amount stays integer minor units with the rule's own currency, converted to the +buyer currency through the existing `convert_minor` path (same as coupons). + +### D2. Explicit choice, server-authoritative amount + +Checkout body gains optional `threshold_by_shop: {shop_id: rule_id}` next to +`coupon_by_shop`. Rules per shop order: + +- both a coupon and a threshold rule sent for the same shop → 409 + "choose either the coupon or the automatic reduction"; +- activity-priced shop (flash/group) + threshold rule → 409 (same exclusivity + as coupons); +- rule must belong to the shop, be enabled, unarchived, inside its window, and + the converted merchandise subtotal must reach the converted threshold, else + 409; +- the discount is always the converted `reduction_minor` capped at the + subtotal; the client sends which rule, never how much. + +The mall checkout renders one radio group per shop (`threshold` / each +eligible coupon / none), so the exclusivity choice is explicit in the UI, not +a silent server preference. Eligibility for display comes from a read-only +preview endpoint (same inputs as checkout, no writes) so the customer sees the +reduction before submitting. + +Both maps join the existing request-hash/header-hash idempotency payloads, so +a changed choice is a 409 replay conflict, identical choice replays. + +### D3. Order persistence reuses `discount_minor` + +`orders` gains nullable `threshold_promotion_id` (FK) next to `coupon_id`; +the realized amount continues to live in `discount_minor`. Exactly one of +`coupon_id` / `threshold_promotion_id` is set (CHECK). Payment, refund, +settlement, and aftersale code paths already operate on order totals and +`discount_minor`; they need no change. + +### D4. Module placement + +New `modules/promotion/` slice (handlers/service/repo/dto) rather than growing +`modules/coupon/`: different lifecycle (no claims, no customer snapshot), +different exclusivity surface, and the repo rule that new features land in +their own bounded context. Shop-admin CRUD is owner-scoped via +`auth.own_shop()` exactly like coupon templates. + +### D5. Read model + +- Shop: paginated rule CRUD + archive (referenced rules are archived, never + deleted, so historical orders keep their FK target). +- Mall checkout preview: per-shop eligible best rule (highest converted + reduction among eligible rules) plus the chosen-rule validation at checkout. + "Best" is deterministic: max `reduction_minor` after conversion, ties broken + by `display_order` then `created_at`. + +## Migration + +`0026_threshold_promotions.sql`: create table + indexes +(`shop_id, enabled, window`), `orders.threshold_promotion_id` + FK + +exclusive CHECK vs `coupon_id`. Append-only; no backfill. + +## Transaction/lock shape + +Checkout keeps its existing lock order. Threshold rules are read-only inside +the transaction (`SELECT` eligible rules per shop after the subtotal is +known); no `FOR UPDATE` needed because the rule is only priced, never +mutated, and archival mid-checkout simply yields 409 on validation. No new +lock-ordering hazard. + +## Failure modes + +- Rule archived/disabled between preview and submit → 409 at checkout, cart + untouched. +- Subtotal drops below threshold after repricing (e.g. flash resolution) → + 409 before any write (totals are computed before writes today). +- Idempotent replay with a different threshold/coupon choice → 409 via the + existing hash comparison. + +## Verification + +- `cargo test -p vmall-api` (twice): new `tests/promotions.rs` covers CRUD + scoping, window/currency eligibility, best-rule selection, both-chosen 409, + activity-priced 409, discount on order + payment total, replay behavior. +- Builds: mall, shop-admin (+ mobile/admin unaffected but must still build). +- Browser smoke: create rule in shop-admin, check out at/under threshold, + radio exclusivity vs coupon, order detail discount display. diff --git a/openspec/changes/archive/2026-10-19-add-threshold-promotions/proposal.md b/openspec/changes/archive/2026-10-19-add-threshold-promotions/proposal.md new file mode 100644 index 0000000..690750c --- /dev/null +++ b/openspec/changes/archive/2026-10-19-add-threshold-promotions/proposal.md @@ -0,0 +1,29 @@ +## Why + +Coupons require the customer to claim and select; merchants also need automatic threshold promotions (满减) that apply without a code. This is the remaining unchecked marketing gap in `docs/TBD-marketing.md`, and it reuses the existing per-shop checkout split rather than introducing a generic promotion engine. + +## What Changes + +- Add shop-scoped threshold promotion rules (满减): localized name, threshold and reduction in one currency, active window, enabled/archived lifecycle, and an explicit best-eligible rule selection. +- Extend checkout to evaluate eligible rules per shop order automatically, persist the server-authoritative discount on the order, and display it in checkout and order surfaces. +- Enforce exclusivity consistently with the existing promotion policy: a shop order priced by flash sale or group buying applies no threshold reduction, and a threshold reduction and a shop coupon are never combined on the same shop order — the customer-facing checkout MUST make the choice explicit rather than silently stacking. +- Restore nothing on cancellation: threshold reductions are computed at checkout, not claimed inventory, so cancellation needs no restoration beyond existing stock/coupon logic. +- Add shop-admin rule management and replace any mock full-reduction display with live data. + +## Capabilities + +### New Capabilities +- `threshold-promotions`: Shop threshold promotion rules, eligibility evaluation, and checkout application. + +### Modified Capabilities +- `order`: Checkout evaluates and persists the automatic per-shop threshold discount and the coupon/threshold exclusivity. +- `frontend-mall`: Checkout and order surfaces display the automatic reduction and the explicit coupon-vs-threshold choice. +- `frontend-shop-admin`: Merchants manage threshold rules only within their own shop. + +## Non-goals + +Stacking across promotion types, platform-funded reductions, 满折 (percentage discounts), multi-tier ladders, gift promotions, and campaign targeting are excluded. The promotion-window database invariant (`promotion_windows` exclusion constraint) stays deferred as recorded in `docs/TBD-marketing.md`. + +## Impact + +Adds a Rust `promotion` (or coupon-adjacent) module slice and one migration, shared types/API/locales, mall checkout display and shop-admin management screens, and a checkout response contract extension. No payment, refund, or settlement changes: the reduction lowers the shop-order total before payment exactly like the existing coupon discount. diff --git a/openspec/changes/archive/2026-10-19-add-threshold-promotions/specs/frontend-mall/spec.md b/openspec/changes/archive/2026-10-19-add-threshold-promotions/specs/frontend-mall/spec.md new file mode 100644 index 0000000..0c5ce2f --- /dev/null +++ b/openspec/changes/archive/2026-10-19-add-threshold-promotions/specs/frontend-mall/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: Checkout promotion choice display +The mall checkout SHALL present, per shop, the eligible automatic threshold reduction and the eligible coupons as one explicit mutually exclusive choice (at most one of best threshold rule, one coupon, or none), using the server preview for amounts and never client-computed discounts. Order detail and checkout confirmation SHALL display the realized reduction from the order's server-persisted discount. All copy SHALL use `$t()` keys owned by the mall app. + +#### Scenario: threshold shown as exclusive option +- **WHEN** a shop in the cart has both an eligible threshold rule and claimable coupons +- **THEN** checkout renders them in one radio group and selecting one clears the other + +#### Scenario: order detail shows the reduction +- **WHEN** a paid order applied a threshold rule +- **THEN** the order detail shows the persisted reduction amount and no coupon reference diff --git a/openspec/changes/archive/2026-10-19-add-threshold-promotions/specs/frontend-shop-admin/spec.md b/openspec/changes/archive/2026-10-19-add-threshold-promotions/specs/frontend-shop-admin/spec.md new file mode 100644 index 0000000..89e7901 --- /dev/null +++ b/openspec/changes/archive/2026-10-19-add-threshold-promotions/specs/frontend-shop-admin/spec.md @@ -0,0 +1,12 @@ +## ADDED Requirements + +### Requirement: Merchant threshold-promotion management +The shop-admin console SHALL let the owning shop list, create, edit, enable/disable and archive/delete its threshold promotion rules with bilingual name fields, integer minor-unit amounts in the rule currency, active-window pickers and ordering, using existing shared UI components and tokens. Rules referenced by orders SHALL be presented as archive-only. Cross-shop rules SHALL NOT be visible or addressable. + +#### Scenario: merchant manages a rule end to end +- **WHEN** the owning shop creates, edits, disables and archives a rule +- **THEN** each action persists through the live API and the list reflects the current lifecycle state + +#### Scenario: referenced rule cannot be deleted +- **WHEN** a rule is referenced by at least one order +- **THEN** the console offers archive instead of delete diff --git a/openspec/changes/archive/2026-10-19-add-threshold-promotions/specs/order/spec.md b/openspec/changes/archive/2026-10-19-add-threshold-promotions/specs/order/spec.md new file mode 100644 index 0000000..0062a1e --- /dev/null +++ b/openspec/changes/archive/2026-10-19-add-threshold-promotions/specs/order/spec.md @@ -0,0 +1,40 @@ +## MODIFIED Requirements + +### Requirement: Checkout splits by shop +`POST /api/orders/checkout` SHALL create one order per distinct shop in the cart, in a single database transaction: stock decrement, order + item insert with price snapshots, cart clear, active flash-sale resolution when no group-buy intent applies to the SKU, requested group-buying intent validation, requested coupon redemption when eligible, and requested threshold-promotion application when eligible. All amounts use the cart's SKU currencies converted into the buyer-chosen display currency at checkout time. A group-buy intent SHALL contain exactly one active activity SKU at quantity 1 and either an open group identifier or a request to open a group; the server SHALL snapshot the group price and group identity on the resulting pending-payment order. Group intent SHALL return 409 when that SKU is also eligible for an overlapping flash sale. Each shop order SHALL apply at most one of coupon, threshold promotion, or activity pricing; conflicting selections SHALL return 409 and create no orders. Checkout idempotency hashes SHALL cover the coupon and threshold selections so a changed selection replays as 409 and an identical selection replays the durable result. + +#### Scenario: two shops → two orders +- **WHEN** the cart contains SKUs from shops A and B without group-buy intent +- **THEN** two orders are created, each with only its shop's items, and the cart is empty + +#### Scenario: insufficient stock +- **WHEN** any line's qty exceeds SKU stock or applicable flash-sale stock +- **THEN** the whole checkout returns 409 and no order is created and stock is unchanged + +#### Scenario: active flash price applied +- **WHEN** checkout contains a SKU with an active eligible flash-sale item and no group-buy intent applies to it +- **THEN** the order item records the server-calculated flash-sale price and activity identity + +#### Scenario: coupon rejected on a flash-priced shop order +- **WHEN** the customer selects a coupon for a shop whose generated order applied flash-sale pricing on any line +- **THEN** checkout returns 409 and creates no orders + +#### Scenario: open a group at checkout +- **WHEN** a customer checks out one eligible group-buying SKU at quantity 1 with open-group intent +- **THEN** a pending-payment order and an open group identity are created with the server-resolved group price + +#### Scenario: coupon rejected on a group shop order +- **WHEN** the customer selects a coupon for the shop that carries group-buy intent +- **THEN** checkout returns 409 and creates no orders + +#### Scenario: coupon applies to one matching shop +- **WHEN** the customer selects an eligible shop coupon and that shop's generated order has no flash-priced lines and no group-buy intent +- **THEN** the order records that coupon and its server-calculated discount + +#### Scenario: threshold applies to one matching shop +- **WHEN** the customer selects an eligible threshold rule and that shop's generated order has no activity-priced lines, no group-buy intent and no selected coupon +- **THEN** the order records that rule and its server-calculated reduction + +#### Scenario: coupon and threshold conflict +- **WHEN** the customer selects both a coupon and a threshold rule for the same shop +- **THEN** checkout returns 409 and creates no orders diff --git a/openspec/changes/archive/2026-10-19-add-threshold-promotions/specs/threshold-promotions/spec.md b/openspec/changes/archive/2026-10-19-add-threshold-promotions/specs/threshold-promotions/spec.md new file mode 100644 index 0000000..8a1dba3 --- /dev/null +++ b/openspec/changes/archive/2026-10-19-add-threshold-promotions/specs/threshold-promotions/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: Shop threshold-promotion management +A shop owner or staff member SHALL manage threshold promotion rules (满减) scoped to their own shop through `auth.own_shop()`; cross-shop access returns 404. A rule SHALL carry a localized name `{"en","zh"}`, one currency, integer minor-unit `threshold_minor` and `reduction_minor` with `threshold_minor > 0`, `reduction_minor > 0` and `reduction_minor < threshold_minor`, an active window with `ends_at > starts_at`, an enabled flag, and a display order. Rules referenced by any order SHALL be archived, never deleted; unreferenced rules MAY be deleted. Platform administrators SHALL have no write path to shop rules. + +#### Scenario: merchant creates a rule +- **WHEN** the owning shop creates a rule with valid bilingual name, amounts, currency and window +- **THEN** the rule is persisted and listed for that shop only + +#### Scenario: invalid amounts are rejected +- **WHEN** a rule has a reduction that is not positive or not smaller than its threshold +- **THEN** the API returns 400 and writes nothing + +#### Scenario: cross-shop rule access is hidden +- **WHEN** a shop user requests or mutates a rule belonging to another shop +- **THEN** the API returns 404 + +#### Scenario: referenced rule is archived not deleted +- **WHEN** a shop deletes a rule that at least one order references +- **THEN** the rule becomes archived, remains readable for order history, and no longer applies to new checkouts + +### Requirement: Threshold eligibility evaluation +Eligibility SHALL be evaluated server-side in the buyer's checkout currency: a rule is eligible for a shop order when it is enabled, unarchived, inside its active window, and the merchandise subtotal converted from the rule's currency reaches the converted threshold. The eligible best rule SHALL be the one with the highest converted `reduction_minor`, ties broken by display order then creation time. A read-only preview SHALL report the per-shop best eligible rule and its computed reduction for the current cart, address and currency without writing anything. + +#### Scenario: best eligible rule wins +- **WHEN** two active rules of one shop both meet the converted subtotal +- **THEN** the preview reports the rule with the higher converted reduction + +#### Scenario: below threshold yields no rule +- **WHEN** the converted merchandise subtotal is below every active rule's converted threshold +- **THEN** the preview reports no eligible rule for that shop + +#### Scenario: out-of-window rule is ineligible +- **WHEN** a rule's window has not started or has ended +- **THEN** it is never reported as eligible and checkout rejects its selection with 409 + +### Requirement: Checkout threshold application +Checkout SHALL accept an optional per-shop threshold rule selection. A shop order SHALL apply at most one price adjustment source: a selected coupon, a selected threshold rule, or activity pricing; sending more than one for the same shop SHALL return 409 and create no orders. The applied discount SHALL be the server-converted `reduction_minor` capped at the shop merchandise subtotal, persisted as the order's `discount_minor` with the rule reference; the client SHALL submit only which rule, never an amount. Orders SHALL record at most one of a coupon reference or a threshold rule reference. Cancellation SHALL NOT restore anything for a threshold reduction because no inventory or claim exists. + +#### Scenario: threshold reduces the shop order total +- **WHEN** the customer selects an eligible threshold rule for a shop with no activity pricing and no coupon +- **THEN** the order records the rule reference and its server-calculated reduction and the payment total decreases by the same amount + +#### Scenario: coupon and threshold together are rejected +- **WHEN** checkout selects both a coupon and a threshold rule for the same shop +- **THEN** checkout returns 409 and creates no orders + +#### Scenario: threshold rejected on an activity-priced shop order +- **WHEN** the customer selects a threshold rule for a shop whose generated order applied flash-sale or group-buy pricing on any line +- **THEN** checkout returns 409 and creates no orders + +#### Scenario: ineligible selection is rejected before writes +- **WHEN** the selected rule is archived, disabled, out of window, another shop's, or the repriced subtotal no longer reaches the threshold +- **THEN** checkout returns 409 before any order, inventory or payment write diff --git a/openspec/changes/archive/2026-10-19-add-threshold-promotions/tasks.md b/openspec/changes/archive/2026-10-19-add-threshold-promotions/tasks.md new file mode 100644 index 0000000..949da6d --- /dev/null +++ b/openspec/changes/archive/2026-10-19-add-threshold-promotions/tasks.md @@ -0,0 +1,25 @@ +# Tasks: add-threshold-promotions + +## 1. Contract and schema + +- [x] 1.1 Add migration `0026_threshold_promotions.sql`: `threshold_promotions` table (checks, indexes), `orders.threshold_promotion_id` + FK + mutually exclusive CHECK vs `coupon_id`. Append-only. +- [x] 1.2 Add shared types (`ThresholdPromotion`, `ThresholdPromotionBody`, preview DTOs), API client methods (shop CRUD, preview), and en/zh locale keys in the owning apps; extend `checkout` body with optional `threshold_by_shop`. + +## 2. Backend module + +- [x] 2.1 Implement `modules/promotion/` (dto/repo/service/handlers): shop-scoped CRUD with `auth.own_shop()`, validation, archive-vs-delete rule, paginated list. +- [x] 2.2 Implement eligibility evaluation: conversion-aware threshold check, deterministic best-rule selection, read-only checkout preview endpoint. +- [x] 2.3 Integrate checkout: `threshold_by_shop` validation, coupon/threshold/activity exclusivity 409s before writes, discount into `discount_minor` + rule reference, idempotency hash coverage of the new selection. + +## 3. Frontends + +- [x] 3.1 Shop-admin promotions page: list/create/edit/enable/archive with existing shared UI components; archive-only for referenced rules. +- [x] 3.2 Mall checkout: per-shop radio group combining best threshold rule with coupons (explicit exclusive choice); order detail/checkout confirmation show the persisted reduction. +- [x] 3.3 Remove any mock full-reduction display and check off the 满减 entry in `docs/TBD-marketing.md`. + +## 4. Verification and closeout + +- [x] 4.1 Add `apps/api/tests/promotions.rs`: CRUD scoping (cross-shop 404), invalid amounts 400, window/currency eligibility, best-rule selection, both-chosen 409, activity-priced 409, discount on order + payment total, idempotent replay vs changed selection, archive-vs-delete. +- [x] 4.2 Run `cargo test -p vmall-api` twice green; build @vmall/mall and @vmall/shop-admin (mobile/admin must still build). +- [x] 4.3 Browser smoke: create rule in shop-admin, check out above/below threshold, coupon-vs-threshold radio exclusivity, order detail reduction display. +- [x] 4.4 Update `docs/code_index/marketing.md` (+ order/consoles entries) for new files; validate strictly and archive. diff --git a/openspec/specs/frontend-mall/spec.md b/openspec/specs/frontend-mall/spec.md index 7d80fb8..b7f1810 100644 --- a/openspec/specs/frontend-mall/spec.md +++ b/openspec/specs/frontend-mall/spec.md @@ -425,3 +425,14 @@ The mall SHALL discover eligible methods from the shared payment API, submit the - **WHEN** external funds arrive but a required group seat is unavailable - **THEN** the mall shows received-but-unapplied and refund progress, not a paid or ordinary retry state +### Requirement: Checkout promotion choice display +The mall checkout SHALL present, per shop, the eligible automatic threshold reduction and the eligible coupons as one explicit mutually exclusive choice (at most one of best threshold rule, one coupon, or none), using the server preview for amounts and never client-computed discounts. Order detail and checkout confirmation SHALL display the realized reduction from the order's server-persisted discount. All copy SHALL use `$t()` keys owned by the mall app. + +#### Scenario: threshold shown as exclusive option +- **WHEN** a shop in the cart has both an eligible threshold rule and claimable coupons +- **THEN** checkout renders them in one radio group and selecting one clears the other + +#### Scenario: order detail shows the reduction +- **WHEN** a paid order applied a threshold rule +- **THEN** the order detail shows the persisted reduction amount and no coupon reference + diff --git a/openspec/specs/frontend-shop-admin/spec.md b/openspec/specs/frontend-shop-admin/spec.md index e316291..0d87b52 100644 --- a/openspec/specs/frontend-shop-admin/spec.md +++ b/openspec/specs/frontend-shop-admin/spec.md @@ -157,3 +157,14 @@ The shop owner SHALL view the shop account summary (available and frozen balance - **WHEN** the platform rejects the shop owner's pending withdrawal - **THEN** the shop-account summary shows the amount returned to available balance and the request shows as rejected +### Requirement: Merchant threshold-promotion management +The shop-admin console SHALL let the owning shop list, create, edit, enable/disable and archive/delete its threshold promotion rules with bilingual name fields, integer minor-unit amounts in the rule currency, active-window pickers and ordering, using existing shared UI components and tokens. Rules referenced by orders SHALL be presented as archive-only. Cross-shop rules SHALL NOT be visible or addressable. + +#### Scenario: merchant manages a rule end to end +- **WHEN** the owning shop creates, edits, disables and archives a rule +- **THEN** each action persists through the live API and the list reflects the current lifecycle state + +#### Scenario: referenced rule cannot be deleted +- **WHEN** a rule is referenced by at least one order +- **THEN** the console offers archive instead of delete + diff --git a/openspec/specs/order/spec.md b/openspec/specs/order/spec.md index 09ae228..0e1f038 100644 --- a/openspec/specs/order/spec.md +++ b/openspec/specs/order/spec.md @@ -4,7 +4,7 @@ Checkout splitting a cart into per-shop orders, plus order lifecycle and ownership. ## Requirements ### Requirement: Checkout splits by shop -`POST /api/orders/checkout` SHALL create one order per distinct shop in the cart, in a single database transaction: stock decrement, order + item insert with price snapshots, cart clear, active flash-sale resolution when no group-buy intent applies to the SKU, requested group-buying intent validation, and requested coupon redemption when eligible. All amounts use the cart's SKU currencies converted into the buyer-chosen display currency at checkout time. A group-buy intent SHALL contain exactly one active activity SKU at quantity 1 and either an open group identifier or a request to open a group; the server SHALL snapshot the group price and group identity on the resulting pending-payment order. Group intent SHALL return 409 when that SKU is also eligible for an overlapping flash sale. For each remaining cart line without group intent the server SHALL resolve an eligible active flash-sale item as specified by flash-sales, snapshot the final unit price and activity identity, and charge standard price for ineligible quantity. The request MAY select at most one owned coupon per generated shop order; the server SHALL persist `coupon_id`, `discount_minor`, and post-discount `total_minor` only after ownership, shop, status, window, and threshold validation. The client SHALL NOT provide a discount amount. The server SHALL reject a coupon (409) for any shop order that applied flash-sale pricing or carries group-buy intent. +`POST /api/orders/checkout` SHALL create one order per distinct shop in the cart, in a single database transaction: stock decrement, order + item insert with price snapshots, cart clear, active flash-sale resolution when no group-buy intent applies to the SKU, requested group-buying intent validation, requested coupon redemption when eligible, and requested threshold-promotion application when eligible. All amounts use the cart's SKU currencies converted into the buyer-chosen display currency at checkout time. A group-buy intent SHALL contain exactly one active activity SKU at quantity 1 and either an open group identifier or a request to open a group; the server SHALL snapshot the group price and group identity on the resulting pending-payment order. Group intent SHALL return 409 when that SKU is also eligible for an overlapping flash sale. Each shop order SHALL apply at most one of coupon, threshold promotion, or activity pricing; conflicting selections SHALL return 409 and create no orders. Checkout idempotency hashes SHALL cover the coupon and threshold selections so a changed selection replays as 409 and an identical selection replays the durable result. #### Scenario: two shops → two orders - **WHEN** the cart contains SKUs from shops A and B without group-buy intent @@ -34,6 +34,14 @@ Checkout splitting a cart into per-shop orders, plus order lifecycle and ownersh - **WHEN** the customer selects an eligible shop coupon and that shop's generated order has no flash-priced lines and no group-buy intent - **THEN** the order records that coupon and its server-calculated discount +#### Scenario: threshold applies to one matching shop +- **WHEN** the customer selects an eligible threshold rule and that shop's generated order has no activity-priced lines, no group-buy intent and no selected coupon +- **THEN** the order records that rule and its server-calculated reduction + +#### Scenario: coupon and threshold conflict +- **WHEN** the customer selects both a coupon and a threshold rule for the same shop +- **THEN** checkout returns 409 and creates no orders + ### Requirement: Order lifecycle Status transitions SHALL be pending_payment → paid → fulfilling → shipped → completed. An order SHALL become paid only through application of its payment's funded receipt, with all member orders applied atomically. Cancellation SHALL be allowed only from pending_payment when the payment has no unresolved or applied collection; cancelling one member SHALL cancel its entire unpaid payment group atomically and restore SKU stock, reserved flash-sale stock and redeemed coupons exactly once. Unknown provider outcomes SHALL block cancellation with 409. Pending-payment cancellation SHALL NOT change group paid-member counts. When a cancelled order opened a group with zero paid members, that group SHALL become cancelled. Payment SHALL atomically validate and claim any required open group seats. A wallet attempt against a full, expired or cancelled group SHALL return 409 without collection or order changes. External collection that cannot claim a seat SHALL retain its receipt, leave every member unpaid and schedule compensation rather than imply funds were rolled back. diff --git a/openspec/specs/threshold-promotions/spec.md b/openspec/specs/threshold-promotions/spec.md new file mode 100644 index 0000000..0e1aef2 --- /dev/null +++ b/openspec/specs/threshold-promotions/spec.md @@ -0,0 +1,58 @@ +# threshold-promotions Specification + +## Purpose +TBD - created by archiving change add-threshold-promotions. Update Purpose after archive. +## Requirements +### Requirement: Shop threshold-promotion management +A shop owner or staff member SHALL manage threshold promotion rules (满减) scoped to their own shop through `auth.own_shop()`; cross-shop access returns 404. A rule SHALL carry a localized name `{"en","zh"}`, one currency, integer minor-unit `threshold_minor` and `reduction_minor` with `threshold_minor > 0`, `reduction_minor > 0` and `reduction_minor < threshold_minor`, an active window with `ends_at > starts_at`, an enabled flag, and a display order. Rules referenced by any order SHALL be archived, never deleted; unreferenced rules MAY be deleted. Platform administrators SHALL have no write path to shop rules. + +#### Scenario: merchant creates a rule +- **WHEN** the owning shop creates a rule with valid bilingual name, amounts, currency and window +- **THEN** the rule is persisted and listed for that shop only + +#### Scenario: invalid amounts are rejected +- **WHEN** a rule has a reduction that is not positive or not smaller than its threshold +- **THEN** the API returns 400 and writes nothing + +#### Scenario: cross-shop rule access is hidden +- **WHEN** a shop user requests or mutates a rule belonging to another shop +- **THEN** the API returns 404 + +#### Scenario: referenced rule is archived not deleted +- **WHEN** a shop deletes a rule that at least one order references +- **THEN** the rule becomes archived, remains readable for order history, and no longer applies to new checkouts + +### Requirement: Threshold eligibility evaluation +Eligibility SHALL be evaluated server-side in the buyer's checkout currency: a rule is eligible for a shop order when it is enabled, unarchived, inside its active window, and the merchandise subtotal converted from the rule's currency reaches the converted threshold. The eligible best rule SHALL be the one with the highest converted `reduction_minor`, ties broken by display order then creation time. A read-only preview SHALL report the per-shop best eligible rule and its computed reduction for the current cart, address and currency without writing anything. + +#### Scenario: best eligible rule wins +- **WHEN** two active rules of one shop both meet the converted subtotal +- **THEN** the preview reports the rule with the higher converted reduction + +#### Scenario: below threshold yields no rule +- **WHEN** the converted merchandise subtotal is below every active rule's converted threshold +- **THEN** the preview reports no eligible rule for that shop + +#### Scenario: out-of-window rule is ineligible +- **WHEN** a rule's window has not started or has ended +- **THEN** it is never reported as eligible and checkout rejects its selection with 409 + +### Requirement: Checkout threshold application +Checkout SHALL accept an optional per-shop threshold rule selection. A shop order SHALL apply at most one price adjustment source: a selected coupon, a selected threshold rule, or activity pricing; sending more than one for the same shop SHALL return 409 and create no orders. The applied discount SHALL be the server-converted `reduction_minor` capped at the shop merchandise subtotal, persisted as the order's `discount_minor` with the rule reference; the client SHALL submit only which rule, never an amount. Orders SHALL record at most one of a coupon reference or a threshold rule reference. Cancellation SHALL NOT restore anything for a threshold reduction because no inventory or claim exists. + +#### Scenario: threshold reduces the shop order total +- **WHEN** the customer selects an eligible threshold rule for a shop with no activity pricing and no coupon +- **THEN** the order records the rule reference and its server-calculated reduction and the payment total decreases by the same amount + +#### Scenario: coupon and threshold together are rejected +- **WHEN** checkout selects both a coupon and a threshold rule for the same shop +- **THEN** checkout returns 409 and creates no orders + +#### Scenario: threshold rejected on an activity-priced shop order +- **WHEN** the customer selects a threshold rule for a shop whose generated order applied flash-sale or group-buy pricing on any line +- **THEN** checkout returns 409 and creates no orders + +#### Scenario: ineligible selection is rejected before writes +- **WHEN** the selected rule is archived, disabled, out of window, another shop's, or the repriced subtotal no longer reaches the threshold +- **THEN** checkout returns 409 before any order, inventory or payment write + diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 604dea6..c8caaec 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -22,6 +22,9 @@ import type { Coupon, CouponTemplate, CouponTemplateInput, + ShopThresholdPreview, + ThresholdPromotion, + ThresholdPromotionInput, CreatePaymentAttemptBody, Currency, EligibleMethod, @@ -264,6 +267,7 @@ export interface ApiClient { shippingAddress: Address, currency: string, couponByShop: Record | undefined, + thresholdByShop: Record | undefined, groupBuy: GroupBuyIntent | undefined, idempotencyKey: string, ): Promise; @@ -312,6 +316,8 @@ export interface ApiClient { removeShopFavorite(shopId: string): Promise; /** Public: coupons a shopper could claim from this shop right now. */ listShopCouponTemplates(shopId: string): Promise; + /** Per-shop best eligible threshold rule for the current cart; display-only. */ + previewCheckoutThresholds(currency: string): Promise; listMyCoupons(): Promise; claimCoupon(templateId: string): Promise; applyForAftersale(body: AftersaleApplyBody): Promise; @@ -393,6 +399,11 @@ export interface ApiClient { createCouponTemplate(body: CouponTemplateInput): Promise; updateCouponTemplate(id: string, body: CouponTemplateInput): Promise; deleteCouponTemplate(id: string): Promise; + listThresholdPromotions(): Promise; + createThresholdPromotion(body: ThresholdPromotionInput): Promise; + updateThresholdPromotion(id: string, body: ThresholdPromotionInput): Promise; + /** Referenced rules are archived, not deleted; both return 204. */ + deleteThresholdPromotion(id: string): Promise; listFlashSales(): Promise; createFlashSale(body: FlashSaleSessionInput): Promise; updateFlashSale(id: string, body: FlashSaleSessionInput): Promise; @@ -532,7 +543,7 @@ export function createApi(opts: ApiClientOptions): ApiClient { addCartItem: (skuId, qty) => r("POST", "/cart/items", { sku_id: skuId, qty }), updateCartItem: (skuId, qty) => r("PUT", `/cart/items/${skuId}`, { qty }), removeCartItem: (skuId) => r("DELETE", `/cart/items/${skuId}`), - checkout: (shippingAddress, currency, couponByShop, groupBuy, idempotencyKey) => + checkout: (shippingAddress, currency, couponByShop, thresholdByShop, groupBuy, idempotencyKey) => r( "POST", "/orders/checkout", @@ -540,6 +551,7 @@ export function createApi(opts: ApiClientOptions): ApiClient { shipping_address: shippingAddress, currency, coupon_by_shop: couponByShop ?? {}, + threshold_by_shop: thresholdByShop ?? {}, group_buy: groupBuy ?? null, }, undefined, @@ -576,6 +588,8 @@ export function createApi(opts: ApiClientOptions): ApiClient { addShopFavorite: (shopId) => r("PUT", `/favorites/shops/${shopId}`), removeShopFavorite: (shopId) => r("DELETE", `/favorites/shops/${shopId}`), listShopCouponTemplates: (shopId) => r("GET", `/shops/${shopId}/coupon-templates`), + previewCheckoutThresholds: (currency) => + r("GET", "/checkout/threshold-preview", undefined, { currency }), listMyCoupons: () => r("GET", "/me/coupons"), claimCoupon: (templateId) => r("POST", "/me/coupons", { template_id: templateId }), applyForAftersale: (body) => r("POST", "/aftersales", body), @@ -642,6 +656,10 @@ export function createApi(opts: ApiClientOptions): ApiClient { createCouponTemplate: (body) => r("POST", "/shop/coupon-templates", body), updateCouponTemplate: (id, body) => r("PUT", `/shop/coupon-templates/${id}`, body), deleteCouponTemplate: (id) => r("DELETE", `/shop/coupon-templates/${id}`), + listThresholdPromotions: () => r("GET", "/shop/threshold-promotions"), + createThresholdPromotion: (body) => r("POST", "/shop/threshold-promotions", body), + updateThresholdPromotion: (id, body) => r("PUT", `/shop/threshold-promotions/${id}`, body), + deleteThresholdPromotion: (id) => r("DELETE", `/shop/threshold-promotions/${id}`), listFlashSales: () => r("GET", "/shop/flash-sales"), createFlashSale: (body) => r("POST", "/shop/flash-sales", body), updateFlashSale: (id, body) => r("PUT", `/shop/flash-sales/${id}`, body), diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 1466343..c06067f 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -159,7 +159,7 @@ export interface Order { status: OrderStatus; currency: string; total_minor: number; - /** Server-calculated coupon discount, already applied to `total_minor`. */ + /** Server-calculated promotion discount (coupon or threshold), already applied to `total_minor`. */ discount_minor: number; /** Authoritative sum of completed refunds, integer minor units. */ refund_total_minor: number; @@ -167,6 +167,8 @@ export interface Order { shipping_fee_minor: number; /** The coupon this order redeemed, or null. */ coupon_id: string | null; + /** The threshold promotion this order applied, or null. Never set together with `coupon_id`. */ + threshold_promotion_id: string | null; /** The group-buying activity this order joined, or null. */ group_activity_id: string | null; /** The concrete group this order belongs to, or null. */ @@ -241,6 +243,55 @@ export interface CouponTemplateInput { ends_at: string; } +/** Shop-owned automatic threshold reduction (满减); priced at checkout, never claimed. */ +export interface ThresholdPromotion { + id: string; + shop_id: string; + name: LocalizedText; + currency: string; + threshold_minor: number; + reduction_minor: number; + enabled: boolean; + archived: boolean; + display_order: number; + starts_at: string; + ends_at: string; + created_at: string; + updated_at: string; +} + +/** Create/update body for a shop threshold promotion. */ +export interface ThresholdPromotionInput { + name: LocalizedText; + currency: string; + threshold_minor: number; + reduction_minor: number; + enabled: boolean; + display_order?: number; + starts_at: string; + ends_at: string; +} + +/** One eligible rule in the buyer's currency, for checkout preview display. */ +export interface ThresholdRuleView { + id: string; + shop_id: string; + name: LocalizedText; + currency: string; + threshold_minor: number; + reduction_minor: number; + converted_threshold_minor: number; + converted_reduction_minor: number; + ends_at: string; +} + +/** Per-shop read-only threshold preview for the current cart. */ +export interface ShopThresholdPreview { + shop_id: string; + subtotal_minor: number; + best: ThresholdRuleView | null; +} + /** A customer-owned snapshot of the terms at claim time. */ export interface Coupon { id: string;