From 23955434c6f116050031c864a811faacb5f7290e Mon Sep 17 00:00:00 2001 From: Zhang Chengdong Date: Fri, 18 Sep 2026 12:03:00 +0000 Subject: [PATCH] feat(api): shop coupons with per-shop checkout redemption Templates belong to a shop; claiming copies their terms into a customer-owned snapshot so a later edit or disable cannot rewrite a held coupon. Claim stock is taken with a guarded decrement after locking the template, and a unique (user, template) index makes a duplicate claim a 409. Deleting a template leaves claimed snapshots standing via ON DELETE SET NULL. Checkout accepts at most one owned coupon per generated shop order, locks the selected coupons by primary key after the SKU locks, and resolves eligibility and the discount server-side (ownership, shop, status, window, converted threshold). The realized discount and coupon id land on the order, and a pending-payment cancellation restores the coupon in the same transaction as stock. The shared contract gains the coupon types, claim/list/manage methods, and the checkout coupon map; the fixed-data adapter implements the same surface. Surfaces (shop-admin management, mall coupon pages, checkout selection) and seeding still follow in tasks 3.1-4.2. --- apps/api/migrations/0011_shop_coupons.sql | 51 +++ apps/api/src/models.rs | 57 +++ apps/api/src/modules/coupon/dto.rs | 21 ++ apps/api/src/modules/coupon/handlers.rs | 100 ++++++ apps/api/src/modules/coupon/mod.rs | 12 + apps/api/src/modules/coupon/repo.rs | 242 +++++++++++++ apps/api/src/modules/coupon/service.rs | 193 +++++++++++ apps/api/src/modules/mod.rs | 2 + apps/api/src/modules/order/handlers.rs | 13 +- apps/api/src/modules/order/repo.rs | 11 +- apps/api/src/modules/order/service.rs | 82 ++++- apps/api/tests/coupons.rs | 382 +++++++++++++++++++++ apps/api/tests/order_service.rs | 8 +- apps/mall/mock/api.ts | 118 ++++++- apps/mall/mock/data.ts | 2 + apps/mall/nuxt.config.ts | 2 +- apps/mall/plugins/api.ts | 9 +- openspec/changes/add-shop-coupons/tasks.md | 16 +- packages/shared/src/api.ts | 33 +- packages/shared/src/types.ts | 55 ++- 20 files changed, 1363 insertions(+), 46 deletions(-) create mode 100644 apps/api/migrations/0011_shop_coupons.sql create mode 100644 apps/api/src/modules/coupon/dto.rs create mode 100644 apps/api/src/modules/coupon/handlers.rs create mode 100644 apps/api/src/modules/coupon/mod.rs create mode 100644 apps/api/src/modules/coupon/repo.rs create mode 100644 apps/api/src/modules/coupon/service.rs create mode 100644 apps/api/tests/coupons.rs 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;