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:
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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?,
|
||||
),
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user