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
+68
View File
@@ -193,6 +193,10 @@ pub struct Order {
pub discount_minor: i64,
/// The coupon this order redeemed, if any.
pub coupon_id: Option<Uuid>,
/// Group-buying activity this order joined, if any.
pub group_activity_id: Option<Uuid>,
/// Concrete group this order belongs to, if any.
pub group_id: Option<Uuid>,
pub shipping_address: serde_json::Value,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@@ -438,6 +442,70 @@ pub struct FlashSaleItem {
pub const FLASH_SALE_ITEM_COLUMNS: &str = "id, session_id, sku_id, sale_price_minor, currency, \
reserved_stock, sold_count, per_customer_limit, created_at, updated_at";
/// Lifecycle of a concrete group instance.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
#[sqlx(type_name = "collective_group_status", rename_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum CollectiveGroupStatus {
Open,
Successful,
Expired,
Cancelled,
}
/// Shop-owned timed activity on one SKU. Checkout accepts it at quantity 1 only.
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct GroupBuyingActivity {
pub id: Uuid,
pub shop_id: Uuid,
pub sku_id: Uuid,
pub name: serde_json::Value,
pub description: Option<serde_json::Value>,
pub image: Option<String>,
pub group_price_minor: i64,
pub currency: String,
pub required_members: i32,
pub starts_at: DateTime<Utc>,
pub ends_at: DateTime<Utc>,
pub group_lifetime_hours: i32,
pub enabled: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub const GROUP_BUYING_ACTIVITY_COLUMNS: &str = "id, shop_id, sku_id, name, description, image, \
group_price_minor, currency, required_members, starts_at, ends_at, group_lifetime_hours, \
enabled, created_at, updated_at";
/// A concrete group instance.
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct CollectiveGroup {
pub id: Uuid,
pub activity_id: Uuid,
pub leader_order_id: Option<Uuid>,
pub paid_member_count: i32,
pub status: CollectiveGroupStatus,
pub expires_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
pub const COLLECTIVE_GROUP_COLUMNS: &str = "id, activity_id, leader_order_id, \
paid_member_count, status, expires_at, created_at, updated_at";
/// One paid seat: a membership row per paid order and group.
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct CollectiveGroupMember {
pub id: Uuid,
pub group_id: Uuid,
pub order_id: Uuid,
pub user_id: Uuid,
pub joined_at: DateTime<Utc>,
}
pub const COLLECTIVE_GROUP_MEMBER_COLUMNS: &str =
"id, group_id, order_id, user_id, joined_at";
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct AddressBookEntry {
pub id: Uuid,