feat(api): group buying with payment-time seat claims
Shop-owned activities on one SKU, concrete groups with an open/successful/expired/cancelled lifecycle, and one paid membership row per paid order. Checkout accepts a single-SKU quantity-1 intent, snapshots the group price and identity on the pending order, and opens or references a group; a paid seat is claimed only at payment, which locks the group and fills it exactly at capacity. Pending-payment cancellation restores SKU stock only and never rolls back paid seats; an unpaid opener cancelling closes a still-empty group. Coupons are refused on a group shop order, and the flash-sale exclusion is now enforced in both directions because the activity table exists, which activates the guard add-flash-sales shipped dormant. The activity column names follow the contract recorded in this change's design. Surfaces (shop-admin, mall) and seeding follow.
This commit is contained in:
@@ -201,38 +201,33 @@ async fn cross_shop_sku_and_overlapping_sessions_are_rejected() {
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn overlapping_group_buying_is_rejected_once_that_capability_exists() {
|
||||
async fn overlapping_group_buying_is_rejected() {
|
||||
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();
|
||||
|
||||
// The group-buying activity is configured first.
|
||||
let res = client()
|
||||
.post(app.url("/api/shop/group-buying-activities"))
|
||||
.bearer_auth(&owner)
|
||||
.json(&serde_json::json!({
|
||||
"sku_id": sku,
|
||||
"name": {"en": "Group deal", "zh": "拼团"},
|
||||
"group_price_minor": 700,
|
||||
"currency": "USD",
|
||||
"required_members": 2,
|
||||
"starts_at": starts,
|
||||
"ends_at": ends,
|
||||
"group_lifetime_hours": 24,
|
||||
"enabled": true,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), 201, "group activity: {:?}", res.text().await);
|
||||
|
||||
// This guard was dormant until the group-buying table existed; it is live now.
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user