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:
2026-09-18 13:16:28 +00:00
parent 705cbe249a
commit a7bc476251
15 changed files with 1563 additions and 48 deletions
+22 -27
View File
@@ -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");