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
+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(())
}