diff --git a/apps/api/migrations/0011_shop_coupons.sql b/apps/api/migrations/0011_shop_coupons.sql new file mode 100644 index 0000000..6a50958 --- /dev/null +++ b/apps/api/migrations/0011_shop_coupons.sql @@ -0,0 +1,51 @@ +-- Shop coupons: a shop-owned template plus a customer-owned snapshot taken at +-- claim time. The snapshot copies terms and window so editing or disabling a +-- template cannot change a coupon a customer already holds. +-- +-- `orders` gains the realized discount and the coupon it redeemed. Both tables +-- reference each other; ON DELETE SET NULL keeps either side deletable. + +CREATE TYPE coupon_status AS ENUM ('claimed', 'redeemed', 'expired'); + +CREATE TABLE coupon_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + shop_id UUID NOT NULL REFERENCES shops (id) ON DELETE CASCADE, + title JSONB NOT NULL, + amount_minor BIGINT NOT NULL CHECK (amount_minor > 0), + threshold_minor BIGINT NOT NULL CHECK (threshold_minor >= 0), + currency CHAR(3) NOT NULL REFERENCES currencies (code), + stock INT NOT NULL CHECK (stock >= 0), + enabled BOOLEAN NOT NULL DEFAULT TRUE, + starts_at TIMESTAMPTZ NOT NULL, + ends_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT coupon_templates_window CHECK (ends_at >= starts_at) +); + +CREATE INDEX coupon_templates_shop_idx ON coupon_templates (shop_id); + +CREATE TABLE coupons ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + -- Nullable so deleting a template keeps the snapshots customers already hold. + template_id UUID REFERENCES coupon_templates (id) ON DELETE SET NULL, + shop_id UUID NOT NULL REFERENCES shops (id) ON DELETE CASCADE, + title JSONB NOT NULL, + amount_minor BIGINT NOT NULL CHECK (amount_minor > 0), + threshold_minor BIGINT NOT NULL CHECK (threshold_minor >= 0), + currency CHAR(3) NOT NULL REFERENCES currencies (code), + starts_at TIMESTAMPTZ NOT NULL, + ends_at TIMESTAMPTZ NOT NULL, + status coupon_status NOT NULL DEFAULT 'claimed', + order_id UUID REFERENCES orders (id) ON DELETE SET NULL, + claimed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + redeemed_at TIMESTAMPTZ +); + +-- One claim per customer per template. +CREATE UNIQUE INDEX coupons_user_template_idx ON coupons (user_id, template_id); +CREATE INDEX coupons_user_idx ON coupons (user_id, status); + +ALTER TABLE orders ADD COLUMN coupon_id UUID REFERENCES coupons (id) ON DELETE SET NULL; +ALTER TABLE orders ADD COLUMN discount_minor BIGINT NOT NULL DEFAULT 0 CHECK (discount_minor >= 0); diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index cc3738a..cd40a38 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -189,6 +189,10 @@ pub struct Order { pub status: OrderStatus, pub currency: String, pub total_minor: i64, + /// Realized coupon discount, converted to the order currency server-side. + pub discount_minor: i64, + /// The coupon this order redeemed, if any. + pub coupon_id: Option, pub shipping_address: serde_json::Value, pub created_at: DateTime, pub updated_at: DateTime, @@ -286,6 +290,59 @@ pub struct CustomerAccountEntry { pub const CUSTOMER_ACCOUNT_ENTRY_COLUMNS: &str = "id, account_id, delta_minor, balance_minor, reason, reference_type, reference_id, created_at"; +/// Lifecycle of a customer-owned coupon snapshot. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[sqlx(type_name = "coupon_status", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum CouponStatus { + Claimed, + Redeemed, + Expired, +} + +/// Shop-issued coupon definition. Editing one never changes claims already made. +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct CouponTemplate { + pub id: Uuid, + pub shop_id: Uuid, + pub title: serde_json::Value, + pub amount_minor: i64, + pub threshold_minor: i64, + pub currency: String, + /// Remaining claim stock; decremented conditionally on claim. + pub stock: i32, + pub enabled: bool, + pub starts_at: DateTime, + pub ends_at: DateTime, + pub created_at: DateTime, +} + +pub const COUPON_TEMPLATE_COLUMNS: &str = "id, shop_id, title, amount_minor, threshold_minor, \ + currency, stock, enabled, starts_at, ends_at, created_at"; + +/// Customer-owned snapshot of a template's terms at claim time. +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct Coupon { + pub id: Uuid, + pub user_id: Uuid, + /// Null once the issuing template is deleted; the snapshot still stands. + pub template_id: Option, + pub shop_id: Uuid, + pub title: serde_json::Value, + pub amount_minor: i64, + pub threshold_minor: i64, + pub currency: String, + pub starts_at: DateTime, + pub ends_at: DateTime, + pub status: CouponStatus, + pub order_id: Option, + pub claimed_at: DateTime, + pub redeemed_at: Option>, +} + +pub const COUPON_COLUMNS: &str = "id, user_id, template_id, shop_id, title, amount_minor, \ + threshold_minor, currency, starts_at, ends_at, status, order_id, claimed_at, redeemed_at"; + #[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub struct AddressBookEntry { pub id: Uuid, diff --git a/apps/api/src/modules/coupon/dto.rs b/apps/api/src/modules/coupon/dto.rs new file mode 100644 index 0000000..4d99a46 --- /dev/null +++ b/apps/api/src/modules/coupon/dto.rs @@ -0,0 +1,21 @@ +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +/// Shop-side create/update body. The snapshot a customer claims copies these +/// terms, so later edits never rewrite a held coupon. +#[derive(Deserialize)] +pub struct CouponTemplateInput { + pub title: serde_json::Value, + pub amount_minor: i64, + pub threshold_minor: i64, + pub currency: String, + pub stock: i32, + #[serde(default = "default_enabled")] + pub enabled: bool, + pub starts_at: DateTime, + pub ends_at: DateTime, +} + +fn default_enabled() -> bool { + true +} diff --git a/apps/api/src/modules/coupon/handlers.rs b/apps/api/src/modules/coupon/handlers.rs new file mode 100644 index 0000000..d1b6300 --- /dev/null +++ b/apps/api/src/modules/coupon/handlers.rs @@ -0,0 +1,100 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + routing::{get, put}, + Json, Router, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::models::{Coupon, CouponTemplate}; +use crate::state::AppState; + +use super::dto::CouponTemplateInput; +use super::service; + +pub fn router() -> Router { + Router::new() + // Public: coupons a shopper could claim from a shop right now. + .route("/shops/{shop_id}/coupon-templates", get(list_claimable)) + .route("/me/coupons", get(list_mine).post(claim)) + .route("/shop/coupon-templates", get(shop_list).post(shop_create)) + .route( + "/shop/coupon-templates/{id}", + put(shop_update).delete(shop_delete), + ) +} + +async fn list_claimable( + State(state): State, + Path(shop_id): Path, +) -> ApiResult>> { + Ok(Json(service::list_claimable(&state, shop_id).await?)) +} + +async fn list_mine( + State(state): State, + auth: AuthUser, +) -> ApiResult>> { + auth.require_customer()?; + Ok(Json(service::list_mine(&state, auth.id).await?)) +} + +#[derive(Deserialize)] +struct ClaimBody { + template_id: Uuid, +} + +async fn claim( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + auth.require_customer()?; + Ok(( + StatusCode::CREATED, + Json(service::claim(&state, auth.id, body.template_id).await?), + )) +} + +async fn shop_list( + State(state): State, + auth: AuthUser, +) -> ApiResult>> { + let shop_id = auth.require_shop()?; + Ok(Json(service::list_for_shop(&state, shop_id).await?)) +} + +async fn shop_create( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + let shop_id = auth.require_shop()?; + Ok(( + StatusCode::CREATED, + Json(service::create(&state, shop_id, body).await?), + )) +} + +async fn shop_update( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + let shop_id = auth.require_shop()?; + Ok(Json(service::update(&state, shop_id, id, body).await?)) +} + +async fn shop_delete( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult { + let shop_id = auth.require_shop()?; + service::delete(&state, shop_id, id).await?; + Ok(StatusCode::NO_CONTENT) +} diff --git a/apps/api/src/modules/coupon/mod.rs b/apps/api/src/modules/coupon/mod.rs new file mode 100644 index 0000000..2cdf8fe --- /dev/null +++ b/apps/api/src/modules/coupon/mod.rs @@ -0,0 +1,12 @@ +mod dto; +mod handlers; +mod repo; +pub mod service; + +use axum::Router; + +use crate::state::AppState; + +pub fn router() -> Router { + handlers::router() +} diff --git a/apps/api/src/modules/coupon/repo.rs b/apps/api/src/modules/coupon/repo.rs new file mode 100644 index 0000000..2147a8e --- /dev/null +++ b/apps/api/src/modules/coupon/repo.rs @@ -0,0 +1,242 @@ +use sqlx::{PgConnection, PgExecutor}; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::models::{Coupon, CouponTemplate, COUPON_COLUMNS, COUPON_TEMPLATE_COLUMNS}; + +use super::dto::CouponTemplateInput; + +// ---- shop-owned templates ---- + +pub async fn currency_enabled<'e, E: PgExecutor<'e>>(exec: E, code: &str) -> ApiResult { + 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 list_for_shop<'e, E: PgExecutor<'e>>( + exec: E, + shop_id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_as::<_, CouponTemplate>(&format!( + "SELECT {COUPON_TEMPLATE_COLUMNS} FROM coupon_templates + WHERE shop_id = $1 ORDER BY created_at DESC" + )) + .bind(shop_id) + .fetch_all(exec) + .await?) +} + +/// Templates a customer may claim right now: enabled, in window, stock left. +pub async fn list_claimable<'e, E: PgExecutor<'e>>( + exec: E, + shop_id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_as::<_, CouponTemplate>(&format!( + "SELECT {COUPON_TEMPLATE_COLUMNS} FROM coupon_templates + WHERE shop_id = $1 AND enabled = TRUE AND stock > 0 + AND now() BETWEEN starts_at AND ends_at + ORDER BY amount_minor DESC" + )) + .bind(shop_id) + .fetch_all(exec) + .await?) +} + +pub async fn insert( + tx: &mut PgConnection, + shop_id: Uuid, + body: &CouponTemplateInput, +) -> ApiResult { + Ok(sqlx::query_as::<_, CouponTemplate>(&format!( + "INSERT INTO coupon_templates + (shop_id, title, amount_minor, threshold_minor, currency, stock, enabled, + starts_at, ends_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING {COUPON_TEMPLATE_COLUMNS}" + )) + .bind(shop_id) + .bind(&body.title) + .bind(body.amount_minor) + .bind(body.threshold_minor) + .bind(body.currency.to_uppercase()) + .bind(body.stock) + .bind(body.enabled) + .bind(body.starts_at) + .bind(body.ends_at) + .fetch_one(&mut *tx) + .await?) +} + +pub async fn update( + tx: &mut PgConnection, + shop_id: Uuid, + id: Uuid, + body: &CouponTemplateInput, +) -> ApiResult { + sqlx::query_as::<_, CouponTemplate>(&format!( + "UPDATE coupon_templates + SET title = $3, amount_minor = $4, threshold_minor = $5, currency = $6, + stock = $7, enabled = $8, starts_at = $9, ends_at = $10, updated_at = now() + WHERE id = $1 AND shop_id = $2 + RETURNING {COUPON_TEMPLATE_COLUMNS}" + )) + .bind(id) + .bind(shop_id) + .bind(&body.title) + .bind(body.amount_minor) + .bind(body.threshold_minor) + .bind(body.currency.to_uppercase()) + .bind(body.stock) + .bind(body.enabled) + .bind(body.starts_at) + .bind(body.ends_at) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::NotFound("coupon template".into())) +} + +pub async fn delete(tx: &mut PgConnection, shop_id: Uuid, id: Uuid) -> ApiResult<()> { + let result = sqlx::query("DELETE FROM coupon_templates 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("coupon template".into())); + } + Ok(()) +} + +/// Lock the template row for a claim so two customers cannot both take the last one. +pub async fn lock_template( + tx: &mut PgConnection, + id: Uuid, +) -> ApiResult { + sqlx::query_as::<_, CouponTemplate>(&format!( + "SELECT {COUPON_TEMPLATE_COLUMNS} FROM coupon_templates WHERE id = $1 FOR UPDATE" + )) + .bind(id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::NotFound("coupon template".into())) +} + +/// Guarded counter decrement: never drives claim stock below zero. +pub async fn decrement_claim_stock(tx: &mut PgConnection, id: Uuid) -> ApiResult<()> { + let result = sqlx::query( + "UPDATE coupon_templates SET stock = stock - 1, updated_at = now() + WHERE id = $1 AND stock >= 1", + ) + .bind(id) + .execute(&mut *tx) + .await?; + if result.rows_affected() == 0 { + return Err(ApiError::Conflict("coupon is no longer available".into())); + } + Ok(()) +} + +// ---- customer-owned snapshots ---- + +pub async fn insert_claim( + tx: &mut PgConnection, + user_id: Uuid, + template: &CouponTemplate, +) -> Result { + sqlx::query_as::<_, Coupon>(&format!( + "INSERT INTO coupons + (user_id, template_id, shop_id, title, amount_minor, threshold_minor, currency, + starts_at, ends_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING {COUPON_COLUMNS}" + )) + .bind(user_id) + .bind(template.id) + .bind(template.shop_id) + .bind(&template.title) + .bind(template.amount_minor) + .bind(template.threshold_minor) + .bind(&template.currency) + .bind(template.starts_at) + .bind(template.ends_at) + .fetch_one(&mut *tx) + .await +} + +/// Record expiry lazily on read; no scheduler is involved. +pub async fn expire_due_for_user<'e, E: PgExecutor<'e>>(exec: E, user_id: Uuid) -> ApiResult<()> { + sqlx::query( + "UPDATE coupons SET status = 'expired' + WHERE user_id = $1 AND status = 'claimed' AND ends_at < now()", + ) + .bind(user_id) + .execute(exec) + .await?; + Ok(()) +} + +pub async fn list_for_user<'e, E: PgExecutor<'e>>( + exec: E, + user_id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_as::<_, Coupon>(&format!( + "SELECT {COUPON_COLUMNS} FROM coupons + WHERE user_id = $1 + ORDER BY CASE status WHEN 'claimed' THEN 0 WHEN 'redeemed' THEN 1 ELSE 2 END, + ends_at DESC" + )) + .bind(user_id) + .fetch_all(exec) + .await?) +} + +/// Lock the customer's chosen coupons in primary-key order for checkout. +pub async fn lock_owned_for_update( + tx: &mut PgConnection, + user_id: Uuid, + ids: &[Uuid], +) -> ApiResult> { + Ok(sqlx::query_as::<_, Coupon>(&format!( + "SELECT {COUPON_COLUMNS} FROM coupons + WHERE id = ANY($1) AND user_id = $2 + ORDER BY id + FOR UPDATE" + )) + .bind(ids) + .bind(user_id) + .fetch_all(&mut *tx) + .await?) +} + +pub async fn redeem( + tx: &mut PgConnection, + coupon_id: Uuid, + order_id: Uuid, +) -> ApiResult { + sqlx::query_as::<_, Coupon>(&format!( + "UPDATE coupons SET status = 'redeemed', order_id = $2, redeemed_at = now() + WHERE id = $1 AND status = 'claimed' + RETURNING {COUPON_COLUMNS}" + )) + .bind(coupon_id) + .bind(order_id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::Conflict("coupon cannot be redeemed".into())) +} + +/// Pending-payment cancellation returns a redeemed coupon to claimed. +pub async fn restore_for_order(tx: &mut PgConnection, order_id: Uuid) -> ApiResult<()> { + sqlx::query( + "UPDATE coupons SET status = 'claimed', order_id = NULL, redeemed_at = NULL + WHERE order_id = $1 AND status = 'redeemed'", + ) + .bind(order_id) + .execute(&mut *tx) + .await?; + Ok(()) +} diff --git a/apps/api/src/modules/coupon/service.rs b/apps/api/src/modules/coupon/service.rs new file mode 100644 index 0000000..60667e9 --- /dev/null +++ b/apps/api/src/modules/coupon/service.rs @@ -0,0 +1,193 @@ +use chrono::Utc; +use serde_json::Value; +use sqlx::PgConnection; +use uuid::Uuid; + +use crate::error::{unique_conflict, ApiError, ApiResult}; +use crate::models::{Coupon, CouponStatus, CouponTemplate, Currency}; +use crate::money::convert_minor; +use crate::state::AppState; + +use super::dto::CouponTemplateInput; +use super::repo; + +// ---- shop-side management ---- + +pub async fn list_for_shop(state: &AppState, shop_id: Uuid) -> ApiResult> { + repo::list_for_shop(&state.db, shop_id).await +} + +/// Public: what a customer may claim from this shop right now. +pub async fn list_claimable(state: &AppState, shop_id: Uuid) -> ApiResult> { + repo::list_claimable(&state.db, shop_id).await +} + +pub async fn create( + state: &AppState, + shop_id: Uuid, + body: CouponTemplateInput, +) -> ApiResult { + validate_input(&body)?; + let mut tx = state.db.begin().await?; + ensure_currency(&mut tx, &body.currency).await?; + let template = repo::insert(&mut tx, shop_id, &body).await?; + tx.commit().await?; + Ok(template) +} + +pub async fn update( + state: &AppState, + shop_id: Uuid, + id: Uuid, + body: CouponTemplateInput, +) -> ApiResult { + validate_input(&body)?; + let mut tx = state.db.begin().await?; + ensure_currency(&mut tx, &body.currency).await?; + let template = repo::update(&mut tx, shop_id, id, &body).await?; + tx.commit().await?; + Ok(template) +} + +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(()) +} + +// ---- customer-side ---- + +pub async fn list_mine(state: &AppState, user_id: Uuid) -> ApiResult> { + repo::expire_due_for_user(&state.db, user_id).await?; + repo::list_for_user(&state.db, user_id).await +} + +/// Claim a template once: guarded stock decrement plus a unique claim per customer. +pub async fn claim(state: &AppState, user_id: Uuid, template_id: Uuid) -> ApiResult { + let mut tx = state.db.begin().await?; + let template = repo::lock_template(&mut tx, template_id).await?; + let now = Utc::now(); + if !template.enabled + || template.stock <= 0 + || now < template.starts_at + || now > template.ends_at + { + return Err(ApiError::Conflict("coupon is not claimable".into())); + } + repo::decrement_claim_stock(&mut tx, template.id).await?; + let coupon = repo::insert_claim(&mut tx, user_id, &template) + .await + .map_err(|e| unique_conflict(e, "coupon already claimed"))?; + tx.commit().await?; + Ok(coupon) +} + +// ---- checkout composition ---- + +/// Lock the customer's selections before validating them. Ordered by id, like +/// the SKU locks, so concurrent checkouts cannot deadlock. +pub async fn lock_selected( + tx: &mut PgConnection, + user_id: Uuid, + coupon_ids: &[Uuid], +) -> ApiResult> { + repo::lock_owned_for_update(tx, user_id, coupon_ids).await +} + +/// Server-authoritative eligibility and discount. The client never sends money. +pub fn checkout_discount( + coupon: &Coupon, + shop_id: Uuid, + subtotal_minor: i64, + target: &Currency, + currencies: &[Currency], +) -> ApiResult { + if coupon.status != CouponStatus::Claimed { + return Err(ApiError::Conflict("coupon is not redeemable".into())); + } + if coupon.shop_id != shop_id { + return Err(ApiError::BadRequest( + "coupon was not issued by this shop".into(), + )); + } + let now = Utc::now(); + if now < coupon.starts_at || now > coupon.ends_at { + return Err(ApiError::Conflict("coupon is outside its active window".into())); + } + let from = currencies + .iter() + .find(|c| c.code == coupon.currency) + .ok_or_else(|| { + ApiError::BadRequest(format!("currency {} is disabled", coupon.currency)) + })?; + let amount = convert_minor(coupon.amount_minor, from, target)?; + let threshold = convert_minor(coupon.threshold_minor, from, target)?; + if subtotal_minor < threshold { + return Err(ApiError::Conflict( + "order subtotal does not reach the coupon threshold".into(), + )); + } + // A discount can never exceed the shop order it applies to. + Ok(amount.min(subtotal_minor)) +} + +pub async fn redeem( + tx: &mut PgConnection, + coupon_id: Uuid, + order_id: Uuid, +) -> ApiResult { + repo::redeem(tx, coupon_id, order_id).await +} + +/// Restore a redeemed coupon when its pending order is cancelled. +pub async fn restore_for_order(tx: &mut PgConnection, order_id: Uuid) -> ApiResult<()> { + repo::restore_for_order(tx, order_id).await +} + +// ---- validation helpers ---- + +fn validate_input(body: &CouponTemplateInput) -> ApiResult<()> { + bilingual(&body.title, "title")?; + if body.amount_minor <= 0 { + return Err(ApiError::BadRequest("amount_minor must be positive".into())); + } + if body.threshold_minor < 0 { + return Err(ApiError::BadRequest( + "threshold_minor must not be negative".into(), + )); + } + if body.stock < 0 { + return Err(ApiError::BadRequest("stock must not be negative".into())); + } + if body.ends_at < body.starts_at { + return Err(ApiError::BadRequest( + "ends_at must not precede starts_at".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 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(()) +} diff --git a/apps/api/src/modules/mod.rs b/apps/api/src/modules/mod.rs index 13ea016..5784ffd 100644 --- a/apps/api/src/modules/mod.rs +++ b/apps/api/src/modules/mod.rs @@ -4,6 +4,7 @@ pub mod billing; pub mod cart; pub mod catalog; pub mod content; +pub mod coupon; pub mod currency; pub mod fulfillment; pub mod health; @@ -25,6 +26,7 @@ pub fn api_router() -> Router { .merge(catalog::router()) .merge(content::router()) .merge(cart::router()) + .merge(coupon::router()) .merge(order::router()) .merge(shop::router()) .merge(fulfillment::router()) diff --git a/apps/api/src/modules/order/handlers.rs b/apps/api/src/modules/order/handlers.rs index f24bfad..8fa99f1 100644 --- a/apps/api/src/modules/order/handlers.rs +++ b/apps/api/src/modules/order/handlers.rs @@ -57,6 +57,10 @@ async fn get_order( struct CheckoutBody { shipping_address: AddressBody, currency: String, + /// At most one owned coupon per generated shop order. The client sends the + /// choice, never a discount amount. + #[serde(default)] + coupon_by_shop: std::collections::HashMap, } async fn checkout( @@ -67,7 +71,14 @@ async fn checkout( Ok(( StatusCode::CREATED, Json( - service::checkout(&state, auth.id, body.shipping_address, body.currency).await?, + service::checkout( + &state, + auth.id, + body.shipping_address, + body.currency, + body.coupon_by_shop, + ) + .await?, ), )) } diff --git a/apps/api/src/modules/order/repo.rs b/apps/api/src/modules/order/repo.rs index 034f6fe..d2c496d 100644 --- a/apps/api/src/modules/order/repo.rs +++ b/apps/api/src/modules/order/repo.rs @@ -9,7 +9,7 @@ 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, - shipping_address, created_at, updated_at"; + discount_minor, coupon_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"; @@ -140,18 +140,23 @@ pub async fn insert_order( user_id: Uuid, currency: &str, total: i64, + discount_minor: i64, + coupon_id: Option, address: &serde_json::Value, ) -> ApiResult { Ok(sqlx::query_as::<_, Order>(&format!( - "INSERT INTO orders (order_no, shop_id, user_id, currency, total_minor, shipping_address) + "INSERT INTO orders (order_no, shop_id, user_id, currency, total_minor, discount_minor, + coupon_id, shipping_address) VALUES ('VM' || to_char(now(), 'YYMMDD') || lpad(nextval('order_no_seq')::text, 6, '0'), - $1, $2, $3, $4, $5) + $1, $2, $3, $4, $5, $6, $7) RETURNING {ORDER_COLS}" )) .bind(shop_id) .bind(user_id) .bind(currency) .bind(total) + .bind(discount_minor) + .bind(coupon_id) .bind(address) .fetch_one(&mut *tx) .await?) diff --git a/apps/api/src/modules/order/service.rs b/apps/api/src/modules/order/service.rs index cad30cb..6b759fe 100644 --- a/apps/api/src/modules/order/service.rs +++ b/apps/api/src/modules/order/service.rs @@ -1,10 +1,12 @@ +use std::collections::HashMap; + use uuid::Uuid; 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; +use crate::modules::{cart, coupon}; use crate::state::AppState; use super::dto::{AddressBody, OrderScope, OrderView}; @@ -54,6 +56,7 @@ pub async fn checkout( user_id: Uuid, shipping_address: AddressBody, currency: String, + coupon_by_shop: HashMap, ) -> ApiResult> { shipping_address.validate()?; let target_currency = currency.to_uppercase(); @@ -73,7 +76,7 @@ pub async fn checkout( "some cart items are no longer purchasable".into(), )); } - let qty_by_sku: std::collections::HashMap = entries.iter().copied().collect(); + let qty_by_sku: HashMap = entries.iter().copied().collect(); for row in &rows { let qty = qty_by_sku.get(&row.sku_id).copied().unwrap_or(0); if qty > row.stock { @@ -90,35 +93,78 @@ pub async fn checkout( shop_order.push(row.shop_id); } } + for shop_id in coupon_by_shop.keys() { + if !shop_order.contains(shop_id) { + return Err(ApiError::BadRequest( + "coupon selected for a shop that is not in the cart".into(), + )); + } + } + + // Per-shop merchandise subtotal in the buyer's currency. + let mut subtotal_by_shop: HashMap = HashMap::new(); + let mut line_prices: Vec<(Uuid, i64, i32)> = Vec::new(); + for row in &rows { + let qty = qty_by_sku.get(&row.sku_id).copied().unwrap_or(0); + let from = all_currencies + .iter() + .find(|c| c.code == row.currency) + .ok_or_else(|| ApiError::BadRequest(format!("currency {} disabled", row.currency)))?; + let unit = convert_minor(row.price_minor, from, &target)?; + *subtotal_by_shop.entry(row.shop_id).or_insert(0) += unit * qty as i64; + line_prices.push((row.sku_id, unit, qty)); + } + + // Lock every selected coupon in id order, then prove the customer owns it. + let mut coupon_ids: Vec = coupon_by_shop.values().copied().collect(); + coupon_ids.sort_unstable(); + coupon_ids.dedup(); + let locked = coupon::service::lock_selected(&mut tx, user_id, &coupon_ids).await?; + if locked.len() != coupon_ids.len() { + return Err(ApiError::NotFound("coupon".into())); + } + let mut coupons: HashMap = + locked.into_iter().map(|c| (c.id, c)).collect(); let address = serde_json::to_value(&shipping_address).map_err(ApiError::internal)?; let mut created = Vec::new(); for shop_id in shop_order { let shop_rows: Vec<&repo::CheckoutRow> = rows.iter().filter(|r| r.shop_id == shop_id).collect(); - let mut total: i64 = 0; - let mut line_prices: Vec<(Uuid, i64, i32)> = Vec::new(); - for row in &shop_rows { - let qty = qty_by_sku.get(&row.sku_id).copied().unwrap_or(0); - let from = all_currencies - .iter() - .find(|c| c.code == row.currency) - .ok_or_else(|| { - ApiError::BadRequest(format!("currency {} disabled", row.currency)) - })?; - let unit = convert_minor(row.price_minor, from, &target)?; - total += unit * qty as i64; - line_prices.push((row.sku_id, unit, qty)); - } + let subtotal = subtotal_by_shop.get(&shop_id).copied().unwrap_or(0); + let selected_coupon = coupon_by_shop.get(&shop_id).copied(); + // Eligibility and the discount are resolved server-side; the client + // only ever sends which coupon, never how much. + let discount = match selected_coupon { + Some(id) => { + let coupon = coupons + .remove(&id) + .ok_or_else(|| ApiError::Conflict("coupon cannot be redeemed".into()))?; + coupon::service::checkout_discount( + &coupon, + shop_id, + subtotal, + &target, + &all_currencies, + )? + } + None => 0, + }; + let order = repo::insert_order( &mut tx, shop_id, user_id, &target.code, - total, + subtotal - discount, + discount, + selected_coupon, &address, ) .await?; + if let Some(id) = selected_coupon { + coupon::service::redeem(&mut tx, id, order.id).await?; + } for row in &shop_rows { let (_, unit, qty) = line_prices .iter() @@ -155,6 +201,8 @@ pub async fn cancel(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult serde_json::Value { + serde_json::json!({ + "title": {"en": "Test coupon", "zh": "测试优惠券"}, + "amount_minor": amount, + "threshold_minor": threshold, + "currency": "USD", + "stock": stock, + "enabled": true, + "starts_at": "2020-01-01T00:00:00Z", + "ends_at": "2999-01-01T00:00:00Z", + }) +} + +async fn create_template(app: &TestApp, owner: &str, body: serde_json::Value) -> String { + let res = client() + .post(app.url("/api/shop/coupon-templates")) + .bearer_auth(owner) + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "create template: {:?}", res.text().await); + res.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_string() +} + +async fn claim(app: &TestApp, token: &str, template_id: &str) -> reqwest::StatusCode { + client() + .post(app.url("/api/me/coupons")) + .bearer_auth(token) + .json(&serde_json::json!({ "template_id": template_id })) + .send() + .await + .unwrap() + .status() +} + +async fn claim_ok(app: &TestApp, token: &str, template_id: &str) -> serde_json::Value { + let res = client() + .post(app.url("/api/me/coupons")) + .bearer_auth(token) + .json(&serde_json::json!({ "template_id": template_id })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "claim: {:?}", res.text().await); + res.json().await.unwrap() +} + +async fn checkout_with( + app: &TestApp, + token: &str, + coupon_by_shop: HashMap, +) -> reqwest::Response { + client() + .post(app.url("/api/orders/checkout")) + .bearer_auth(token) + .json(&serde_json::json!({ + "shipping_address": { + "recipient": "Test Recipient", + "phone": "123456", + "country": "US", + "region": "CA", + "city": "San Jose", + "line1": "1 Test Way", + "postal_code": "95131" + }, + "currency": "USD", + "coupon_by_shop": coupon_by_shop, + })) + .send() + .await + .unwrap() +} + +#[tokio::test] +#[serial] +async fn shop_manages_only_its_own_templates() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let shop_a = create_shop(&app, &admin, "cp-own-a").await; + let owner_a = make_shop_owner(&app, &admin, &shop_a).await; + let shop_b = create_shop(&app, &admin, "cp-own-b").await; + let owner_b = make_shop_owner(&app, &admin, &shop_b).await; + + let template_id = create_template(&app, &owner_a, template_body(500, 0, 3)).await; + + // The issuing shop sees it; another shop does not. + let res = client() + .get(app.url("/api/shop/coupon-templates")) + .bearer_auth(&owner_a) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let mine: Vec = res.json().await.unwrap(); + assert_eq!(mine.len(), 1); + + let res = client() + .get(app.url("/api/shop/coupon-templates")) + .bearer_auth(&owner_b) + .send() + .await + .unwrap(); + let theirs: Vec = res.json().await.unwrap(); + assert!(theirs.is_empty(), "cross-shop templates are hidden"); + + // Cross-shop mutation is a 404, not a 403 with an existence hint. + let res = client() + .put(app.url(&format!("/api/shop/coupon-templates/{template_id}"))) + .bearer_auth(&owner_b) + .json(&template_body(100, 0, 1)) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 404); + + let res = client() + .delete(app.url(&format!("/api/shop/coupon-templates/{template_id}"))) + .bearer_auth(&owner_b) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 404); + + // The public claimable listing exposes an active template. + let res = client() + .get(app.url(&format!("/api/shops/{shop_a}/coupon-templates"))) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let public: Vec = res.json().await.unwrap(); + assert_eq!(public.len(), 1); + assert_eq!(public[0]["amount_minor"], 500); +} + +#[tokio::test] +#[serial] +async fn claim_is_unique_and_the_last_one_wins() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let shop = create_shop(&app, &admin, "cp-claim").await; + let owner = make_shop_owner(&app, &admin, &shop).await; + let template = create_template(&app, &owner, template_body(500, 0, 1)).await; + + let (token_a, _) = register_customer(&app, "cp-claim-a").await; + let (token_b, _) = register_customer(&app, "cp-claim-b").await; + + // Two customers race for the final instance. + let (a, b) = tokio::join!( + claim(&app, &token_a, &template), + claim(&app, &token_b, &template), + ); + let wins = [a, b].iter().filter(|s| s.is_success()).count(); + assert_eq!(wins, 1, "exactly one claim takes the last stock: {a} {b}"); + let loser = if a.is_success() { b } else { a }; + assert_eq!(loser, reqwest::StatusCode::CONFLICT); + + // The same customer cannot claim the same template twice. + let dupe = create_template(&app, &owner, template_body(500, 0, 5)).await; + let (token_c, _) = register_customer(&app, "cp-claim-c").await; + claim_ok(&app, &token_c, &dupe).await; + assert_eq!( + claim(&app, &token_c, &dupe).await, + reqwest::StatusCode::CONFLICT, + "a duplicate claim is refused" + ); + + // Stock never went negative. + let stock: i32 = sqlx::query_scalar("SELECT stock FROM coupon_templates WHERE id = $1") + .bind(Uuid::parse_str(&template).unwrap()) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(stock, 0); +} + +#[tokio::test] +#[serial] +async fn checkout_discounts_only_the_issuing_shop() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (owner_a, shop_a, _, sku_a) = setup_sellable(&app, &admin, "cp-multi-a", 1000, 5).await; + let (_owner_b, _shop_b, _, sku_b) = setup_sellable(&app, &admin, "cp-multi-b", 2000, 5).await; + let template = create_template(&app, &owner_a, template_body(500, 0, 5)).await; + + let (token, _user_id) = register_customer(&app, "cp-multi").await; + let coupon = claim_ok(&app, &token, &template).await; + let coupon_id = coupon["id"].as_str().unwrap().to_string(); + + add_to_cart(&app, &token, &sku_a, 1).await; + add_to_cart(&app, &token, &sku_b, 1).await; + + let res = checkout_with( + &app, + &token, + HashMap::from([(shop_a.clone(), coupon_id.clone())]), + ) + .await; + assert_eq!(res.status(), 201, "checkout: {:?}", res.text().await); + let orders: Vec = res.json().await.unwrap(); + assert_eq!(orders.len(), 2); + + let discounted = orders + .iter() + .find(|o| o["shop_id"] == shop_a.as_str()) + .expect("shop A order"); + assert_eq!(discounted["discount_minor"], 500); + assert_eq!(discounted["total_minor"], 500, "1000 − 500"); + assert_eq!(discounted["coupon_id"], coupon_id.as_str()); + + let untouched = orders + .iter() + .find(|o| o["shop_id"] != shop_a.as_str()) + .expect("shop B order"); + assert_eq!(untouched["discount_minor"], 0); + assert_eq!(untouched["total_minor"], 2000); + + // The coupon is now bound to the order. + let (status, order_ref): (String, Option) = + sqlx::query_as("SELECT status::text, order_id FROM coupons WHERE id = $1") + .bind(Uuid::parse_str(&coupon_id).unwrap()) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(status, "redeemed"); + assert!(order_ref.is_some()); + + // A redeemed coupon cannot be spent again. + add_to_cart(&app, &token, &sku_a, 1).await; + let res = checkout_with( + &app, + &token, + HashMap::from([(shop_a.clone(), coupon_id.clone())]), + ) + .await; + assert_eq!(res.status(), 409, "a spent coupon is not redeemable"); +} + +#[tokio::test] +#[serial] +async fn rejects_cross_shop_threshold_and_expired_coupons() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (owner_a, shop_a, _, sku_a) = setup_sellable(&app, &admin, "cp-rej-a", 1000, 5).await; + let (_owner_b, shop_b, _, sku_b) = setup_sellable(&app, &admin, "cp-rej-b", 2000, 5).await; + + let (token, user_id) = register_customer(&app, "cp-rej").await; + let user = Uuid::parse_str(&user_id).unwrap(); + + // Cross-shop: a shop A coupon offered against a shop B order. + let cross = create_template(&app, &owner_a, template_body(500, 0, 5)).await; + let cross_coupon = claim_ok(&app, &token, &cross).await; + add_to_cart(&app, &token, &sku_b, 1).await; + let res = checkout_with( + &app, + &token, + HashMap::from([( + shop_b.clone(), + cross_coupon["id"].as_str().unwrap().to_string(), + )]), + ) + .await; + assert_eq!(res.status(), 400, "coupon was not issued by that shop"); + + // Threshold: subtotal below the coupon's threshold. + let high = create_template(&app, &owner_a, template_body(500, 10_000, 5)).await; + let high_coupon = claim_ok(&app, &token, &high).await; + add_to_cart(&app, &token, &sku_a, 1).await; + let res = checkout_with( + &app, + &token, + HashMap::from([( + shop_a.clone(), + high_coupon["id"].as_str().unwrap().to_string(), + )]), + ) + .await; + assert_eq!(res.status(), 409, "subtotal does not reach the threshold"); + + // Expired: claim while active, then let the snapshot window elapse. + let expiring = create_template(&app, &owner_a, template_body(500, 0, 5)).await; + let expiring_coupon = claim_ok(&app, &token, &expiring).await; + sqlx::query("UPDATE coupons SET ends_at = now() - interval '1 day' WHERE id = $1") + .bind(Uuid::parse_str(expiring_coupon["id"].as_str().unwrap()).unwrap()) + .execute(&app.db) + .await + .unwrap(); + let res = checkout_with( + &app, + &token, + HashMap::from([( + shop_a.clone(), + expiring_coupon["id"].as_str().unwrap().to_string(), + )]), + ) + .await; + assert_eq!(res.status(), 409, "an elapsed coupon is not redeemable"); + + // Every failed checkout rolled back: no order was created. + let orders: i64 = sqlx::query_scalar("SELECT count(*) FROM orders WHERE user_id = $1") + .bind(user) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(orders, 0); +} + +#[tokio::test] +#[serial] +async fn cancelling_a_pending_order_restores_its_coupon() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let (owner, _shop, _product, sku) = setup_sellable(&app, &admin, "cp-cancel", 1000, 5).await; + let template = create_template(&app, &owner, template_body(500, 0, 5)).await; + + let (token, user_id) = register_customer(&app, "cp-cancel").await; + let user = Uuid::parse_str(&user_id).unwrap(); + let coupon = claim_ok(&app, &token, &template).await; + let coupon_id = coupon["id"].as_str().unwrap().to_string(); + let shop_id = coupon["shop_id"].as_str().unwrap().to_string(); + + add_to_cart(&app, &token, &sku, 2).await; + let res = checkout_with( + &app, + &token, + HashMap::from([(shop_id, coupon_id.clone())]), + ) + .await; + assert_eq!(res.status(), 201); + let orders: Vec = res.json().await.unwrap(); + let order_id = orders[0]["id"].as_str().unwrap().to_string(); + + let res = client() + .post(app.url(&format!("/api/orders/{order_id}/cancel"))) + .bearer_auth(&token) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200, "cancel: {:?}", res.text().await); + + let (status, order_ref): (String, Option) = + sqlx::query_as("SELECT status::text, order_id FROM coupons WHERE id = $1") + .bind(Uuid::parse_str(&coupon_id).unwrap()) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(status, "claimed", "the coupon is claimable again"); + assert!(order_ref.is_none(), "and no longer bound to the order"); + + // Stock came back with it. + let stock: i32 = sqlx::query_scalar("SELECT stock FROM skus WHERE id = $1") + .bind(Uuid::parse_str(&sku).unwrap()) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(stock, 5); + + // The snapshot survived: the customer still holds it. + let held: i64 = + sqlx::query_scalar("SELECT count(*) FROM coupons WHERE user_id = $1 AND template_id = $2") + .bind(user) + .bind(Uuid::parse_str(&template).unwrap()) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(held, 1); +} diff --git a/apps/api/tests/order_service.rs b/apps/api/tests/order_service.rs index 9040740..b1b0940 100644 --- a/apps/api/tests/order_service.rs +++ b/apps/api/tests/order_service.rs @@ -73,7 +73,7 @@ async fn sellable_sku(state: &AppState, slug: &str, price_minor: i64, stock: i32 async fn checkout_rejects_empty_cart() { let state = common::spawn_state().await; let user_id = register_user(&state, "empty-cart").await; - let err = order::checkout(&state, user_id, address(), "USD".into()) + let err = order::checkout(&state, user_id, address(), "USD".into(), Default::default()) .await .unwrap_err(); assert!(matches!(err, ApiError::BadRequest(m) if m.contains("empty"))); @@ -107,7 +107,7 @@ async fn checkout_rejects_insufficient_stock() { cart::service::add_item(&state, user_id, sku_id, 2) .await .unwrap(); - let err = order::checkout(&state, user_id, address(), "USD".into()) + let err = order::checkout(&state, user_id, address(), "USD".into(), Default::default()) .await .unwrap_err(); assert!(matches!(err, ApiError::Conflict(m) if m.contains("insufficient stock"))); @@ -126,7 +126,7 @@ async fn checkout_splits_per_shop() { cart::service::add_item(&state, user_id, sku_b, 1) .await .unwrap(); - let orders = order::checkout(&state, user_id, address(), "USD".into()) + let orders = order::checkout(&state, user_id, address(), "USD".into(), Default::default()) .await .unwrap(); assert_eq!(orders.len(), 2); @@ -143,7 +143,7 @@ async fn pay_and_cancel_require_pending_payment() { cart::service::add_item(&state, user_id, sku_id, 1) .await .unwrap(); - let orders = order::checkout(&state, user_id, address(), "USD".into()) + let orders = order::checkout(&state, user_id, address(), "USD".into(), Default::default()) .await .unwrap(); let id = orders[0].order.id; diff --git a/apps/mall/mock/api.ts b/apps/mall/mock/api.ts index d6bbccc..7cadbad 100644 --- a/apps/mall/mock/api.ts +++ b/apps/mall/mock/api.ts @@ -12,6 +12,9 @@ import type { AuthTokens, Cart, CartItem, + Coupon, + CouponTemplate, + CouponTemplateInput, HomeContent, Invoice, InvoiceKind, @@ -26,6 +29,7 @@ import { MOCK_BANNERS, MOCK_BRANDS, MOCK_CATEGORIES, + MOCK_COUPONS, MOCK_CURRENCIES, MOCK_PROMOS, MOCK_QUICK_LINKS, @@ -48,6 +52,8 @@ interface MockState { shipments: Shipment[]; invoices: Invoice[]; addresses: AddressBookEntry[]; + /** In-memory only: claims made during this browser session. */ + coupons: Coupon[]; addressSeq: number; orderSeq: number; invoiceSeq: number; @@ -77,9 +83,51 @@ function loadPersisted(): PersistedState | null { } } +/** Mock coupons are global fixtures, so any shop id renders them as its own. */ +function mockTemplate(t: (typeof MOCK_COUPONS)[number], shopId: string): CouponTemplate { + return { + id: t.id, + shop_id: shopId, + title: t.title, + amount_minor: t.amountMinor, + threshold_minor: t.thresholdMinor, + currency: t.currency, + stock: 100, + enabled: true, + starts_at: "2026-01-01T00:00:00.000Z", + ends_at: `${t.expiresAt}T23:59:59.000Z`, + created_at: "2026-01-01T00:00:00.000Z", + }; +} + +function mockOwnedCoupon(t: (typeof MOCK_COUPONS)[number]): Coupon { + const template = mockTemplate(t, MOCK_STORES[0].id); + return { + id: `uc-${t.id}`, + user_id: MOCK_USER.id, + template_id: template.id, + shop_id: template.shop_id, + title: template.title, + amount_minor: template.amount_minor, + threshold_minor: template.threshold_minor, + currency: template.currency, + starts_at: template.starts_at, + ends_at: template.ends_at, + status: "claimed", + order_id: null, + claimed_at: "2026-01-01T00:00:00.000Z", + redeemed_at: null, + }; +} + +function seedCoupons(): Coupon[] { + return MOCK_COUPONS.map(mockOwnedCoupon); +} + function initialState(): MockState { const persisted = loadPersisted(); - if (persisted) return { token: null, ...persisted }; + // Coupons are session-only, so a restored snapshot re-seeds them. + if (persisted) return { token: null, ...persisted, coupons: seedCoupons() }; const seed = seedOrders(MOCK_USER.id); const seededAddresses: AddressBookEntry[] = MOCK_ADDRESSES.map((a, i) => ({ id: a.id, @@ -101,6 +149,7 @@ function initialState(): MockState { shipments: seed.shipments, invoices: seed.invoices, addresses: seededAddresses, + coupons: seedCoupons(), addressSeq: 100, orderSeq: 100, invoiceSeq: 100, @@ -261,7 +310,11 @@ export function createMockApi(): ApiClient { return Promise.resolve(cartSnapshot()); }, - checkout: (shippingAddress: Address, currency: string) => { + checkout: ( + shippingAddress: Address, + currency: string, + couponByShop: Record = {}, + ) => { if (state.cart.length === 0) { return Promise.reject(new ApiError(400, "EMPTY_CART", "Cart is empty")); } @@ -271,7 +324,23 @@ export function createMockApi(): ApiClient { const shopId = product?.shop_id ?? "unknown"; (byShop[shopId] ??= []).push(item); } - const created: Order[] = Object.entries(byShop).map(([shopId, items]) => { + const created: Order[] = []; + for (const [shopId, items] of Object.entries(byShop)) { + const subtotal = items.reduce((sum, i) => sum + i.unit_price_minor * i.qty, 0); + const couponId = couponByShop[shopId]; + let discount = 0; + if (couponId) { + const coupon = state.coupons.find((c) => c.id === couponId); + if (!coupon || coupon.status !== "claimed" || coupon.shop_id !== shopId) { + return Promise.reject(new ApiError(409, "CONFLICT", "Coupon is not redeemable")); + } + if (subtotal < coupon.threshold_minor) { + return Promise.reject( + new ApiError(409, "CONFLICT", "Order does not reach the coupon threshold"), + ); + } + discount = Math.min(coupon.amount_minor, subtotal); + } const order: Order = { id: `o-${Date.now()}-${shopId}`, order_no: nextOrderNo(), @@ -279,7 +348,9 @@ export function createMockApi(): ApiClient { user_id: MOCK_USER.id, status: "pending_payment", currency, - total_minor: items.reduce((sum, i) => sum + i.unit_price_minor * i.qty, 0), + total_minor: subtotal - discount, + discount_minor: discount, + coupon_id: couponId ?? null, items: items.map((i, k) => ({ id: `oi-${Date.now()}-${k}`, sku_id: i.sku_id, @@ -292,8 +363,16 @@ export function createMockApi(): ApiClient { shipping_address: shippingAddress, created_at: new Date().toISOString(), }; - return order; - }); + if (couponId) { + const coupon = state.coupons.find((c) => c.id === couponId); + if (coupon) { + coupon.status = "redeemed"; + coupon.order_id = order.id; + coupon.redeemed_at = order.created_at; + } + } + created.push(order); + } state.orders = [...created, ...state.orders]; state.cart = []; persist(); @@ -324,6 +403,12 @@ export function createMockApi(): ApiClient { return Promise.reject(new ApiError(409, "INVALID_STATE", "Only unpaid orders can be cancelled")); } order.status = "cancelled"; + const coupon = state.coupons.find((c) => c.order_id === order.id); + if (coupon) { + coupon.status = "claimed"; + coupon.order_id = null; + coupon.redeemed_at = null; + } persist(); return Promise.resolve(order); }, @@ -485,6 +570,23 @@ export function createMockApi(): ApiClient { return Promise.resolve({ ...entry }); }, + listShopCouponTemplates: (shopId: string) => + Promise.resolve(MOCK_COUPONS.map((t) => mockTemplate(t, shopId))), + + listMyCoupons: () => Promise.resolve(state.coupons.map((c) => ({ ...c }))), + + claimCoupon: (templateId: string) => { + const template = MOCK_COUPONS.find((t) => t.id === templateId); + if (!template) return Promise.reject(new ApiError(404, "NOT_FOUND", "Coupon not found")); + if (state.coupons.some((c) => c.template_id === templateId)) { + return Promise.reject(new ApiError(409, "CONFLICT", "Coupon already claimed")); + } + const coupon = mockOwnedCoupon(template); + state.coupons.push(coupon); + persist(); + return Promise.resolve({ ...coupon }); + }, + shop: { getMyShop: () => unsupported(), listMyProducts: () => unsupported(), @@ -501,6 +603,10 @@ export function createMockApi(): ApiClient { markShipped: () => unsupported(), listInvoices: () => unsupported(), issueInvoice: () => unsupported(), + listCouponTemplates: () => unsupported(), + createCouponTemplate: (_body: CouponTemplateInput) => unsupported(), + updateCouponTemplate: (_id: string, _body: CouponTemplateInput) => unsupported(), + deleteCouponTemplate: (_id: string) => unsupported(), }, admin: { listUsers: () => unsupported(), diff --git a/apps/mall/mock/data.ts b/apps/mall/mock/data.ts index 937db76..d88d844 100644 --- a/apps/mall/mock/data.ts +++ b/apps/mall/mock/data.ts @@ -703,6 +703,8 @@ export function seedOrders(userId: string): MockOrderSeed { status, currency: BASE_CURRENCY, total_minor: total, + discount_minor: 0, + coupon_id: null, items, shipping_address: defaultAddress(), created_at: createdAt, diff --git a/apps/mall/nuxt.config.ts b/apps/mall/nuxt.config.ts index 12bc8b6..caf06b4 100644 --- a/apps/mall/nuxt.config.ts +++ b/apps/mall/nuxt.config.ts @@ -9,7 +9,7 @@ export default defineNuxtConfig({ // Domains served by the live backend; every other domain stays on the // fixed-data adapter. Override with NUXT_PUBLIC_LIVE_DOMAINS='["catalog"]'. // See openspec/changes/replace-mock-api-wave-1/design.md and waves 2-3. - liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "account", "cart", "orders", "shipments", "invoices", "addresses"], + liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "account", "cart", "orders", "shipments", "invoices", "addresses", "coupons"], appName: "mall", }, }, diff --git a/apps/mall/plugins/api.ts b/apps/mall/plugins/api.ts index c47098b..b29d908 100644 --- a/apps/mall/plugins/api.ts +++ b/apps/mall/plugins/api.ts @@ -19,7 +19,8 @@ type LiveDomain = | "orders" | "shipments" | "invoices" - | "addresses"; + | "addresses" + | "coupons"; /** * Explicit per-domain method picks rather than a string allowlist: indexing @@ -66,6 +67,11 @@ const LIVE_PICKS = { deleteAddress: a.deleteAddress, setDefaultAddress: a.setDefaultAddress, }), + coupons: (a: ApiClient) => ({ + listShopCouponTemplates: a.listShopCouponTemplates, + listMyCoupons: a.listMyCoupons, + claimCoupon: a.claimCoupon, + }), } satisfies Record Partial>; const KNOWN_DOMAINS = Object.keys(LIVE_PICKS) as LiveDomain[]; @@ -84,6 +90,7 @@ const DEFAULT_LIVE_DOMAINS: LiveDomain[] = [ "shipments", "invoices", "addresses", + "coupons", ]; export default defineNuxtPlugin(() => { diff --git a/openspec/changes/add-shop-coupons/tasks.md b/openspec/changes/add-shop-coupons/tasks.md index fdf0962..7d4fa84 100644 --- a/openspec/changes/add-shop-coupons/tasks.md +++ b/openspec/changes/add-shop-coupons/tasks.md @@ -1,15 +1,15 @@ ## 1. Coupon domain and contract -- [ ] 1.1 Add additive Postgres migrations for coupon templates, owned coupon snapshots, order coupon binding, and realized discount minor values. -- [ ] 1.2 Implement the Rust coupon module (DTOs, repository, service, handlers, router registration) with customer and shop RBAC/ownership boundaries. -- [ ] 1.3 Add `@vmall/shared` coupon types, API-client methods, localized UI strings, and matching fixed-data adapter methods. +- [x] 1.1 Add additive Postgres migrations for coupon templates, owned coupon snapshots, order coupon binding, and realized discount minor values. +- [x] 1.2 Implement the Rust coupon module (DTOs, repository, service, handlers, router registration) with customer and shop RBAC/ownership boundaries. +- [x] 1.3 Add `@vmall/shared` coupon types, API-client methods, localized UI strings, and matching fixed-data adapter methods. ## 2. Atomic claims and checkout redemption -- [ ] 2.1 Implement conditional template stock claim and per-customer duplicate protection with valid-window checks. -- [ ] 2.2 Extend checkout request handling with per-shop coupon choices, server-side currency conversion and threshold validation, and persisted discounts. -- [ ] 2.3 Restore redeemed coupons atomically with stock when a pending-payment order is cancelled. -- [ ] 2.4 Add API integration coverage for claim contention, cross-shop/expired/ineligible rejection, multi-shop redemption, and cancellation restoration. +- [x] 2.1 Implement conditional template stock claim and per-customer duplicate protection with valid-window checks. +- [x] 2.2 Extend checkout request handling with per-shop coupon choices, server-side currency conversion and threshold validation, and persisted discounts. +- [x] 2.3 Restore redeemed coupons atomically with stock when a pending-payment order is cancelled. +- [x] 2.4 Add API integration coverage for claim contention, cross-shop/expired/ineligible rejection, multi-shop redemption, and cancellation restoration. ## 3. Merchant and customer surfaces @@ -20,4 +20,4 @@ ## 4. Verification and specification - [ ] 4.1 Seed deterministic coupon templates and verify the live claim-to-checkout-to-cancel flow against the API. -- [ ] 4.2 Run cargo test for vmall-api, builds for mall and shop-admin, and strict validation for this OpenSpec change. \ No newline at end of file +- [ ] 4.2 Run cargo test for vmall-api, builds for mall and shop-admin, and strict validation for this OpenSpec change. diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index ad4f767..5aad62f 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -9,6 +9,9 @@ import type { Category, ContentInputByKind, ContentKind, + Coupon, + CouponTemplate, + CouponTemplateInput, Currency, HomeContent, Invoice, @@ -164,7 +167,12 @@ export interface ApiClient { addCartItem(skuId: string, qty: number): Promise; updateCartItem(skuId: string, qty: number): Promise; removeCartItem(skuId: string): Promise; - checkout(shippingAddress: Address, currency: string): Promise; + checkout( + shippingAddress: Address, + currency: string, + /** At most one owned coupon per generated shop order. */ + couponByShop?: Record, + ): Promise; listMyOrders(page?: number): Promise>; getOrder(id: string): Promise; cancelOrder(id: string): Promise; @@ -188,6 +196,10 @@ export interface ApiClient { updateAddress(id: string, address: AddressInput): Promise; deleteAddress(id: string): Promise; setDefaultAddress(id: string): Promise; + /** Public: coupons a shopper could claim from this shop right now. */ + listShopCouponTemplates(shopId: string): Promise; + listMyCoupons(): Promise; + claimCoupon(templateId: string): Promise; shop: { getMyShop(): Promise; listMyProducts(q?: ShopProductQuery): Promise>; @@ -209,6 +221,10 @@ export interface ApiClient { markShipped(id: string): Promise; listInvoices(): Promise; issueInvoice(id: string): Promise; + listCouponTemplates(): Promise; + createCouponTemplate(body: CouponTemplateInput): Promise; + updateCouponTemplate(id: string, body: CouponTemplateInput): Promise; + deleteCouponTemplate(id: string): Promise; }; admin: { listUsers(page?: number): Promise>; @@ -250,8 +266,12 @@ export function createApi(opts: ApiClientOptions): ApiClient { addCartItem: (skuId, qty) => r("POST", "/cart/items", { sku_id: skuId, qty }), updateCartItem: (skuId, qty) => r("PUT", `/cart/items/${skuId}`, { qty }), removeCartItem: (skuId) => r("DELETE", `/cart/items/${skuId}`), - checkout: (shippingAddress, currency) => - r("POST", "/orders/checkout", { shipping_address: shippingAddress, currency }), + checkout: (shippingAddress, currency, couponByShop = {}) => + r("POST", "/orders/checkout", { + shipping_address: shippingAddress, + currency, + coupon_by_shop: couponByShop, + }), listMyOrders: (page = 1) => r("GET", "/orders", undefined, { page }), getOrder: (id) => r("GET", `/orders/${id}`), cancelOrder: (id) => r("POST", `/orders/${id}/cancel`), @@ -269,6 +289,9 @@ export function createApi(opts: ApiClientOptions): ApiClient { updateAddress: (id, address) => r("PUT", `/addresses/${id}`, address), deleteAddress: (id) => r("DELETE", `/addresses/${id}`), setDefaultAddress: (id) => r("POST", `/addresses/${id}/default`), + listShopCouponTemplates: (shopId) => r("GET", `/shops/${shopId}/coupon-templates`), + listMyCoupons: () => r("GET", "/me/coupons"), + claimCoupon: (templateId) => r("POST", "/me/coupons", { template_id: templateId }), shop: { getMyShop: () => r("GET", "/shop/profile"), listMyProducts: (q = {}) => r("GET", "/shop/products", undefined, { ...q }), @@ -290,6 +313,10 @@ export function createApi(opts: ApiClientOptions): ApiClient { markShipped: (id) => r("POST", `/shop/shipments/${id}/ship`), listInvoices: () => r("GET", "/shop/invoices"), issueInvoice: (id) => r("POST", `/shop/invoices/${id}/issue`), + listCouponTemplates: () => r("GET", "/shop/coupon-templates"), + createCouponTemplate: (body) => r("POST", "/shop/coupon-templates", body), + updateCouponTemplate: (id, body) => r("PUT", `/shop/coupon-templates/${id}`, body), + deleteCouponTemplate: (id) => r("DELETE", `/shop/coupon-templates/${id}`), }, admin: { listUsers: (page = 1) => r("GET", "/admin/users", undefined, { page }), diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index dbf72f0..7c2d24c 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -155,6 +155,10 @@ export interface Order { status: OrderStatus; currency: string; total_minor: number; + /** Server-calculated coupon discount, already applied to `total_minor`. */ + discount_minor: number; + /** The coupon this order redeemed, or null. */ + coupon_id: string | null; items: OrderItem[]; shipping_address: Address; created_at: string; @@ -195,9 +199,56 @@ export interface AddressBookEntry extends Address { created_at: string; } -export type ShipmentStatus = "pending" | "shipped" | "delivered"; +export type CouponStatus = "claimed" | "redeemed" | "expired"; -export interface Shipment { +/** Shop-issued coupon definition. Editing one never rewrites existing claims. */ +export interface CouponTemplate { + id: string; + shop_id: string; + title: LocalizedText; + amount_minor: number; + threshold_minor: number; + currency: string; + /** Remaining claim stock. */ + stock: number; + enabled: boolean; + starts_at: string; + ends_at: string; + created_at: string; +} + +/** Create/update body for a shop coupon template. */ +export interface CouponTemplateInput { + title: LocalizedText; + amount_minor: number; + threshold_minor: number; + currency: string; + stock: number; + enabled?: boolean; + starts_at: string; + ends_at: string; +} + +/** A customer-owned snapshot of the terms at claim time. */ +export interface Coupon { + id: string; + user_id: string; + /** Null once the issuing template is deleted; the snapshot still stands. */ + template_id: string | null; + shop_id: string; + title: LocalizedText; + amount_minor: number; + threshold_minor: number; + currency: string; + starts_at: string; + ends_at: string; + status: CouponStatus; + order_id: string | null; + claimed_at: string; + redeemed_at: string | null; +} + +export type ShipmentStatus = "pending" | "shipped" | "delivered";export interface Shipment { id: string; shipment_no: string; order_id: string;