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
+66
View File
@@ -0,0 +1,66 @@
-- Group buying: a shop-owned timed activity on one SKU, concrete group
-- instances with a lifecycle, and one paid membership row per paid order.
--
-- The activity column names are a contract: the archived flash-sales guard
-- queries `group_buying_activities` by `sku_id`, `enabled`, `starts_at`, and
-- `ends_at` to reject a SKU that is in both activities at once.
CREATE TYPE collective_group_status AS ENUM ('open', 'successful', 'expired', 'cancelled');
CREATE TABLE group_buying_activities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
shop_id UUID NOT NULL REFERENCES shops (id) ON DELETE CASCADE,
sku_id UUID NOT NULL REFERENCES skus (id) ON DELETE CASCADE,
name JSONB NOT NULL,
description JSONB,
image TEXT,
group_price_minor BIGINT NOT NULL CHECK (group_price_minor > 0),
currency CHAR(3) NOT NULL REFERENCES currencies (code),
required_members INT NOT NULL CHECK (required_members >= 2),
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ NOT NULL,
group_lifetime_hours INT NOT NULL CHECK (group_lifetime_hours > 0),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT group_buying_activities_window CHECK (ends_at >= starts_at)
);
CREATE INDEX group_buying_activities_shop_idx ON group_buying_activities (shop_id);
CREATE INDEX group_buying_activities_sku_idx ON group_buying_activities (sku_id);
CREATE TABLE collective_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
activity_id UUID NOT NULL REFERENCES group_buying_activities (id) ON DELETE CASCADE,
-- The pending order that opened the group; it may be cancelled later.
leader_order_id UUID REFERENCES orders (id) ON DELETE SET NULL,
paid_member_count INT NOT NULL DEFAULT 0 CHECK (paid_member_count >= 0),
status collective_group_status NOT NULL DEFAULT 'open',
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX collective_groups_activity_idx ON collective_groups (activity_id, status);
CREATE TABLE collective_group_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES collective_groups (id) ON DELETE CASCADE,
order_id UUID NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- One paid seat per order, and one membership per customer per group.
CREATE UNIQUE INDEX collective_group_members_order_idx ON collective_group_members (order_id);
CREATE UNIQUE INDEX collective_group_members_user_idx
ON collective_group_members (group_id, user_id);
CREATE INDEX collective_group_members_group_idx ON collective_group_members (group_id);
-- Group identity snapshotted on the order at checkout.
ALTER TABLE orders
ADD COLUMN group_activity_id UUID REFERENCES group_buying_activities (id) ON DELETE SET NULL;
ALTER TABLE orders
ADD COLUMN group_id UUID REFERENCES collective_groups (id) ON DELETE SET NULL;
CREATE INDEX idx_orders_group ON orders (group_id);
+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,
+82
View File
@@ -0,0 +1,82 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Deserialize)]
pub struct ActivityInput {
pub sku_id: Uuid,
pub name: serde_json::Value,
#[serde(default)]
pub description: Option<serde_json::Value>,
#[serde(default)]
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,
#[serde(default = "default_enabled")]
pub enabled: bool,
}
fn default_enabled() -> bool {
true
}
/// Checkout intent: one activity SKU at quantity 1, either joining an open
/// group or asking to open a new one.
#[derive(Deserialize, Clone, Copy)]
pub struct GroupBuyIntent {
pub activity_id: Uuid,
pub sku_id: Uuid,
#[serde(default)]
pub group_id: Option<Uuid>,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct OpenGroupView {
pub id: Uuid,
pub paid_member_count: i32,
pub required_members: i32,
pub expires_at: DateTime<Utc>,
}
/// Activity plus the SKU/catalog details both surfaces need, with its open
/// groups for discovery.
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct ActivityView {
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 sku_code: String,
pub product_id: Uuid,
pub product_slug: String,
pub product_name: serde_json::Value,
pub original_price_minor: i64,
pub original_currency: String,
#[sqlx(skip)]
pub open_groups: Vec<OpenGroupView>,
}
/// Outcome of validating a checkout intent.
pub struct ResolvedIntent {
pub activity_id: Uuid,
pub group_id: Uuid,
pub shop_id: Uuid,
pub unit_price_minor: i64,
/// True when checkout opened the group rather than joining one.
pub opened: bool,
}
@@ -0,0 +1,73 @@
use axum::{
extract::{Path, State},
http::StatusCode,
routing::{get, put},
Json, Router,
};
use uuid::Uuid;
use crate::auth::AuthUser;
use crate::error::ApiResult;
use crate::models::GroupBuyingActivity;
use crate::state::AppState;
use super::dto::{ActivityInput, ActivityView};
use super::service;
pub fn router() -> Router<AppState> {
Router::new()
// Public: active activities with their open groups.
.route("/group-buying/activities", get(public_active))
.route(
"/shop/group-buying-activities",
get(shop_list).post(shop_create),
)
.route(
"/shop/group-buying-activities/{id}",
put(shop_update).delete(shop_delete),
)
}
async fn public_active(State(state): State<AppState>) -> ApiResult<Json<Vec<ActivityView>>> {
Ok(Json(service::public_active(&state).await?))
}
async fn shop_list(
State(state): State<AppState>,
auth: AuthUser,
) -> ApiResult<Json<Vec<ActivityView>>> {
let shop_id = auth.require_shop()?;
Ok(Json(service::list_for_shop(&state, shop_id).await?))
}
async fn shop_create(
State(state): State<AppState>,
auth: AuthUser,
Json(body): Json<ActivityInput>,
) -> ApiResult<(StatusCode, Json<GroupBuyingActivity>)> {
let shop_id = auth.require_shop()?;
Ok((
StatusCode::CREATED,
Json(service::create(&state, shop_id, body).await?),
))
}
async fn shop_update(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
Json(body): Json<ActivityInput>,
) -> ApiResult<Json<GroupBuyingActivity>> {
let shop_id = auth.require_shop()?;
Ok(Json(service::update(&state, shop_id, id, body).await?))
}
async fn shop_delete(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> ApiResult<StatusCode> {
let shop_id = auth.require_shop()?;
service::delete(&state, shop_id, id).await?;
Ok(StatusCode::NO_CONTENT)
}
+14
View File
@@ -0,0 +1,14 @@
mod dto;
mod handlers;
mod repo;
pub mod service;
use axum::Router;
use crate::state::AppState;
pub use dto::{GroupBuyIntent, ResolvedIntent};
pub fn router() -> Router<AppState> {
handlers::router()
}
+363
View File
@@ -0,0 +1,363 @@
use sqlx::{PgConnection, PgExecutor};
use uuid::Uuid;
use crate::error::{ApiError, ApiResult};
use crate::models::{
CollectiveGroup, GroupBuyingActivity, COLLECTIVE_GROUP_COLUMNS,
};
use super::dto::{ActivityInput, ActivityView, OpenGroupView};
/// Activity joined with the SKU and catalog price it discounts.
const ACTIVITY_VIEW_SELECT: &str = "SELECT a.id, a.shop_id, a.sku_id, a.name, a.description,
COALESCE(a.image, (p.images ->> 0)) AS image,
a.group_price_minor, a.currency, a.required_members, a.starts_at, a.ends_at,
a.group_lifetime_hours, a.enabled, a.created_at, a.updated_at,
s.sku_code, s.product_id, p.slug AS product_slug, p.name AS product_name,
s.price_minor AS original_price_minor, s.currency AS original_currency
FROM group_buying_activities a
JOIN skus s ON s.id = a.sku_id
JOIN products p ON p.id = s.product_id";
// ---- shop management ----
pub async fn currency_enabled<'e, E: PgExecutor<'e>>(exec: E, code: &str) -> ApiResult<bool> {
Ok(sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM currencies WHERE code = $1 AND enabled = TRUE)",
)
.bind(code)
.fetch_one(exec)
.await?)
}
pub async fn sku_is_own_active<'e, E: PgExecutor<'e>>(
exec: E,
shop_id: Uuid,
sku_id: Uuid,
) -> ApiResult<bool> {
Ok(sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM skus s
JOIN products p ON p.id = s.product_id
WHERE s.id = $1 AND p.shop_id = $2
AND s.active = TRUE AND p.status = 'published'
)",
)
.bind(sku_id)
.bind(shop_id)
.fetch_one(exec)
.await?)
}
pub async fn list_views_for_shop<'e, E: PgExecutor<'e>>(
exec: E,
shop_id: Uuid,
) -> ApiResult<Vec<ActivityView>> {
Ok(sqlx::query_as::<_, ActivityView>(&format!(
"{ACTIVITY_VIEW_SELECT} WHERE a.shop_id = $1 ORDER BY a.created_at DESC"
))
.bind(shop_id)
.fetch_all(exec)
.await?)
}
pub async fn list_active_views<'e, E: PgExecutor<'e>>(exec: E) -> ApiResult<Vec<ActivityView>> {
Ok(sqlx::query_as::<_, ActivityView>(&format!(
"{ACTIVITY_VIEW_SELECT}
WHERE a.enabled = TRUE AND now() BETWEEN a.starts_at AND a.ends_at
ORDER BY a.ends_at"
))
.fetch_all(exec)
.await?)
}
pub async fn get_own<'e, E: PgExecutor<'e>>(
exec: E,
shop_id: Uuid,
id: Uuid,
) -> ApiResult<GroupBuyingActivity> {
sqlx::query_as::<_, GroupBuyingActivity>(
"SELECT 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
FROM group_buying_activities WHERE id = $1 AND shop_id = $2",
)
.bind(id)
.bind(shop_id)
.fetch_optional(exec)
.await?
.ok_or_else(|| ApiError::NotFound("group-buying activity".into()))
}
pub async fn get_by_id<'e, E: PgExecutor<'e>>(
exec: E,
id: Uuid,
) -> ApiResult<GroupBuyingActivity> {
sqlx::query_as::<_, GroupBuyingActivity>(
"SELECT 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
FROM group_buying_activities WHERE id = $1",
)
.bind(id)
.fetch_optional(exec)
.await?
.ok_or_else(|| ApiError::NotFound("group-buying activity".into()))
}
pub async fn insert(
tx: &mut PgConnection,
shop_id: Uuid,
body: &ActivityInput,
) -> ApiResult<GroupBuyingActivity> {
Ok(sqlx::query_as::<_, GroupBuyingActivity>(
"INSERT INTO group_buying_activities
(shop_id, sku_id, name, description, image, group_price_minor, currency,
required_members, starts_at, ends_at, group_lifetime_hours, enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING 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",
)
.bind(shop_id)
.bind(body.sku_id)
.bind(&body.name)
.bind(&body.description)
.bind(&body.image)
.bind(body.group_price_minor)
.bind(body.currency.to_uppercase())
.bind(body.required_members)
.bind(body.starts_at)
.bind(body.ends_at)
.bind(body.group_lifetime_hours)
.bind(body.enabled)
.fetch_one(&mut *tx)
.await?)
}
pub async fn update(
tx: &mut PgConnection,
shop_id: Uuid,
id: Uuid,
body: &ActivityInput,
) -> ApiResult<GroupBuyingActivity> {
sqlx::query_as::<_, GroupBuyingActivity>(
"UPDATE group_buying_activities
SET sku_id = $3, name = $4, description = $5, image = $6, group_price_minor = $7,
currency = $8, required_members = $9, starts_at = $10, ends_at = $11,
group_lifetime_hours = $12, enabled = $13, updated_at = now()
WHERE id = $1 AND shop_id = $2
RETURNING 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",
)
.bind(id)
.bind(shop_id)
.bind(body.sku_id)
.bind(&body.name)
.bind(&body.description)
.bind(&body.image)
.bind(body.group_price_minor)
.bind(body.currency.to_uppercase())
.bind(body.required_members)
.bind(body.starts_at)
.bind(body.ends_at)
.bind(body.group_lifetime_hours)
.bind(body.enabled)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| ApiError::NotFound("group-buying activity".into()))
}
pub async fn delete(tx: &mut PgConnection, shop_id: Uuid, id: Uuid) -> ApiResult<()> {
let result =
sqlx::query("DELETE FROM group_buying_activities WHERE id = $1 AND shop_id = $2")
.bind(id)
.bind(shop_id)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::NotFound("group-buying activity".into()));
}
Ok(())
}
/// An enabled flash-sale item whose window overlaps this activity.
pub async fn overlapping_flash_exists<'e, E: PgExecutor<'e>>(
exec: E,
sku_id: Uuid,
starts_at: chrono::DateTime<chrono::Utc>,
ends_at: chrono::DateTime<chrono::Utc>,
) -> ApiResult<bool> {
Ok(sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM flash_sale_items i
JOIN flash_sale_sessions s ON s.id = i.session_id
WHERE i.sku_id = $1 AND s.enabled = TRUE
AND s.starts_at <= $3 AND s.ends_at >= $2
)",
)
.bind(sku_id)
.bind(starts_at)
.bind(ends_at)
.fetch_one(exec)
.await?)
}
// ---- groups ----
/// Record expiry lazily; no scheduler is involved.
pub async fn expire_due_groups<'e, E: PgExecutor<'e>>(exec: E) -> ApiResult<u64> {
let result = sqlx::query(
"UPDATE collective_groups SET status = 'expired', updated_at = now()
WHERE status = 'open' AND expires_at < now()",
)
.execute(exec)
.await?;
Ok(result.rows_affected())
}
pub async fn open_groups_for_activities(
tx: &mut PgConnection,
activity_ids: &[Uuid],
) -> ApiResult<Vec<CollectiveGroup>> {
Ok(sqlx::query_as::<_, CollectiveGroup>(&format!(
"SELECT {COLLECTIVE_GROUP_COLUMNS} FROM collective_groups
WHERE activity_id = ANY($1) AND status = 'open' AND expires_at >= now()
ORDER BY expires_at"
))
.bind(activity_ids)
.fetch_all(&mut *tx)
.await?)
}
/// Joining only reads the group: a seat is claimed at payment, not checkout.
pub async fn get_group<'e, E: PgExecutor<'e>>(
exec: E,
id: Uuid,
) -> ApiResult<CollectiveGroup> {
sqlx::query_as::<_, CollectiveGroup>(&format!(
"SELECT {COLLECTIVE_GROUP_COLUMNS} FROM collective_groups WHERE id = $1"
))
.bind(id)
.fetch_optional(exec)
.await?
.ok_or_else(|| ApiError::NotFound("group".into()))
}
pub async fn insert_group(
tx: &mut PgConnection,
activity_id: Uuid,
expires_at: chrono::DateTime<chrono::Utc>,
) -> ApiResult<CollectiveGroup> {
Ok(sqlx::query_as::<_, CollectiveGroup>(&format!(
"INSERT INTO collective_groups (activity_id, expires_at) VALUES ($1, $2)
RETURNING {COLLECTIVE_GROUP_COLUMNS}"
))
.bind(activity_id)
.bind(expires_at)
.fetch_one(&mut *tx)
.await?)
}
/// Record which pending order opened the group.
pub async fn set_leader(tx: &mut PgConnection, group_id: Uuid, order_id: Uuid) -> ApiResult<()> {
sqlx::query("UPDATE collective_groups SET leader_order_id = $2, updated_at = now() WHERE id = $1")
.bind(group_id)
.bind(order_id)
.execute(&mut *tx)
.await?;
Ok(())
}
/// Lock the group row so two payments cannot claim the same final seat.
pub async fn lock_group(tx: &mut PgConnection, id: Uuid) -> ApiResult<CollectiveGroup> {
sqlx::query_as::<_, CollectiveGroup>(&format!(
"SELECT {COLLECTIVE_GROUP_COLUMNS} FROM collective_groups WHERE id = $1 FOR UPDATE"
))
.bind(id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| ApiError::NotFound("group".into()))
}
pub async fn insert_member(
tx: &mut PgConnection,
group_id: Uuid,
order_id: Uuid,
user_id: Uuid,
) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO collective_group_members (group_id, order_id, user_id)
VALUES ($1, $2, $3)",
)
.bind(group_id)
.bind(order_id)
.bind(user_id)
.execute(&mut *tx)
.await?;
Ok(())
}
/// One paid seat: increment and become successful exactly at capacity.
pub async fn claim_seat(tx: &mut PgConnection, group_id: Uuid, required: i32) -> ApiResult<CollectiveGroup> {
Ok(sqlx::query_as::<_, CollectiveGroup>(&format!(
"UPDATE collective_groups
SET paid_member_count = paid_member_count + 1,
status = CASE WHEN paid_member_count + 1 >= $2
THEN 'successful'::collective_group_status
ELSE status END,
updated_at = now()
WHERE id = $1
RETURNING {COLLECTIVE_GROUP_COLUMNS}"
))
.bind(group_id)
.bind(required)
.fetch_one(&mut *tx)
.await?)
}
/// An unpaid opener cancelling closes a still-empty group.
pub async fn cancel_empty_opener_group(
tx: &mut PgConnection,
group_id: Uuid,
order_id: Uuid,
) -> ApiResult<u64> {
let result = sqlx::query(
"UPDATE collective_groups SET status = 'cancelled', updated_at = now()
WHERE id = $1 AND leader_order_id = $2 AND paid_member_count = 0
AND status = 'open'",
)
.bind(group_id)
.bind(order_id)
.execute(&mut *tx)
.await?;
Ok(result.rows_affected())
}
/// Attach open groups (with the activity's required count) to each view.
pub fn attach_open_groups(
views: &mut [ActivityView],
groups: Vec<CollectiveGroup>,
required_by_activity: &std::collections::HashMap<Uuid, i32>,
) {
let mut by_activity: std::collections::HashMap<Uuid, Vec<OpenGroupView>> =
std::collections::HashMap::new();
for group in groups {
by_activity
.entry(group.activity_id)
.or_default()
.push(OpenGroupView {
id: group.id,
paid_member_count: group.paid_member_count,
required_members: required_by_activity
.get(&group.activity_id)
.copied()
.unwrap_or(0),
expires_at: group.expires_at,
});
}
for view in views.iter_mut() {
view.open_groups = by_activity.remove(&view.id).unwrap_or_default();
}
}
@@ -0,0 +1,266 @@
use std::collections::HashMap;
use chrono::{Duration, Utc};
use serde_json::Value;
use sqlx::PgConnection;
use uuid::Uuid;
use crate::error::{unique_conflict, ApiError, ApiResult};
use crate::models::{CollectiveGroup, CollectiveGroupStatus, Currency, GroupBuyingActivity};
use crate::money::convert_minor;
use crate::state::AppState;
use super::dto::{ActivityInput, ActivityView, GroupBuyIntent, ResolvedIntent};
use super::repo;
// ---- discovery ----
pub async fn list_for_shop(state: &AppState, shop_id: Uuid) -> ApiResult<Vec<ActivityView>> {
let mut tx = state.db.begin().await?;
repo::expire_due_groups(&mut *tx).await?;
let mut views = repo::list_views_for_shop(&mut *tx, shop_id).await?;
attach_open_groups(&mut tx, &mut views).await?;
tx.commit().await?;
Ok(views)
}
pub async fn public_active(state: &AppState) -> ApiResult<Vec<ActivityView>> {
let mut tx = state.db.begin().await?;
repo::expire_due_groups(&mut *tx).await?;
let mut views = repo::list_active_views(&mut *tx).await?;
attach_open_groups(&mut tx, &mut views).await?;
tx.commit().await?;
Ok(views)
}
async fn attach_open_groups(tx: &mut PgConnection, views: &mut [ActivityView]) -> ApiResult<()> {
let ids: Vec<Uuid> = views.iter().map(|view| view.id).collect();
if ids.is_empty() {
return Ok(());
}
let groups = repo::open_groups_for_activities(tx, &ids).await?;
let required: HashMap<Uuid, i32> = views
.iter()
.map(|view| (view.id, view.required_members))
.collect();
repo::attach_open_groups(views, groups, &required);
Ok(())
}
// ---- shop management ----
pub async fn create(
state: &AppState,
shop_id: Uuid,
body: ActivityInput,
) -> ApiResult<GroupBuyingActivity> {
validate(&body)?;
let mut tx = state.db.begin().await?;
ensure_currency(&mut tx, &body.currency).await?;
ensure_activity_slot(&mut tx, shop_id, &body).await?;
let activity = repo::insert(&mut tx, shop_id, &body).await?;
tx.commit().await?;
Ok(activity)
}
pub async fn update(
state: &AppState,
shop_id: Uuid,
id: Uuid,
body: ActivityInput,
) -> ApiResult<GroupBuyingActivity> {
validate(&body)?;
let mut tx = state.db.begin().await?;
repo::get_own(&mut *tx, shop_id, id).await?;
ensure_currency(&mut tx, &body.currency).await?;
ensure_activity_slot(&mut tx, shop_id, &body).await?;
let activity = repo::update(&mut tx, shop_id, id, &body).await?;
tx.commit().await?;
Ok(activity)
}
pub async fn delete(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult<()> {
let mut tx = state.db.begin().await?;
repo::delete(&mut tx, shop_id, id).await?;
tx.commit().await?;
Ok(())
}
// ---- checkout composition ----
/// Validate a single-SKU quantity-1 intent and resolve its group identity and
/// price. Opening allocates an empty group; joining only reads an open one,
/// because a seat is claimed at payment.
pub async fn resolve_intent(
tx: &mut PgConnection,
shop_id: Uuid,
intent: &GroupBuyIntent,
target: &Currency,
currencies: &[Currency],
) -> ApiResult<ResolvedIntent> {
let now = Utc::now();
let activity = repo::get_by_id(&mut *tx, intent.activity_id).await?;
if !activity.enabled || now < activity.starts_at || now > activity.ends_at {
return Err(ApiError::Conflict(
"group-buying activity is not active".into(),
));
}
if activity.sku_id != intent.sku_id {
return Err(ApiError::BadRequest(
"group-buying intent does not match the activity SKU".into(),
));
}
if activity.shop_id != shop_id {
return Err(ApiError::BadRequest(
"group-buying activity does not belong to that shop".into(),
));
}
let (group_id, opened) = match intent.group_id {
Some(group_id) => {
repo::expire_due_groups(&mut *tx).await?;
let group = repo::get_group(&mut *tx, group_id).await?;
if group.activity_id != activity.id {
return Err(ApiError::BadRequest(
"group belongs to another activity".into(),
));
}
if group.status != CollectiveGroupStatus::Open || group.expires_at < now {
return Err(ApiError::Conflict("group is no longer joinable".into()));
}
(group_id, false)
}
None => {
let expires_at = now + Duration::hours(activity.group_lifetime_hours as i64);
let group = repo::insert_group(tx, activity.id, expires_at).await?;
(group.id, true)
}
};
let from = currencies
.iter()
.find(|c| c.code == activity.currency)
.ok_or_else(|| {
ApiError::BadRequest(format!("currency {} is disabled", activity.currency))
})?;
let unit_price_minor = convert_minor(activity.group_price_minor, from, target)?;
Ok(ResolvedIntent {
activity_id: activity.id,
group_id,
shop_id,
unit_price_minor,
opened,
})
}
/// Claim a paid seat while paying for a group order. Expires due groups first,
/// then locks the group so two payments cannot take the same final seat.
pub async fn claim_seat(
tx: &mut PgConnection,
user_id: Uuid,
order_id: Uuid,
group_id: Uuid,
) -> ApiResult<CollectiveGroup> {
repo::expire_due_groups(&mut *tx).await?;
let group = repo::lock_group(tx, group_id).await?;
if group.status != CollectiveGroupStatus::Open {
return Err(ApiError::Conflict("group is not open".into()));
}
let activity = repo::get_by_id(&mut *tx, group.activity_id).await?;
if group.paid_member_count >= activity.required_members {
return Err(ApiError::Conflict("group is full".into()));
}
repo::insert_member(tx, group.id, order_id, user_id)
.await
.map_err(|e| unique_conflict(e, "you already joined this group"))?;
repo::claim_seat(tx, group.id, activity.required_members).await
}
/// Record which pending order opened the group.
pub async fn link_opener(tx: &mut PgConnection, group_id: Uuid, order_id: Uuid) -> ApiResult<()> {
repo::set_leader(tx, group_id, order_id).await
}
/// An unpaid opener cancelling closes a group that has no paid members.
pub async fn cancel_opener_group(
tx: &mut PgConnection,
order_id: Uuid,
group_id: Uuid,
) -> ApiResult<()> {
repo::cancel_empty_opener_group(tx, group_id, order_id).await?;
Ok(())
}
// ---- validation ----
async fn ensure_activity_slot(
tx: &mut PgConnection,
shop_id: Uuid,
body: &ActivityInput,
) -> ApiResult<()> {
if !repo::sku_is_own_active(&mut *tx, shop_id, body.sku_id).await? {
return Err(ApiError::BadRequest(
"SKU is not an active published product of this shop".into(),
));
}
// Mutual exclusion with flash sales: this is the live direction, the flash
// side activates now that this table exists.
if repo::overlapping_flash_exists(&mut *tx, body.sku_id, body.starts_at, body.ends_at).await? {
return Err(ApiError::Conflict(
"SKU already has an overlapping flash sale".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 validate(body: &ActivityInput) -> ApiResult<()> {
bilingual(&body.name, "name")?;
if let Some(description) = &body.description {
bilingual(description, "description")?;
}
if body.group_price_minor <= 0 {
return Err(ApiError::BadRequest(
"group_price_minor must be positive".into(),
));
}
if body.required_members < 2 {
return Err(ApiError::BadRequest(
"required_members must be at least 2".into(),
));
}
if body.group_lifetime_hours <= 0 {
return Err(ApiError::BadRequest(
"group_lifetime_hours must be positive".into(),
));
}
if body.ends_at < body.starts_at {
return Err(ApiError::BadRequest(
"ends_at must not precede starts_at".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(())
}
+2
View File
@@ -8,6 +8,7 @@ pub mod coupon;
pub mod currency;
pub mod flash_sale;
pub mod fulfillment;
pub mod group_buying;
pub mod health;
pub mod identity;
pub mod order;
@@ -30,6 +31,7 @@ pub fn api_router() -> Router<AppState> {
.merge(cart::router())
.merge(coupon::router())
.merge(flash_sale::router())
.merge(group_buying::router())
.merge(order::router())
.merge(points::router())
.merge(shop::router())
+4
View File
@@ -61,6 +61,9 @@ struct CheckoutBody {
/// choice, never a discount amount.
#[serde(default)]
coupon_by_shop: std::collections::HashMap<Uuid, Uuid>,
/// Optional group-buying intent for one activity SKU at quantity 1.
#[serde(default)]
group_buy: Option<crate::modules::group_buying::GroupBuyIntent>,
}
async fn checkout(
@@ -77,6 +80,7 @@ async fn checkout(
body.shipping_address,
body.currency,
body.coupon_by_shop,
body.group_buy,
)
.await?,
),
+27 -9
View File
@@ -9,7 +9,8 @@ use crate::models::{Order, OrderItem, OrderStatus};
use super::dto::{OrderScope, OrderView};
const ORDER_COLS: &str = "id, order_no, shop_id, user_id, status, currency, total_minor,
discount_minor, coupon_id, shipping_address, created_at, updated_at";
discount_minor, coupon_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";
@@ -134,6 +135,7 @@ pub async fn lock_for_shop(
.ok_or_else(|| ApiError::NotFound("order".into()))
}
#[allow(clippy::too_many_arguments)]
pub async fn insert_order(
tx: &mut PgConnection,
shop_id: Uuid,
@@ -142,13 +144,15 @@ pub async fn insert_order(
total: i64,
discount_minor: i64,
coupon_id: Option<Uuid>,
group_activity_id: Option<Uuid>,
group_id: Option<Uuid>,
address: &serde_json::Value,
) -> ApiResult<Order> {
Ok(sqlx::query_as::<_, Order>(&format!(
"INSERT INTO orders (order_no, shop_id, user_id, currency, total_minor, discount_minor,
coupon_id, shipping_address)
coupon_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)
$1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING {ORDER_COLS}"
))
.bind(shop_id)
@@ -157,6 +161,8 @@ pub async fn insert_order(
.bind(total)
.bind(discount_minor)
.bind(coupon_id)
.bind(group_activity_id)
.bind(group_id)
.bind(address)
.fetch_one(&mut *tx)
.await?)
@@ -205,18 +211,30 @@ pub async fn decrement_stock(tx: &mut PgConnection, sku_id: Uuid, qty: i32) -> A
Ok(())
}
pub async fn pay(db: &PgPool, user_id: Uuid, id: Uuid) -> ApiResult<Order> {
/// Lock one customer's order for a status transition.
pub async fn lock_for_user(tx: &mut PgConnection, user_id: Uuid, id: Uuid) -> ApiResult<Order> {
sqlx::query_as::<_, Order>(&format!(
"UPDATE orders SET status = 'paid', updated_at = now()
WHERE id = $1 AND user_id = $2 AND status = 'pending_payment'
RETURNING {ORDER_COLS}"
"SELECT {ORDER_COLS} FROM orders WHERE id = $1 AND user_id = $2 FOR UPDATE"
))
.bind(id)
.bind(user_id)
.fetch_optional(db)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| ApiError::NotFound("order".into()))
}
/// Transition pending_payment → paid, validating the precondition.
pub async fn mark_paid(tx: &mut PgConnection, id: Uuid) -> ApiResult<Order> {
sqlx::query_as::<_, Order>(&format!(
"UPDATE orders SET status = 'paid', updated_at = now()
WHERE id = $1 AND status = 'pending_payment'
RETURNING {ORDER_COLS}"
))
.bind(id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| {
ApiError::Conflict("order not payable (missing, not yours, or wrong status)".into())
ApiError::Conflict("order not payable (missing or wrong status)".into())
})
}
+69 -6
View File
@@ -6,7 +6,8 @@ use crate::error::{ApiError, ApiResult};
use crate::http::{clamp_page, clamp_per_page, Paged};
use crate::models::OrderStatus;
use crate::money::convert_minor;
use crate::modules::{cart, coupon, flash_sale};
use crate::modules::group_buying::GroupBuyIntent;
use crate::modules::{cart, coupon, flash_sale, group_buying};
use crate::state::AppState;
use super::dto::{AddressBody, OrderScope, OrderView};
@@ -79,6 +80,7 @@ pub async fn checkout(
shipping_address: AddressBody,
currency: String,
coupon_by_shop: HashMap<Uuid, Uuid>,
group_buy: Option<GroupBuyIntent>,
) -> ApiResult<Vec<OrderView>> {
shipping_address.validate()?;
let target_currency = currency.to_uppercase();
@@ -177,14 +179,51 @@ pub async fn checkout(
});
}
// Group-buying intent: exactly one activity SKU at quantity 1, priced by the
// activity. A seat is claimed at payment, not here.
let mut group_by_shop: HashMap<Uuid, group_buying::ResolvedIntent> = HashMap::new();
if let Some(intent) = &group_buy {
let index = lines
.iter()
.position(|line| line.sku_id == intent.sku_id)
.ok_or_else(|| {
ApiError::BadRequest("group-buying SKU is not in the cart".into())
})?;
let qty = lines[index].normal_qty + lines[index].activity_qty;
if qty != 1 {
return Err(ApiError::BadRequest(
"group-buying intent requires quantity 1".into(),
));
}
if lines[index].activity_qty > 0 {
return Err(ApiError::Conflict(
"SKU is also eligible for an overlapping flash sale".into(),
));
}
let shop_id = lines[index].shop_id;
let resolved = group_buying::service::resolve_intent(
&mut tx,
shop_id,
intent,
&target,
&all_currencies,
)
.await?;
lines[index].normal_unit = resolved.unit_price_minor;
lines[index].normal_qty = 1;
lines[index].activity_qty = 0;
lines[index].activity_item = None;
group_by_shop.insert(shop_id, resolved);
}
// Activity pricing and coupons are exclusive per shop order.
for shop_id in coupon_by_shop.keys() {
if lines
let flash_priced = lines
.iter()
.any(|line| line.shop_id == *shop_id && line.activity_qty > 0)
{
.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 coupon cannot be combined with flash-sale pricing on one shop order".into(),
"a coupon cannot be combined with activity pricing on one shop order".into(),
));
}
}
@@ -231,6 +270,7 @@ pub async fn checkout(
None => 0,
};
let group = group_by_shop.get(&shop_id);
let order = repo::insert_order(
&mut tx,
shop_id,
@@ -239,9 +279,17 @@ pub async fn checkout(
subtotal - discount,
discount,
selected_coupon,
group.map(|g| g.activity_id),
group.map(|g| g.group_id),
&address,
)
.await?;
if let Some(group) = group {
if group.opened {
// The pending order that opened the group can later cancel it.
group_buying::service::link_opener(&mut tx, group.group_id, order.id).await?;
}
}
if let Some(id) = selected_coupon {
coupon::service::redeem(&mut tx, id, order.id).await?;
}
@@ -289,7 +337,17 @@ pub async fn checkout(
}
pub async fn pay(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult<OrderView> {
let order = repo::pay(&state.db, user_id, id).await?;
let mut tx = state.db.begin().await?;
let order = repo::lock_for_user(&mut tx, user_id, id).await?;
if order.status != OrderStatus::PendingPayment {
return Err(ApiError::Conflict("order not payable (wrong status)".into()));
}
// A group order claims its paid seat in the same transaction as payment.
if let Some(group_id) = order.group_id {
group_buying::service::claim_seat(&mut tx, user_id, order.id, group_id).await?;
}
let order = repo::mark_paid(&mut tx, order.id).await?;
tx.commit().await?;
let mut views = repo::attach_items(&state.db, vec![order]).await?;
Ok(views.remove(0))
}
@@ -302,6 +360,11 @@ pub async fn cancel(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult<Orde
// in the same transaction.
flash_sale::service::restore_for_order(&mut tx, order.id).await?;
coupon::service::restore_for_order(&mut tx, order.id).await?;
// Cancelling a group order restores SKU stock only: paid seats are never
// rolled back. An unpaid opener leaves an empty group closed.
if let Some(group_id) = order.group_id {
group_buying::service::cancel_opener_group(&mut tx, order.id, group_id).await?;
}
tx.commit().await?;
let mut views = repo::attach_items(&state.db, vec![order]).await?;
Ok(views.remove(0))
+20 -25
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)
// 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");
+499
View File
@@ -0,0 +1,499 @@
mod common;
use common::{
add_to_cart, client, login_admin, register_customer, setup_sellable, spawn_app, TestApp,
};
use serial_test::serial;
use uuid::Uuid;
fn active_window() -> (String, String) {
("2020-01-01T00:00:00Z".into(), "2999-01-01T00:00:00Z".into())
}
fn address() -> serde_json::Value {
serde_json::json!({
"recipient": "Test", "phone": "1", "country": "US",
"region": "CA", "city": "SF", "line1": "1 Way", "postal_code": "94105"
})
}
async fn create_activity_body(
app: &TestApp,
owner: &str,
sku: &str,
group_price: i64,
required: i32,
lifetime_hours: i32,
) -> reqwest::Response {
let (starts, ends) = active_window();
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": group_price,
"currency": "USD",
"required_members": required,
"starts_at": starts,
"ends_at": ends,
"group_lifetime_hours": lifetime_hours,
"enabled": true,
}))
.send()
.await
.unwrap()
}
async fn create_activity(
app: &TestApp,
owner: &str,
sku: &str,
group_price: i64,
required: i32,
lifetime_hours: i32,
) -> String {
let res = create_activity_body(app, owner, sku, group_price, required, lifetime_hours).await;
assert_eq!(res.status(), 201, "create activity: {:?}", res.text().await);
res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string()
}
async fn checkout_group(
app: &TestApp,
token: &str,
sku: &str,
activity: &str,
group_id: Option<&str>,
) -> reqwest::Response {
client()
.post(app.url("/api/orders/checkout"))
.bearer_auth(token)
.json(&serde_json::json!({
"shipping_address": address(),
"currency": "USD",
"coupon_by_shop": {},
"group_buy": {
"activity_id": activity,
"sku_id": sku,
"group_id": group_id,
},
}))
.send()
.await
.unwrap()
}
async fn checkout_with_coupon(
app: &TestApp,
token: &str,
sku: &str,
activity: &str,
coupon_by_shop: serde_json::Value,
) -> reqwest::Response {
client()
.post(app.url("/api/orders/checkout"))
.bearer_auth(token)
.json(&serde_json::json!({
"shipping_address": address(),
"currency": "USD",
"coupon_by_shop": coupon_by_shop,
"group_buy": { "activity_id": activity, "sku_id": sku, "group_id": null },
}))
.send()
.await
.unwrap()
}
async fn pay(app: &TestApp, token: &str, order_id: &str) -> reqwest::StatusCode {
client()
.post(app.url(&format!("/api/orders/{order_id}/pay")))
.bearer_auth(token)
.send()
.await
.unwrap()
.status()
}
async fn cancel(app: &TestApp, token: &str, order_id: &str) -> reqwest::StatusCode {
client()
.post(app.url(&format!("/api/orders/{order_id}/cancel")))
.bearer_auth(token)
.send()
.await
.unwrap()
.status()
}
async fn group_state(app: &TestApp, group_id: &str) -> (String, i32) {
sqlx::query_as("SELECT status::text, paid_member_count FROM collective_groups WHERE id = $1")
.bind(Uuid::parse_str(group_id).unwrap())
.fetch_one(&app.db)
.await
.unwrap()
}
async fn order_status(app: &TestApp, order_id: &str) -> String {
sqlx::query_scalar("SELECT status::text FROM orders WHERE id = $1")
.bind(Uuid::parse_str(order_id).unwrap())
.fetch_one(&app.db)
.await
.unwrap()
}
/// Checkout one group order and return its id.
async fn place(
app: &TestApp,
token: &str,
sku: &str,
activity: &str,
group_id: Option<&str>,
) -> String {
let res = checkout_group(app, token, sku, activity, group_id).await;
assert_eq!(res.status(), 201, "checkout: {:?}", res.text().await);
res.json::<serde_json::Value>().await.unwrap()[0]["id"]
.as_str()
.unwrap()
.to_string()
}
#[tokio::test]
#[serial]
async fn activity_requires_an_own_sku_and_two_members() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (owner_a, _shop_a, _p, sku_a) = setup_sellable(&app, &admin, "gb-valid-a", 1000, 10).await;
let (_owner_b, _shop_b, _p2, sku_b) = setup_sellable(&app, &admin, "gb-valid-b", 1000, 10).await;
// Another shop's SKU is refused.
let res = create_activity_body(&app, &owner_a, &sku_b, 700, 2, 24).await;
assert_eq!(res.status(), 400);
// Fewer than two paid members is refused.
let res = create_activity_body(&app, &owner_a, &sku_a, 700, 1, 24).await;
assert_eq!(res.status(), 400);
// A valid activity is accepted and publicly discoverable.
let activity = create_activity(&app, &owner_a, &sku_a, 700, 2, 24).await;
let active: Vec<serde_json::Value> = client()
.get(app.url("/api/group-buying/activities"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let found = active
.iter()
.find(|a| a["id"] == activity.as_str())
.expect("the activity is public");
assert_eq!(found["required_members"], 2);
assert_eq!(found["group_price_minor"], 700);
assert!(found["open_groups"].as_array().unwrap().is_empty());
}
#[tokio::test]
#[serial]
async fn open_then_join_completes_a_group() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "gb-flow", 1000, 10).await;
let activity = create_activity(&app, &owner, &sku, 700, 2, 24).await;
let (token_a, _) = register_customer(&app, "gb-open").await;
let (token_b, _) = register_customer(&app, "gb-join").await;
// The opener creates the group and snapshots the group price.
add_to_cart(&app, &token_a, &sku, 1).await;
let res = checkout_group(&app, &token_a, &sku, &activity, None).await;
assert_eq!(res.status(), 201, "open: {:?}", res.text().await);
let opened: Vec<serde_json::Value> = res.json().await.unwrap();
let order_a = opened[0]["id"].as_str().unwrap().to_string();
let group_id = opened[0]["group_id"].as_str().unwrap().to_string();
assert_eq!(opened[0]["total_minor"], 700, "group price is snapshotted");
assert_eq!(opened[0]["items"][0]["unit_price_minor"], 700);
assert_eq!(opened[0]["group_activity_id"], activity.as_str());
// It is discoverable as an open group before anyone has paid.
let active: Vec<serde_json::Value> = client()
.get(app.url("/api/group-buying/activities"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let found = active.iter().find(|a| a["id"] == activity.as_str()).unwrap();
assert_eq!(found["open_groups"][0]["id"], group_id.as_str());
assert_eq!(found["open_groups"][0]["paid_member_count"], 0);
// A joiner references the open group.
add_to_cart(&app, &token_b, &sku, 1).await;
let order_b = place(&app, &token_b, &sku, &activity, Some(&group_id)).await;
assert_eq!(pay(&app, &token_a, &order_a).await, 200);
assert_eq!(group_state(&app, &group_id).await, ("open".into(), 1));
assert_eq!(pay(&app, &token_b, &order_b).await, 200);
assert_eq!(group_state(&app, &group_id).await, ("successful".into(), 2));
assert_eq!(order_status(&app, &order_a).await, "paid");
assert_eq!(order_status(&app, &order_b).await, "paid");
}
#[tokio::test]
#[serial]
async fn intent_requires_quantity_one() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "gb-qty", 1000, 10).await;
let activity = create_activity(&app, &owner, &sku, 700, 2, 24).await;
let (token, _) = register_customer(&app, "gb-qty").await;
add_to_cart(&app, &token, &sku, 2).await;
let res = checkout_group(&app, &token, &sku, &activity, None).await;
assert_eq!(res.status(), 400, "group intent is quantity-1 only");
}
#[tokio::test]
#[serial]
async fn final_seat_admits_one_payment() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "gb-seat", 1000, 10).await;
let activity = create_activity(&app, &owner, &sku, 700, 2, 24).await;
let (token_a, _) = register_customer(&app, "gb-seat-a").await;
let (token_b, _) = register_customer(&app, "gb-seat-b").await;
let (token_c, _) = register_customer(&app, "gb-seat-c").await;
add_to_cart(&app, &token_a, &sku, 1).await;
let order_a = place(&app, &token_a, &sku, &activity, None).await;
let group_id = sqlx::query_scalar::<_, Uuid>("SELECT group_id FROM orders WHERE id = $1")
.bind(Uuid::parse_str(&order_a).unwrap())
.fetch_one(&app.db)
.await
.unwrap()
.to_string();
assert_eq!(pay(&app, &token_a, &order_a).await, 200);
assert_eq!(group_state(&app, &group_id).await, ("open".into(), 1));
add_to_cart(&app, &token_b, &sku, 1).await;
let order_b = place(&app, &token_b, &sku, &activity, Some(&group_id)).await;
add_to_cart(&app, &token_c, &sku, 1).await;
let order_c = place(&app, &token_c, &sku, &activity, Some(&group_id)).await;
// Two payments race for the one remaining seat.
let (b, c) = tokio::join!(pay(&app, &token_b, &order_b), pay(&app, &token_c, &order_c));
let wins = [b, c].iter().filter(|s| s.is_success()).count();
assert_eq!(wins, 1, "exactly one payment takes the final seat: {b} {c}");
assert_eq!(group_state(&app, &group_id).await, ("successful".into(), 2));
let loser_order = if b.is_success() { &order_c } else { &order_b };
assert_eq!(
order_status(&app, loser_order).await,
"pending_payment",
"a rejected payment leaves the order pending"
);
}
#[tokio::test]
#[serial]
async fn expired_and_cancelled_groups_are_not_joinable() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "gb-state", 1000, 20).await;
let activity = create_activity(&app, &owner, &sku, 700, 3, 24).await;
let (token_a, _) = register_customer(&app, "gb-state-a").await;
let (token_b, _) = register_customer(&app, "gb-state-b").await;
// A paid member whose group expires stays identifiable for a later refund.
add_to_cart(&app, &token_a, &sku, 1).await;
let order_a = place(&app, &token_a, &sku, &activity, None).await;
let group_id = sqlx::query_scalar::<_, Uuid>("SELECT group_id FROM orders WHERE id = $1")
.bind(Uuid::parse_str(&order_a).unwrap())
.fetch_one(&app.db)
.await
.unwrap();
assert_eq!(pay(&app, &token_a, &order_a).await, 200);
sqlx::query("UPDATE collective_groups SET expires_at = now() - interval '1 hour' WHERE id = $1")
.bind(group_id)
.execute(&app.db)
.await
.unwrap();
// A committed read records the expiry (a failed checkout rolls its own
// sweep back, so discovery is what persists it).
let active_after: Vec<serde_json::Value> = client()
.get(app.url("/api/group-buying/activities"))
.send()
.await
.unwrap()
.json()
.await
.unwrap();
let found = active_after
.iter()
.find(|a| a["id"] == activity.as_str())
.unwrap();
assert!(
found["open_groups"]
.as_array()
.unwrap()
.iter()
.all(|g| g["id"] != group_id.to_string()),
"an expired group is not offered"
);
assert_eq!(group_state(&app, &group_id.to_string()).await.0, "expired");
// Joining an expired group is refused.
add_to_cart(&app, &token_b, &sku, 1).await;
let res = checkout_group(&app, &token_b, &sku, &activity, Some(&group_id.to_string())).await;
assert_eq!(res.status(), 409, "an expired group is not joinable");
let members: i64 =
sqlx::query_scalar("SELECT count(*) FROM collective_group_members WHERE group_id = $1")
.bind(group_id)
.fetch_one(&app.db)
.await
.unwrap();
assert_eq!(members, 1, "the paid membership survives for a refund flow");
// A second group, cancelled by its unpaid opener. A failed checkout leaves
// the cart intact, so use a fresh customer rather than adding again.
let (token_c, _) = register_customer(&app, "gb-state-c").await;
add_to_cart(&app, &token_a, &sku, 1).await;
let order_c = place(&app, &token_a, &sku, &activity, None).await;
let group_c = sqlx::query_scalar::<_, Uuid>("SELECT group_id FROM orders WHERE id = $1")
.bind(Uuid::parse_str(&order_c).unwrap())
.fetch_one(&app.db)
.await
.unwrap();
assert_eq!(cancel(&app, &token_a, &order_c).await, 200);
assert_eq!(group_state(&app, &group_c.to_string()).await.0, "cancelled");
add_to_cart(&app, &token_c, &sku, 1).await;
let res = checkout_group(&app, &token_c, &sku, &activity, Some(&group_c.to_string())).await;
assert_eq!(res.status(), 409, "a cancelled group is not joinable");
}
#[tokio::test]
#[serial]
async fn coupon_is_rejected_on_a_group_order() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (owner, shop, _product, sku) = setup_sellable(&app, &admin, "gb-coupon", 1000, 10).await;
let activity = create_activity(&app, &owner, &sku, 700, 2, 24).await;
let res = client()
.post(app.url("/api/shop/coupon-templates"))
.bearer_auth(&owner)
.json(&serde_json::json!({
"title": {"en": "Coupon", "zh": "优惠券"},
"amount_minor": 100, "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 = res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let (token, _) = register_customer(&app, "gb-coupon").await;
let res = client()
.post(app.url("/api/me/coupons"))
.bearer_auth(&token)
.json(&serde_json::json!({ "template_id": template }))
.send()
.await
.unwrap();
let coupon = res.json::<serde_json::Value>().await.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
add_to_cart(&app, &token, &sku, 1).await;
let res = checkout_with_coupon(
&app,
&token,
&sku,
&activity,
serde_json::json!({ shop: coupon }),
)
.await;
assert_eq!(res.status(), 409, "a group order refuses a coupon");
}
#[tokio::test]
#[serial]
async fn activity_rejects_a_sku_with_an_overlapping_flash_sale() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "gb-flash", 1000, 10).await;
let (starts, ends) = active_window();
let session = 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()
.json::<serde_json::Value>()
.await
.unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let res = client()
.post(app.url(&format!("/api/shop/flash-sales/{session}/items")))
.bearer_auth(&owner)
.json(&serde_json::json!({
"sku_id": sku, "sale_price_minor": 500, "currency": "USD",
"reserved_stock": 5, "per_customer_limit": 2,
}))
.send()
.await
.unwrap();
assert_eq!(res.status(), 201);
// The group activity now refuses the SKU that is already in a flash sale.
let res = create_activity_body(&app, &owner, &sku, 700, 2, 24).await;
assert_eq!(res.status(), 409);
}
#[tokio::test]
#[serial]
async fn cancelling_does_not_roll_back_paid_seats() {
let app = spawn_app().await;
let admin = login_admin(&app).await;
let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "gb-cancel", 1000, 10).await;
let activity = create_activity(&app, &owner, &sku, 700, 3, 24).await;
let (token_a, _) = register_customer(&app, "gb-cancel-a").await;
let (token_b, _) = register_customer(&app, "gb-cancel-b").await;
add_to_cart(&app, &token_a, &sku, 1).await;
let order_a = place(&app, &token_a, &sku, &activity, None).await;
let group_id = sqlx::query_scalar::<_, Uuid>("SELECT group_id FROM orders WHERE id = $1")
.bind(Uuid::parse_str(&order_a).unwrap())
.fetch_one(&app.db)
.await
.unwrap()
.to_string();
assert_eq!(pay(&app, &token_a, &order_a).await, 200);
add_to_cart(&app, &token_b, &sku, 1).await;
let order_b = place(&app, &token_b, &sku, &activity, Some(&group_id)).await;
assert_eq!(cancel(&app, &token_b, &order_b).await, 200);
// The paid seat is untouched and the group stays open.
assert_eq!(group_state(&app, &group_id).await, ("open".into(), 1));
assert_eq!(order_status(&app, &order_b).await, "cancelled");
}
@@ -17,6 +17,8 @@ The group-buying page presents fixed participant counts. Unlike a simple price r
### Activities and groups have separate state
`group_buying_activities` stores shop, SKU, localized presentation, group price minor/currency, required paid members, active window, and group lifetime. Each membership order MAY include the activity SKU at quantity 1 only. `collective_groups` references an activity and records leader order, paid-member count, expiry, and `open | successful | expired | cancelled` state. `collective_group_members` is one row per paid order and group, with a uniqueness constraint per order and customer membership policy.
**Column contract with the archived flash-sales guard:** the activity columns MUST be named `sku_id`, `enabled`, `starts_at`, and `ends_at`. `add-flash-sales` ships a dormant overlap check that queries exactly those names once this table exists; any other naming turns that guard into a runtime error on flash-item creation. Both directions of the exclusion are then live: flash rejects a SKU with an overlapping activity, and this change rejects an activity for a SKU with an overlapping flash item.
### Membership finalizes at payment, not cart checkout
Checkout accepts an optional group-buy intent for exactly one activity SKU at quantity 1 and snapshots the activity price plus group ID on the pending order. Opening creates an empty open group; joining references an open group. Unpaid orders do not occupy a paid seat. `pay` locks the group and membership rows, expires due groups first, conditionally claims a paid seat, and transitions the group to successful exactly when its paid count reaches the required count. A full, expired, or cancelled group leaves the order pending payment and returns Conflict.
+5 -5
View File
@@ -1,14 +1,14 @@
## 1. Group-buying state model and contract
- [ ] 1.1 Add additive migrations for shop activities, concrete groups, paid memberships, expiry/state constraints, and order group-price snapshots.
- [ ] 1.2 Implement the Rust group-buying module with shop-scoped activity management, active discovery, deterministic expiry, and open-group queries.
- [x] 1.1 Add additive migrations for shop activities, concrete groups, paid memberships, expiry/state constraints, and order group-price snapshots.
- [x] 1.2 Implement the Rust group-buying module with shop-scoped activity management, active discovery, deterministic expiry, and open-group queries.
- [ ] 1.3 Add shared group-buying types, checkout intent (single SKU, quantity 1), API-client methods, locales, and fixed-data adapter parity.
## 2. Checkout and payment integration
- [ ] 2.1 Validate a single-SKU quantity-1 group-buying intent during checkout, create or reference an open group, and snapshot group price and identity on the pending order. Reject overlapping flash eligibility and coupon selection on that shop order; restate coupon and flash handling for other shop orders in the same checkout.
- [ ] 2.2 Extend payment to atomically expire due groups, claim a paid seat, enforce capacity, and transition a filled group to successful. Pending-payment cancellation restores SKU stock only and does not roll back paid membership. Cancelling the opening unpaid order while paid count is zero marks the group cancelled.
- [ ] 2.3 Add integration tests for activity/shop validation, open versus join flow, quantity-1 enforcement, concurrent final-seat payment, full/expired/cancelled-group conflicts, coupon rejection on group shop orders, overlapping flash rejection, cancel-without-membership-rollback, unpaid-opener empty-group cancel, and visibility of expired paid groups.
- [x] 2.1 Validate a single-SKU quantity-1 group-buying intent during checkout, create or reference an open group, and snapshot group price and identity on the pending order. Reject overlapping flash eligibility and coupon selection on that shop order; restate coupon and flash handling for other shop orders in the same checkout.
- [x] 2.2 Extend payment to atomically expire due groups, claim a paid seat, enforce capacity, and transition a filled group to successful. Pending-payment cancellation restores SKU stock only and does not roll back paid membership. Cancelling the opening unpaid order while paid count is zero marks the group cancelled.
- [x] 2.3 Add integration tests for activity/shop validation, open versus join flow, quantity-1 enforcement, concurrent final-seat payment, full/expired/cancelled-group conflicts, coupon rejection on group shop orders, overlapping flash rejection, cancel-without-membership-rollback, unpaid-opener empty-group cancel, and visibility of expired paid groups.
## 3. Merchant and customer surfaces