feat(api): flash sales resolved server-side at checkout
Shop-owned timed sessions carry SKU activity items with their own reserved inventory and per-customer limit. Checkout resolves eligibility on the server, locks candidate items by primary key after the SKU locks, and splits a cart line into an activity-priced item plus a standard-priced remainder, so every unit price snapshot is honest and a customer cannot exceed the limit. Reserved activity stock and SKU stock decrement together under guards, and a pending-payment cancellation restores both plus any redeemed coupon. Coupons are rejected on a shop order that applied activity pricing, and a SKU cannot join two overlapping enabled sessions. The overlap check against group buying is present but dormant: that capability lands later, so the check activates only once its table exists. Surfaces (shop-admin, mall) and seeding follow.
This commit is contained in:
@@ -0,0 +1,399 @@
|
||||
mod common;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use common::{
|
||||
add_to_cart, client, login_admin, register_customer, setup_sellable, spawn_app, TestApp,
|
||||
};
|
||||
use serial_test::serial;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn past_window() -> (String, String) {
|
||||
("2020-01-01T00:00:00Z".into(), "2020-01-02T00:00:00Z".into())
|
||||
}
|
||||
|
||||
fn active_window() -> (String, String) {
|
||||
("2020-01-01T00:00:00Z".into(), "2999-01-01T00:00:00Z".into())
|
||||
}
|
||||
|
||||
async fn create_session(app: &TestApp, owner: &str, starts: &str, ends: &str) -> String {
|
||||
let res = client()
|
||||
.post(app.url("/api/shop/flash-sales"))
|
||||
.bearer_auth(owner)
|
||||
.json(&serde_json::json!({
|
||||
"label": {"en": "Flash", "zh": "秒杀"},
|
||||
"starts_at": starts,
|
||||
"ends_at": ends,
|
||||
"enabled": true,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 201, "create session: {:?}", res.text().await);
|
||||
res.json::<serde_json::Value>().await.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn add_item(
|
||||
app: &TestApp,
|
||||
owner: &str,
|
||||
session_id: &str,
|
||||
sku_id: &str,
|
||||
sale_price: i64,
|
||||
reserved: i32,
|
||||
limit: i32,
|
||||
) -> reqwest::Response {
|
||||
client()
|
||||
.post(app.url(&format!("/api/shop/flash-sales/{session_id}/items")))
|
||||
.bearer_auth(owner)
|
||||
.json(&serde_json::json!({
|
||||
"sku_id": sku_id,
|
||||
"sale_price_minor": sale_price,
|
||||
"currency": "USD",
|
||||
"reserved_stock": reserved,
|
||||
"per_customer_limit": limit,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn checkout_with(
|
||||
app: &TestApp,
|
||||
token: &str,
|
||||
coupon_by_shop: HashMap<String, String>,
|
||||
) -> reqwest::Response {
|
||||
client()
|
||||
.post(app.url("/api/orders/checkout"))
|
||||
.bearer_auth(token)
|
||||
.json(&serde_json::json!({
|
||||
"shipping_address": {
|
||||
"recipient": "Test", "phone": "1", "country": "US",
|
||||
"region": "CA", "city": "SF", "line1": "1 Way", "postal_code": "94105"
|
||||
},
|
||||
"currency": "USD",
|
||||
"coupon_by_shop": coupon_by_shop,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn create_template(app: &TestApp, owner: &str, amount: i64, threshold: i64) -> String {
|
||||
let res = client()
|
||||
.post(app.url("/api/shop/coupon-templates"))
|
||||
.bearer_auth(owner)
|
||||
.json(&serde_json::json!({
|
||||
"title": {"en": "Coupon", "zh": "优惠券"},
|
||||
"amount_minor": amount,
|
||||
"threshold_minor": threshold,
|
||||
"currency": "USD",
|
||||
"stock": 5,
|
||||
"enabled": true,
|
||||
"starts_at": "2020-01-01T00:00:00Z",
|
||||
"ends_at": "2999-01-01T00:00:00Z",
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 201, "create template: {:?}", res.text().await);
|
||||
res.json::<serde_json::Value>().await.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn claim(app: &TestApp, token: &str, template_id: &str) -> String {
|
||||
let res = client()
|
||||
.post(app.url("/api/me/coupons"))
|
||||
.bearer_auth(token)
|
||||
.json(&serde_json::json!({ "template_id": template_id }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 201, "claim: {:?}", res.text().await);
|
||||
res.json::<serde_json::Value>().await.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn flash_item_state(app: &TestApp, item_id: &str) -> (i32, i32) {
|
||||
sqlx::query_as("SELECT reserved_stock, sold_count FROM flash_sale_items WHERE id = $1")
|
||||
.bind(Uuid::parse_str(item_id).unwrap())
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn activity_lines(app: &TestApp, order_id: &str) -> i64 {
|
||||
sqlx::query_scalar(
|
||||
"SELECT count(*) FROM order_items
|
||||
WHERE order_id = $1 AND flash_sale_item_id IS NOT NULL",
|
||||
)
|
||||
.bind(Uuid::parse_str(order_id).unwrap())
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn inactive_window_falls_back_to_normal_price() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "fs-past", 1000, 5).await;
|
||||
let (starts, ends) = past_window();
|
||||
let session = create_session(&app, &owner, &starts, &ends).await;
|
||||
let res = add_item(&app, &owner, &session, &sku, 500, 5, 3).await;
|
||||
assert_eq!(res.status(), 201, "past sessions may still be configured");
|
||||
|
||||
// Not discoverable as active.
|
||||
let active: Vec<serde_json::Value> = client()
|
||||
.get(app.url("/api/flash-sales"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(active.is_empty(), "an elapsed session is not public");
|
||||
|
||||
let (token, _) = register_customer(&app, "fs-past").await;
|
||||
add_to_cart(&app, &token, &sku, 1).await;
|
||||
let res = checkout_with(&app, &token, HashMap::new()).await;
|
||||
assert_eq!(res.status(), 201);
|
||||
let orders: Vec<serde_json::Value> = res.json().await.unwrap();
|
||||
let item = &orders[0]["items"][0];
|
||||
assert_eq!(item["unit_price_minor"], 1000, "normal price applies");
|
||||
assert!(item["flash_sale_item_id"].is_null());
|
||||
assert_eq!(activity_lines(&app, orders[0]["id"].as_str().unwrap()).await, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn cross_shop_sku_and_overlapping_sessions_are_rejected() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let (owner_a, shop_a, _product_a, sku_a) = setup_sellable(&app, &admin, "fs-a", 1000, 5).await;
|
||||
let (_owner_b, _shop_b, _product_b, sku_b) = setup_sellable(&app, &admin, "fs-b", 1000, 5).await;
|
||||
let (starts, ends) = active_window();
|
||||
|
||||
let session = create_session(&app, &owner_a, &starts, &ends).await;
|
||||
let res = add_item(&app, &owner_a, &session, &sku_b, 500, 5, 3).await;
|
||||
assert_eq!(res.status(), 400, "another shop's SKU is refused");
|
||||
|
||||
let res = add_item(&app, &owner_a, &session, &sku_a, 500, 5, 3).await;
|
||||
assert_eq!(res.status(), 201);
|
||||
|
||||
// A second overlapping session cannot list the same SKU.
|
||||
let session_two = create_session(&app, &owner_a, &starts, &ends).await;
|
||||
let res = add_item(&app, &owner_a, &session_two, &sku_a, 400, 5, 3).await;
|
||||
assert_eq!(res.status(), 409, "overlapping sale for the same SKU is refused");
|
||||
let _ = shop_a;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn overlapping_group_buying_is_rejected_once_that_capability_exists() {
|
||||
let app = spawn_app().await;
|
||||
let table_exists: bool = sqlx::query_scalar(
|
||||
"SELECT to_regclass('public.group_buying_activities') IS NOT NULL",
|
||||
)
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
.unwrap();
|
||||
if !table_exists {
|
||||
// add-group-buying lands after add-flash-sales, so this cross-capability
|
||||
// scenario can only be exercised once its table exists.
|
||||
eprintln!("skipped: group_buying_activities is not present yet");
|
||||
return;
|
||||
}
|
||||
|
||||
let admin = login_admin(&app).await;
|
||||
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "fs-gb", 1000, 5).await;
|
||||
let (starts, ends) = active_window();
|
||||
sqlx::query(
|
||||
"INSERT INTO group_buying_activities
|
||||
(shop_id, sku_id, name, group_price_minor, currency, required_members,
|
||||
starts_at, ends_at, group_lifetime_hours)
|
||||
SELECT p.shop_id, $1, '{\"en\":\"g\",\"zh\":\"团\"}', 100, 'USD', 2, $2, $3, 24
|
||||
FROM skus s JOIN products p ON p.id = s.product_id WHERE s.id = $1",
|
||||
)
|
||||
.bind(Uuid::parse_str(&sku).unwrap())
|
||||
.bind(chrono::DateTime::parse_from_rfc3339(&starts).unwrap())
|
||||
.bind(chrono::DateTime::parse_from_rfc3339(&ends).unwrap())
|
||||
.execute(&app.db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let session = create_session(&app, &owner, &starts, &ends).await;
|
||||
let res = add_item(&app, &owner, &session, &sku, 500, 5, 3).await;
|
||||
assert_eq!(res.status(), 409, "overlapping group-buy SKU is refused");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn reserved_stock_admits_one_activity_price() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "fs-race", 1000, 10).await;
|
||||
let (starts, ends) = active_window();
|
||||
let session = create_session(&app, &owner, &starts, &ends).await;
|
||||
let res = add_item(&app, &owner, &session, &sku, 400, 1, 5).await;
|
||||
assert_eq!(res.status(), 201);
|
||||
let item_id = res.json::<serde_json::Value>().await.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
let (token_a, _) = register_customer(&app, "fs-race-a").await;
|
||||
let (token_b, _) = register_customer(&app, "fs-race-b").await;
|
||||
add_to_cart(&app, &token_a, &sku, 1).await;
|
||||
add_to_cart(&app, &token_b, &sku, 1).await;
|
||||
|
||||
let (a, b) = tokio::join!(
|
||||
checkout_with(&app, &token_a, HashMap::new()),
|
||||
checkout_with(&app, &token_b, HashMap::new()),
|
||||
);
|
||||
assert!(a.status().is_success() && b.status().is_success());
|
||||
|
||||
let (reserved, sold) = flash_item_state(&app, &item_id).await;
|
||||
assert_eq!(reserved, 0, "the single reserved unit is consumed");
|
||||
assert_eq!(sold, 1);
|
||||
|
||||
let activity: i64 = sqlx::query_scalar(
|
||||
"SELECT count(*) FROM order_items WHERE flash_sale_item_id = $1",
|
||||
)
|
||||
.bind(Uuid::parse_str(&item_id).unwrap())
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(activity, 1, "exactly one line got the activity price");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn per_customer_limit_splits_the_line() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "fs-limit", 1000, 10).await;
|
||||
let (starts, ends) = active_window();
|
||||
let session = create_session(&app, &owner, &starts, &ends).await;
|
||||
let res = add_item(&app, &owner, &session, &sku, 400, 10, 1).await;
|
||||
assert_eq!(res.status(), 201);
|
||||
let item_id = res.json::<serde_json::Value>().await.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
let (token, _) = register_customer(&app, "fs-limit").await;
|
||||
add_to_cart(&app, &token, &sku, 3).await;
|
||||
let res = checkout_with(&app, &token, HashMap::new()).await;
|
||||
assert_eq!(res.status(), 201);
|
||||
let orders: Vec<serde_json::Value> = res.json().await.unwrap();
|
||||
let items = orders[0]["items"].as_array().unwrap();
|
||||
assert_eq!(items.len(), 2, "one activity line and one standard line");
|
||||
let activity: Vec<&serde_json::Value> = items
|
||||
.iter()
|
||||
.filter(|i| !i["flash_sale_item_id"].is_null())
|
||||
.collect();
|
||||
let standard: Vec<&serde_json::Value> = items
|
||||
.iter()
|
||||
.filter(|i| i["flash_sale_item_id"].is_null())
|
||||
.collect();
|
||||
assert_eq!(activity.len(), 1);
|
||||
assert_eq!(activity[0]["qty"], 1, "only the allowance gets the activity price");
|
||||
assert_eq!(activity[0]["unit_price_minor"], 400);
|
||||
assert_eq!(standard.len(), 1);
|
||||
assert_eq!(standard[0]["qty"], 2);
|
||||
assert_eq!(standard[0]["unit_price_minor"], 1000);
|
||||
assert_eq!(orders[0]["total_minor"], 400 + 2000);
|
||||
|
||||
// The allowance is spent, so a second checkout is standard-priced.
|
||||
add_to_cart(&app, &token, &sku, 1).await;
|
||||
let res = checkout_with(&app, &token, HashMap::new()).await;
|
||||
let orders: Vec<serde_json::Value> = res.json().await.unwrap();
|
||||
assert!(orders[0]["items"][0]["flash_sale_item_id"].is_null());
|
||||
assert_eq!(activity_lines(&app, orders[0]["id"].as_str().unwrap()).await, 0);
|
||||
|
||||
let (_, sold) = flash_item_state(&app, &item_id).await;
|
||||
assert_eq!(sold, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn coupon_is_rejected_on_a_flash_priced_shop_order() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let (owner, shop, _product, sku) = setup_sellable(&app, &admin, "fs-cp", 1000, 5).await;
|
||||
let (starts, ends) = active_window();
|
||||
let session = create_session(&app, &owner, &starts, &ends).await;
|
||||
assert_eq!(add_item(&app, &owner, &session, &sku, 400, 5, 5).await.status(), 201);
|
||||
|
||||
let template = create_template(&app, &owner, 100, 0).await;
|
||||
let (token, _) = register_customer(&app, "fs-cp").await;
|
||||
let coupon = claim(&app, &token, &template).await;
|
||||
|
||||
add_to_cart(&app, &token, &sku, 1).await;
|
||||
let res = checkout_with(&app, &token, HashMap::from([(shop, coupon)])).await;
|
||||
assert_eq!(res.status(), 409, "activity pricing and coupons are exclusive");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn cancel_restores_reserved_stock_and_coupon() {
|
||||
let app = spawn_app().await;
|
||||
let admin = login_admin(&app).await;
|
||||
let (flash_owner, _flash_shop, _p1, flash_sku) =
|
||||
setup_sellable(&app, &admin, "fs-cancel-a", 1000, 5).await;
|
||||
let (owner_b, shop_b, _p2, sku_b) = setup_sellable(&app, &admin, "fs-cancel-b", 2000, 5).await;
|
||||
let (starts, ends) = active_window();
|
||||
let session = create_session(&app, &flash_owner, &starts, &ends).await;
|
||||
let res = add_item(&app, &flash_owner, &session, &flash_sku, 400, 3, 5).await;
|
||||
assert_eq!(res.status(), 201);
|
||||
let item_id = res.json::<serde_json::Value>().await.unwrap()["id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
let template = create_template(&app, &owner_b, 100, 0).await;
|
||||
let (token, _) = register_customer(&app, "fs-cancel").await;
|
||||
let coupon = claim(&app, &token, &template).await;
|
||||
|
||||
add_to_cart(&app, &token, &flash_sku, 2).await;
|
||||
add_to_cart(&app, &token, &sku_b, 1).await;
|
||||
let res = checkout_with(&app, &token, HashMap::from([(shop_b, coupon.clone())])).await;
|
||||
assert_eq!(res.status(), 201, "checkout: {:?}", res.text().await);
|
||||
let orders: Vec<serde_json::Value> = res.json().await.unwrap();
|
||||
assert_eq!(orders.len(), 2);
|
||||
|
||||
let (reserved_after, sold_after) = flash_item_state(&app, &item_id).await;
|
||||
assert_eq!(reserved_after, 1, "2 of 3 reserved units consumed");
|
||||
assert_eq!(sold_after, 2);
|
||||
|
||||
for order in &orders {
|
||||
let res = client()
|
||||
.post(app.url(&format!("/api/orders/{}/cancel", order["id"].as_str().unwrap())))
|
||||
.bearer_auth(&token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 200);
|
||||
}
|
||||
|
||||
let (reserved, sold) = flash_item_state(&app, &item_id).await;
|
||||
assert_eq!(reserved, 3, "reserved activity stock is restored");
|
||||
assert_eq!(sold, 0);
|
||||
|
||||
let (status, order_ref): (String, Option<Uuid>) =
|
||||
sqlx::query_as("SELECT status::text, order_id FROM coupons WHERE id = $1")
|
||||
.bind(Uuid::parse_str(&coupon).unwrap())
|
||||
.fetch_one(&app.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(status, "claimed");
|
||||
assert!(order_ref.is_none());
|
||||
}
|
||||
Reference in New Issue
Block a user