From 9c2b7058800571551a411b0dd4c8e57a18ecbbc5 Mon Sep 17 00:00:00 2001 From: Zhang Chengdong Date: Fri, 18 Sep 2026 12:24:46 +0000 Subject: [PATCH] feat(api): points mall with atomic redemption and admin fulfillment Platform-owned points products and a redemption order lifecycle kept separate from cash orders. Redeeming locks the published product, reserves stock, creates the order, debits points through the archived customer-accounts ledger with the order as reference, and snapshots the line in one transaction; a failure leaves no order, no stock change, and no ledger entry. Fulfilment moves only from pending_fulfillment, and customers see only their own redemptions. Demo seeding credits the demo customer through the same guarded credit path with reason seed, once, so no balance is ever written absolutely. Surfaces (admin console, mall points page) and product seeding follow. --- apps/api/migrations/0012_points_mall.sql | 52 ++++ apps/api/src/main.rs | 8 +- apps/api/src/models.rs | 59 ++++ apps/api/src/modules/account/repo.rs | 20 ++ apps/api/src/modules/account/service.rs | 21 ++ apps/api/src/modules/mod.rs | 2 + apps/api/src/modules/points/dto.rs | 38 +++ apps/api/src/modules/points/handlers.rs | 144 +++++++++ apps/api/src/modules/points/mod.rs | 12 + apps/api/src/modules/points/repo.rs | 269 +++++++++++++++++ apps/api/src/modules/points/service.rs | 189 ++++++++++++ apps/api/src/seed.rs | 50 ++++ apps/api/tests/points.rs | 347 ++++++++++++++++++++++ apps/mall/mock/api.ts | 97 +++++- apps/mall/nuxt.config.ts | 2 +- apps/mall/plugins/api.ts | 9 +- openspec/changes/add-points-mall/tasks.md | 10 +- packages/shared/src/api.ts | 19 ++ packages/shared/src/types.ts | 62 ++++ 19 files changed, 1400 insertions(+), 10 deletions(-) create mode 100644 apps/api/migrations/0012_points_mall.sql create mode 100644 apps/api/src/modules/points/dto.rs create mode 100644 apps/api/src/modules/points/handlers.rs create mode 100644 apps/api/src/modules/points/mod.rs create mode 100644 apps/api/src/modules/points/repo.rs create mode 100644 apps/api/src/modules/points/service.rs create mode 100644 apps/api/tests/points.rs diff --git a/apps/api/migrations/0012_points_mall.sql b/apps/api/migrations/0012_points_mall.sql new file mode 100644 index 0000000..ad003a6 --- /dev/null +++ b/apps/api/migrations/0012_points_mall.sql @@ -0,0 +1,52 @@ +-- Points mall: a platform-owned catalog of redeemable products plus a +-- redemption order lifecycle kept separate from cash orders and payments. +-- Redemptions snapshot the product and the shipping address; they never share +-- merchant SKU inventory. + +CREATE TYPE integral_order_status AS ENUM ('pending_fulfillment', 'fulfilled', 'cancelled'); + +CREATE SEQUENCE integral_order_no_seq; + +CREATE TABLE integral_products ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name JSONB NOT NULL, + subtitle JSONB, + content JSONB, + image TEXT, + points_price BIGINT NOT NULL CHECK (points_price > 0), + stock INT NOT NULL CHECK (stock >= 0), + published BOOLEAN NOT NULL DEFAULT FALSE, + recommend BOOLEAN NOT NULL DEFAULT FALSE, + position INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX integral_products_public_idx ON integral_products (published, position); + +CREATE TABLE integral_orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_no TEXT NOT NULL UNIQUE, + user_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + status integral_order_status NOT NULL DEFAULT 'pending_fulfillment', + total_points BIGINT NOT NULL CHECK (total_points > 0), + shipping_address JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX integral_orders_user_idx ON integral_orders (user_id, created_at DESC); +CREATE INDEX integral_orders_status_idx ON integral_orders (status); + +CREATE TABLE integral_order_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_id UUID NOT NULL REFERENCES integral_orders (id) ON DELETE CASCADE, + -- Nullable so a deleted catalog item keeps the redemption snapshot. + product_id UUID REFERENCES integral_products (id) ON DELETE SET NULL, + name JSONB NOT NULL, + image TEXT, + points_price BIGINT NOT NULL CHECK (points_price > 0), + qty INT NOT NULL CHECK (qty > 0) +); + +CREATE INDEX integral_order_items_order_idx ON integral_order_items (order_id); diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs index dc74bab..b4c4c4f 100644 --- a/apps/api/src/main.rs +++ b/apps/api/src/main.rs @@ -1,4 +1,9 @@ -use vmall_api::{build_router, config::Config, seed::ensure_platform_admin, state}; +use vmall_api::{ + build_router, + config::Config, + seed::{ensure_demo_points, ensure_platform_admin}, + state, +}; #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -14,6 +19,7 @@ async fn main() -> anyhow::Result<()> { sqlx::migrate!("./migrations").run(&db).await?; let state = state::assemble(config.clone(), db).await?; ensure_platform_admin(&state).await?; + ensure_demo_points(&state).await?; let app = build_router(state); let listener = tokio::net::TcpListener::bind(("0.0.0.0", config.port)).await?; diff --git a/apps/api/src/models.rs b/apps/api/src/models.rs index cd40a38..a127890 100644 --- a/apps/api/src/models.rs +++ b/apps/api/src/models.rs @@ -343,6 +343,65 @@ pub struct Coupon { 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"; +/// Redemption lifecycle; separate from cash orders. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] +#[sqlx(type_name = "integral_order_status", rename_all = "snake_case")] +#[serde(rename_all = "snake_case")] +pub enum IntegralOrderStatus { + PendingFulfillment, + Fulfilled, + Cancelled, +} + +/// Platform-owned catalog item redeemable for points. Never a SKU. +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct IntegralProduct { + pub id: Uuid, + pub name: serde_json::Value, + pub subtitle: Option, + pub content: Option, + pub image: Option, + pub points_price: i64, + pub stock: i32, + pub published: bool, + pub recommend: bool, + pub position: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub const INTEGRAL_PRODUCT_COLUMNS: &str = "id, name, subtitle, content, image, points_price, \ + stock, published, recommend, position, created_at, updated_at"; + +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct IntegralOrder { + pub id: Uuid, + pub order_no: String, + pub user_id: Uuid, + pub status: IntegralOrderStatus, + pub total_points: i64, + pub shipping_address: serde_json::Value, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub const INTEGRAL_ORDER_COLUMNS: &str = + "id, order_no, user_id, status, total_points, shipping_address, created_at, updated_at"; + +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct IntegralOrderItem { + pub id: Uuid, + pub order_id: Uuid, + pub product_id: Option, + pub name: serde_json::Value, + pub image: Option, + pub points_price: i64, + pub qty: i32, +} + +pub const INTEGRAL_ORDER_ITEM_COLUMNS: &str = + "id, order_id, product_id, name, image, points_price, qty"; + #[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub struct AddressBookEntry { pub id: Uuid, diff --git a/apps/api/src/modules/account/repo.rs b/apps/api/src/modules/account/repo.rs index 43bd9fe..cfbefcd 100644 --- a/apps/api/src/modules/account/repo.rs +++ b/apps/api/src/modules/account/repo.rs @@ -110,6 +110,26 @@ pub async fn credit_atomic( /// Append the audit fact for a balance update that already happened in `tx`. #[allow(clippy::too_many_arguments)] +/// Whether any account for this user already carries an entry with `reason`. +/// Used by demo seeding to stay idempotent without reading a balance. +pub async fn has_entry_with_reason<'e, E: PgExecutor<'e>>( + exec: E, + user_id: Uuid, + reason: &str, +) -> ApiResult { + Ok(sqlx::query_scalar( + "SELECT EXISTS( + SELECT 1 FROM customer_account_entries e + JOIN customer_accounts a ON a.id = e.account_id + WHERE a.user_id = $1 AND e.reason = $2 + )", + ) + .bind(user_id) + .bind(reason) + .fetch_one(exec) + .await?) +} + pub async fn insert_entry( tx: &mut PgConnection, account_id: Uuid, diff --git a/apps/api/src/modules/account/service.rs b/apps/api/src/modules/account/service.rs index ab66fbe..c79408c 100644 --- a/apps/api/src/modules/account/service.rs +++ b/apps/api/src/modules/account/service.rs @@ -62,6 +62,27 @@ pub async fn debit( append(tx, account.id, -amount, updated.balance_minor, reason, reference).await } +/// Credit once per `reason`, in its own transaction. Demo seeding uses this so +/// a restart cannot grant twice and no balance is ever written absolutely. +pub async fn credit_once( + state: &AppState, + user_id: Uuid, + kind: AccountKind, + currency: Option<&str>, + amount_minor: i64, + reason: &str, +) -> ApiResult> { + let mut tx = state.db.begin().await?; + ensure_accounts(&mut tx, user_id).await?; + if repo::has_entry_with_reason(&mut *tx, user_id, reason).await? { + tx.commit().await?; + return Ok(None); + } + let entry = credit(&mut tx, user_id, kind, currency, amount_minor, reason, None).await?; + tx.commit().await?; + Ok(Some(entry)) +} + /// Move money from `available` to `frozen`; returns (available, frozen) entries. pub async fn freeze( tx: &mut PgConnection, diff --git a/apps/api/src/modules/mod.rs b/apps/api/src/modules/mod.rs index 5784ffd..aa8fc44 100644 --- a/apps/api/src/modules/mod.rs +++ b/apps/api/src/modules/mod.rs @@ -10,6 +10,7 @@ pub mod fulfillment; pub mod health; pub mod identity; pub mod order; +pub mod points; pub mod shop; use axum::Router; @@ -28,6 +29,7 @@ pub fn api_router() -> Router { .merge(cart::router()) .merge(coupon::router()) .merge(order::router()) + .merge(points::router()) .merge(shop::router()) .merge(fulfillment::router()) .merge(billing::router()) diff --git a/apps/api/src/modules/points/dto.rs b/apps/api/src/modules/points/dto.rs new file mode 100644 index 0000000..ccc4afa --- /dev/null +++ b/apps/api/src/modules/points/dto.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::models::{IntegralOrder, IntegralOrderItem}; + +/// Platform-admin create/update body for a points product. +#[derive(Deserialize)] +pub struct IntegralProductInput { + pub name: serde_json::Value, + #[serde(default)] + pub subtitle: Option, + #[serde(default)] + pub content: Option, + #[serde(default)] + pub image: Option, + pub points_price: i64, + pub stock: i32, + #[serde(default)] + pub published: bool, + #[serde(default)] + pub recommend: bool, + #[serde(default)] + pub position: i32, +} + +#[derive(Deserialize)] +pub struct RedeemInput { + pub product_id: Uuid, + pub qty: i32, + pub shipping_address: crate::modules::order::AddressBody, +} + +#[derive(Debug, Serialize)] +pub struct RedemptionView { + #[serde(flatten)] + pub order: IntegralOrder, + pub items: Vec, +} diff --git a/apps/api/src/modules/points/handlers.rs b/apps/api/src/modules/points/handlers.rs new file mode 100644 index 0000000..420d2fb --- /dev/null +++ b/apps/api/src/modules/points/handlers.rs @@ -0,0 +1,144 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + routing::{get, post, put}, + Json, Router, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::error::ApiResult; +use crate::http::Paged; +use crate::models::{IntegralOrderStatus, IntegralProduct}; +use crate::state::AppState; + +use super::dto::{IntegralProductInput, RedeemInput, RedemptionView}; +use super::service; + +pub fn router() -> Router { + Router::new() + // Public catalog; redemption and history are customer-scoped. + .route("/points/products", get(list_published)) + .route("/points/redemptions", get(list_mine).post(redeem)) + .route( + "/admin/points/products", + get(admin_list).post(admin_create), + ) + .route("/admin/points/products/{id}", put(admin_update)) + .route("/admin/points/products/{id}/publish", post(admin_publish)) + .route("/admin/points/products/{id}/unpublish", post(admin_unpublish)) + .route("/admin/points/orders", get(admin_orders)) + .route("/admin/points/orders/{id}/fulfill", post(admin_fulfill)) + .route("/admin/points/orders/{id}/cancel", post(admin_cancel)) +} + +async fn list_published( + State(state): State, +) -> ApiResult>> { + Ok(Json(service::list_published(&state).await?)) +} + +async fn list_mine( + State(state): State, + auth: AuthUser, +) -> ApiResult>> { + auth.require_customer()?; + Ok(Json(service::list_mine(&state, auth.id).await?)) +} + +async fn redeem( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + auth.require_customer()?; + Ok(( + StatusCode::CREATED, + Json(service::redeem(&state, auth.id, body).await?), + )) +} + +async fn admin_list( + State(state): State, + auth: AuthUser, +) -> ApiResult>> { + auth.require_admin()?; + Ok(Json(service::admin_list_products(&state).await?)) +} + +async fn admin_create( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + auth.require_admin()?; + Ok(( + StatusCode::CREATED, + Json(service::admin_create_product(&state, body).await?), + )) +} + +async fn admin_update( + State(state): State, + auth: AuthUser, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json(service::admin_update_product(&state, id, body).await?)) +} + +async fn admin_publish( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json(service::admin_set_published(&state, id, true).await?)) +} + +async fn admin_unpublish( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json(service::admin_set_published(&state, id, false).await?)) +} + +#[derive(Deserialize)] +struct RedemptionQuery { + page: Option, + per_page: Option, + status: Option, +} + +async fn admin_orders( + State(state): State, + auth: AuthUser, + Query(q): Query, +) -> ApiResult>> { + auth.require_admin()?; + Ok(Json( + service::admin_list_orders(&state, q.status, q.page, q.per_page).await?, + )) +} + +async fn admin_fulfill( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json(service::admin_fulfill(&state, id).await?)) +} + +async fn admin_cancel( + State(state): State, + auth: AuthUser, + Path(id): Path, +) -> ApiResult> { + auth.require_admin()?; + Ok(Json(service::admin_cancel(&state, id).await?)) +} diff --git a/apps/api/src/modules/points/mod.rs b/apps/api/src/modules/points/mod.rs new file mode 100644 index 0000000..2cdf8fe --- /dev/null +++ b/apps/api/src/modules/points/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/points/repo.rs b/apps/api/src/modules/points/repo.rs new file mode 100644 index 0000000..dd3a1cd --- /dev/null +++ b/apps/api/src/modules/points/repo.rs @@ -0,0 +1,269 @@ +use std::collections::HashMap; + +use sqlx::{PgConnection, PgExecutor, PgPool}; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::http::{clamp_page, clamp_per_page, Paged}; +use crate::models::{ + IntegralOrder, IntegralOrderItem, IntegralOrderStatus, IntegralProduct, + INTEGRAL_ORDER_COLUMNS, INTEGRAL_ORDER_ITEM_COLUMNS, INTEGRAL_PRODUCT_COLUMNS, +}; + +use super::dto::{IntegralProductInput, RedemptionView}; + +// ---- catalog ---- + +pub async fn list_published<'e, E: PgExecutor<'e>>(exec: E) -> ApiResult> { + Ok(sqlx::query_as::<_, IntegralProduct>(&format!( + "SELECT {INTEGRAL_PRODUCT_COLUMNS} FROM integral_products + WHERE published = TRUE + ORDER BY position, created_at" + )) + .fetch_all(exec) + .await?) +} + +pub async fn list_all<'e, E: PgExecutor<'e>>(exec: E) -> ApiResult> { + Ok(sqlx::query_as::<_, IntegralProduct>(&format!( + "SELECT {INTEGRAL_PRODUCT_COLUMNS} FROM integral_products + ORDER BY position, created_at" + )) + .fetch_all(exec) + .await?) +} + +/// Lock a published product for redemption. An unpublished or missing product +/// is a 404 so customers cannot probe the draft catalog. +pub async fn lock_published( + tx: &mut PgConnection, + id: Uuid, +) -> ApiResult { + sqlx::query_as::<_, IntegralProduct>(&format!( + "SELECT {INTEGRAL_PRODUCT_COLUMNS} FROM integral_products + WHERE id = $1 AND published = TRUE + FOR UPDATE" + )) + .bind(id) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::NotFound("points product".into())) +} + +pub async fn insert_product( + tx: &mut PgConnection, + body: &IntegralProductInput, +) -> ApiResult { + Ok(sqlx::query_as::<_, IntegralProduct>(&format!( + "INSERT INTO integral_products + (name, subtitle, content, image, points_price, stock, published, recommend, position) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING {INTEGRAL_PRODUCT_COLUMNS}" + )) + .bind(&body.name) + .bind(&body.subtitle) + .bind(&body.content) + .bind(&body.image) + .bind(body.points_price) + .bind(body.stock) + .bind(body.published) + .bind(body.recommend) + .bind(body.position) + .fetch_one(&mut *tx) + .await?) +} + +pub async fn update_product( + tx: &mut PgConnection, + id: Uuid, + body: &IntegralProductInput, +) -> ApiResult { + sqlx::query_as::<_, IntegralProduct>(&format!( + "UPDATE integral_products + SET name = $2, subtitle = $3, content = $4, image = $5, points_price = $6, + stock = $7, published = $8, recommend = $9, position = $10, updated_at = now() + WHERE id = $1 + RETURNING {INTEGRAL_PRODUCT_COLUMNS}" + )) + .bind(id) + .bind(&body.name) + .bind(&body.subtitle) + .bind(&body.content) + .bind(&body.image) + .bind(body.points_price) + .bind(body.stock) + .bind(body.published) + .bind(body.recommend) + .bind(body.position) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::NotFound("points product".into())) +} + +pub async fn set_published( + tx: &mut PgConnection, + id: Uuid, + published: bool, +) -> ApiResult { + sqlx::query_as::<_, IntegralProduct>(&format!( + "UPDATE integral_products SET published = $2, updated_at = now() + WHERE id = $1 + RETURNING {INTEGRAL_PRODUCT_COLUMNS}" + )) + .bind(id) + .bind(published) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::NotFound("points product".into())) +} + +/// Guarded counter decrement, same shape as SKU stock. +pub async fn decrement_stock(tx: &mut PgConnection, id: Uuid, qty: i32) -> ApiResult<()> { + let result = sqlx::query( + "UPDATE integral_products SET stock = stock - $2, updated_at = now() + WHERE id = $1 AND stock >= $2", + ) + .bind(id) + .bind(qty) + .execute(&mut *tx) + .await?; + if result.rows_affected() == 0 { + return Err(ApiError::Conflict("insufficient points stock".into())); + } + Ok(()) +} + +// ---- redemption orders ---- + +pub async fn insert_order( + tx: &mut PgConnection, + user_id: Uuid, + total_points: i64, + address: &serde_json::Value, +) -> ApiResult { + Ok(sqlx::query_as::<_, IntegralOrder>(&format!( + "INSERT INTO integral_orders (order_no, user_id, total_points, shipping_address) + VALUES ('PM' || to_char(now(), 'YYMMDD') + || lpad(nextval('integral_order_no_seq')::text, 6, '0'), + $1, $2, $3) + RETURNING {INTEGRAL_ORDER_COLUMNS}" + )) + .bind(user_id) + .bind(total_points) + .bind(address) + .fetch_one(&mut *tx) + .await?) +} + +pub async fn insert_item( + tx: &mut PgConnection, + order_id: Uuid, + product: &IntegralProduct, + qty: i32, +) -> ApiResult<()> { + sqlx::query( + "INSERT INTO integral_order_items (order_id, product_id, name, image, points_price, qty) + VALUES ($1, $2, $3, $4, $5, $6)", + ) + .bind(order_id) + .bind(product.id) + .bind(&product.name) + .bind(&product.image) + .bind(product.points_price) + .bind(qty) + .execute(&mut *tx) + .await?; + Ok(()) +} + +pub async fn list_for_user<'e, E: PgExecutor<'e>>( + exec: E, + user_id: Uuid, +) -> ApiResult> { + Ok(sqlx::query_as::<_, IntegralOrder>(&format!( + "SELECT {INTEGRAL_ORDER_COLUMNS} FROM integral_orders + WHERE user_id = $1 ORDER BY created_at DESC" + )) + .bind(user_id) + .fetch_all(exec) + .await?) +} + +pub async fn list_all_page( + db: &PgPool, + status: Option, + page: Option, + per_page: Option, +) -> ApiResult> { + let page = clamp_page(page); + let per_page = clamp_per_page(per_page); + let total: i64 = sqlx::query_scalar( + "SELECT count(*) FROM integral_orders + WHERE ($1::integral_order_status IS NULL OR status = $1)", + ) + .bind(status) + .fetch_one(db) + .await?; + let items = sqlx::query_as::<_, IntegralOrder>(&format!( + "SELECT {INTEGRAL_ORDER_COLUMNS} FROM integral_orders + WHERE ($1::integral_order_status IS NULL OR status = $1) + ORDER BY created_at DESC LIMIT $2 OFFSET $3" + )) + .bind(status) + .bind(per_page) + .bind((page - 1) * per_page) + .fetch_all(db) + .await?; + Ok(Paged { + items, + total, + page, + per_page, + }) +} + +/// Lock an order and move it only from the expected status. +pub async fn transition( + tx: &mut PgConnection, + id: Uuid, + from: IntegralOrderStatus, + to: IntegralOrderStatus, +) -> ApiResult { + sqlx::query_as::<_, IntegralOrder>(&format!( + "UPDATE integral_orders SET status = $3, updated_at = now() + WHERE id = $1 AND status = $2 + RETURNING {INTEGRAL_ORDER_COLUMNS}" + )) + .bind(id) + .bind(from) + .bind(to) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| ApiError::Conflict("redemption order is not in the expected state".into())) +} + +pub async fn attach_items(db: &PgPool, orders: Vec) -> ApiResult> { + let ids: Vec = orders.iter().map(|o| o.id).collect(); + let items = if ids.is_empty() { + Vec::new() + } else { + sqlx::query_as::<_, IntegralOrderItem>(&format!( + "SELECT {INTEGRAL_ORDER_ITEM_COLUMNS} FROM integral_order_items + WHERE order_id = ANY($1) ORDER BY id" + )) + .bind(&ids) + .fetch_all(db) + .await? + }; + let mut by_order: HashMap> = HashMap::new(); + for item in items { + by_order.entry(item.order_id).or_default().push(item); + } + Ok(orders + .into_iter() + .map(|order| RedemptionView { + items: by_order.remove(&order.id).unwrap_or_default(), + order, + }) + .collect()) +} diff --git a/apps/api/src/modules/points/service.rs b/apps/api/src/modules/points/service.rs new file mode 100644 index 0000000..6394d9d --- /dev/null +++ b/apps/api/src/modules/points/service.rs @@ -0,0 +1,189 @@ +use serde_json::Value; +use uuid::Uuid; + +use crate::error::{ApiError, ApiResult}; +use crate::http::Paged; +use crate::models::{AccountKind, IntegralOrderStatus, IntegralProduct}; +use crate::modules::account; +use crate::state::AppState; + +use super::dto::{IntegralProductInput, RedeemInput, RedemptionView}; +use super::repo; + +/// Ledger reason recorded for a points spend. +pub const REDEMPTION_REASON: &str = "integral_redemption"; + +// ---- catalog ---- + +pub async fn list_published(state: &AppState) -> ApiResult> { + repo::list_published(&state.db).await +} + +pub async fn admin_list_products(state: &AppState) -> ApiResult> { + repo::list_all(&state.db).await +} + +pub async fn admin_create_product( + state: &AppState, + body: IntegralProductInput, +) -> ApiResult { + validate_product(&body)?; + let mut tx = state.db.begin().await?; + let product = repo::insert_product(&mut tx, &body).await?; + tx.commit().await?; + Ok(product) +} + +pub async fn admin_update_product( + state: &AppState, + id: Uuid, + body: IntegralProductInput, +) -> ApiResult { + validate_product(&body)?; + let mut tx = state.db.begin().await?; + let product = repo::update_product(&mut tx, id, &body).await?; + tx.commit().await?; + Ok(product) +} + +pub async fn admin_set_published( + state: &AppState, + id: Uuid, + published: bool, +) -> ApiResult { + let mut tx = state.db.begin().await?; + let product = repo::set_published(&mut tx, id, published).await?; + tx.commit().await?; + Ok(product) +} + +// ---- redemption ---- + +/// Redeem points atomically: lock the published product, reserve stock, create +/// the order, debit the points ledger with the order as reference, and snapshot +/// the line. A failure leaves no order, no stock change, and no entry. +pub async fn redeem( + state: &AppState, + user_id: Uuid, + body: RedeemInput, +) -> ApiResult { + body.shipping_address.validate()?; + if body.qty <= 0 { + return Err(ApiError::BadRequest("qty must be positive".into())); + } + let mut tx = state.db.begin().await?; + let product = repo::lock_published(&mut tx, body.product_id).await?; + if body.qty > product.stock { + return Err(ApiError::Conflict("insufficient points stock".into())); + } + let total_points = product + .points_price + .checked_mul(body.qty as i64) + .ok_or_else(|| ApiError::BadRequest("quantity is too large".into()))?; + + repo::decrement_stock(&mut tx, product.id, body.qty).await?; + let address = serde_json::to_value(&body.shipping_address).map_err(ApiError::internal)?; + let order = repo::insert_order(&mut tx, user_id, total_points, &address).await?; + // Points are never written directly: the archived account service debits + // with a guard and appends the matching entry. + account::service::debit( + &mut tx, + user_id, + AccountKind::Points, + None, + total_points, + REDEMPTION_REASON, + Some(("integral_order", order.id)), + ) + .await?; + repo::insert_item(&mut tx, order.id, &product, body.qty).await?; + tx.commit().await?; + + let mut views = repo::attach_items(&state.db, vec![order]).await?; + Ok(views.remove(0)) +} + +pub async fn list_mine(state: &AppState, user_id: Uuid) -> ApiResult> { + let orders = repo::list_for_user(&state.db, user_id).await?; + repo::attach_items(&state.db, orders).await +} + +pub async fn admin_list_orders( + state: &AppState, + status: Option, + page: Option, + per_page: Option, +) -> ApiResult> { + let page = repo::list_all_page(&state.db, status, page, per_page).await?; + let items = repo::attach_items(&state.db, page.items).await?; + Ok(Paged { + items, + total: page.total, + page: page.page, + per_page: page.per_page, + }) +} + +pub async fn admin_fulfill(state: &AppState, id: Uuid) -> ApiResult { + transition( + state, + id, + IntegralOrderStatus::PendingFulfillment, + IntegralOrderStatus::Fulfilled, + ) + .await +} + +/// Platform-only for now: a customer-initiated return needs a refund flow. +pub async fn admin_cancel(state: &AppState, id: Uuid) -> ApiResult { + transition( + state, + id, + IntegralOrderStatus::PendingFulfillment, + IntegralOrderStatus::Cancelled, + ) + .await +} + +async fn transition( + state: &AppState, + id: Uuid, + from: IntegralOrderStatus, + to: IntegralOrderStatus, +) -> ApiResult { + let mut tx = state.db.begin().await?; + let order = repo::transition(&mut tx, id, from, to).await?; + tx.commit().await?; + let mut views = repo::attach_items(&state.db, vec![order]).await?; + Ok(views.remove(0)) +} + +// ---- validation ---- + +fn validate_product(body: &IntegralProductInput) -> ApiResult<()> { + bilingual(&body.name, "name")?; + if body.points_price <= 0 { + return Err(ApiError::BadRequest( + "points_price must be positive".into(), + )); + } + if body.stock < 0 { + return Err(ApiError::BadRequest("stock must not be negative".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/seed.rs b/apps/api/src/seed.rs index 35ab6af..47accd1 100644 --- a/apps/api/src/seed.rs +++ b/apps/api/src/seed.rs @@ -1,4 +1,8 @@ +use uuid::Uuid; + use crate::auth::hash_password; +use crate::models::AccountKind; +use crate::modules::account; use crate::state::AppState; /// Idempotent dev seed: platform admin account. @@ -24,3 +28,49 @@ pub async fn ensure_platform_admin(state: &AppState) -> anyhow::Result<()> { tracing::info!("seeded platform admin admin@vmall.local"); Ok(()) } + +pub const DEMO_CUSTOMER_EMAIL: &str = "customer@vmall.local"; +pub const DEMO_CUSTOMER_PASSWORD: &str = "customer123"; +/// Spendable demo points, enough for the cheapest seeded points product. +pub const DEMO_POINTS: i64 = 5_000; +/// Ledger reason for the demo grant; doubles as the idempotency key. +pub const DEMO_POINTS_REASON: &str = "seed"; + +/// Idempotent dev seed: the demo customer plus a spendable points balance. +/// The grant runs through the account service, so it is one append-only entry +/// with reason `seed` - never an absolute balance write - and a restart cannot +/// grant it twice. +pub async fn ensure_demo_points(state: &AppState) -> anyhow::Result<()> { + let user_id: Uuid = match sqlx::query_scalar("SELECT id FROM users WHERE email = $1") + .bind(DEMO_CUSTOMER_EMAIL) + .fetch_optional(&state.db) + .await? + { + Some(id) => id, + None => { + let hash = hash_password(DEMO_CUSTOMER_PASSWORD)?; + sqlx::query_scalar( + "INSERT INTO users (email, password_hash, display_name, role) + VALUES ($1, $2, 'Demo Customer', 'customer') RETURNING id", + ) + .bind(DEMO_CUSTOMER_EMAIL) + .bind(hash) + .fetch_one(&state.db) + .await? + } + }; + + let credited = account::service::credit_once( + state, + user_id, + AccountKind::Points, + None, + DEMO_POINTS, + DEMO_POINTS_REASON, + ) + .await?; + if credited.is_some() { + tracing::info!(points = DEMO_POINTS, "seeded demo customer points"); + } + Ok(()) +} diff --git a/apps/api/tests/points.rs b/apps/api/tests/points.rs new file mode 100644 index 0000000..25ed79d --- /dev/null +++ b/apps/api/tests/points.rs @@ -0,0 +1,347 @@ +mod common; + +use common::{client, login_admin, register_customer, spawn_app, TestApp}; +use serial_test::serial; +use uuid::Uuid; +use vmall_api::models::AccountKind; +use vmall_api::modules::account::service as accounts; +use vmall_api::state::AppState; + +fn address() -> serde_json::Value { + serde_json::json!({ + "recipient": "Test Recipient", + "phone": "123456", + "country": "US", + "region": "CA", + "city": "San Jose", + "line1": "1 Test Way", + "postal_code": "95131" + }) +} + +async fn create_product( + app: &TestApp, + admin: &str, + points_price: i64, + stock: i32, + published: bool, +) -> serde_json::Value { + let res = client() + .post(app.url("/api/admin/points/products")) + .bearer_auth(admin) + .json(&serde_json::json!({ + "name": {"en": "Reward Mug", "zh": "积分杯"}, + "points_price": points_price, + "stock": stock, + "published": published, + })) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 201, "create product: {:?}", res.text().await); + res.json().await.unwrap() +} + +async fn credit_points(state: &AppState, user_id: Uuid, amount: i64) { + let mut tx = state.db.begin().await.unwrap(); + accounts::credit( + &mut tx, + user_id, + AccountKind::Points, + None, + amount, + "test_credit", + None, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); +} + +async fn redeem( + app: &TestApp, + token: &str, + product_id: &str, + qty: i32, +) -> reqwest::Response { + client() + .post(app.url("/api/points/redemptions")) + .bearer_auth(token) + .json(&serde_json::json!({ + "product_id": product_id, + "qty": qty, + "shipping_address": address(), + })) + .send() + .await + .unwrap() +} + +fn uuid(id: &str) -> Uuid { + Uuid::parse_str(id).unwrap() +} + +async fn points_balance(app: &TestApp, user_id: Uuid) -> i64 { + sqlx::query_scalar( + "SELECT balance_minor FROM customer_accounts + WHERE user_id = $1 AND kind = 'points'", + ) + .bind(user_id) + .fetch_one(&app.db) + .await + .unwrap() +} + +async fn stock_of(app: &TestApp, product_id: &str) -> i32 { + sqlx::query_scalar("SELECT stock FROM integral_products WHERE id = $1") + .bind(uuid(product_id)) + .fetch_one(&app.db) + .await + .unwrap() +} + +#[tokio::test] +#[serial] +async fn unpublished_product_is_hidden_and_not_redeemable() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let draft = create_product(&app, &admin, 300, 5, false).await; + let id = draft["id"].as_str().unwrap().to_string(); + + let (token, user_id) = register_customer(&app, "pm-draft").await; + credit_points(&app.state, uuid(&user_id), 1_000).await; + + // Absent from the public catalog. + let res = client() + .get(app.url("/api/points/products")) + .send() + .await + .unwrap(); + let catalog: Vec = res.json().await.unwrap(); + assert!(catalog.iter().all(|p| p["id"] != id.as_str())); + + // And not redeemable. + let res = redeem(&app, &token, &id, 1).await; + assert_eq!(res.status(), 404, "a draft product cannot be redeemed"); + assert_eq!(stock_of(&app, &id).await, 5, "and no stock moved"); + + // Publishing exposes it. + let res = client() + .post(app.url(&format!("/api/admin/points/products/{id}/publish"))) + .bearer_auth(&admin) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let res = client() + .get(app.url("/api/points/products")) + .send() + .await + .unwrap(); + let catalog: Vec = res.json().await.unwrap(); + assert!(catalog.iter().any(|p| p["id"] == id.as_str())); +} + +#[tokio::test] +#[serial] +async fn insufficient_points_creates_nothing() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let product = create_product(&app, &admin, 1_000, 5, true).await; + let id = product["id"].as_str().unwrap().to_string(); + + let (token, user_id) = register_customer(&app, "pm-poor").await; + let user = uuid(&user_id); + credit_points(&app.state, user, 500).await; + + let res = redeem(&app, &token, &id, 1).await; + assert_eq!(res.status(), 409, "points are short"); + + // One transaction: no order, no stock change, no ledger movement. + let orders: i64 = sqlx::query_scalar("SELECT count(*) FROM integral_orders WHERE user_id = $1") + .bind(user) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(orders, 0); + assert_eq!(stock_of(&app, &id).await, 5); + assert_eq!(points_balance(&app, user).await, 500); + + // The failed attempt left no ledger entry either. + let entries: i64 = sqlx::query_scalar( + "SELECT count(*) FROM customer_account_entries e + JOIN customer_accounts a ON a.id = e.account_id + WHERE a.user_id = $1 AND e.reason = 'integral_redemption'", + ) + .bind(user) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(entries, 0); +} + +#[tokio::test] +#[serial] +async fn final_stock_admits_one_redeemer() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let product = create_product(&app, &admin, 100, 1, true).await; + let id = product["id"].as_str().unwrap().to_string(); + + let (token_a, user_a) = register_customer(&app, "pm-race-a").await; + let (token_b, user_b) = register_customer(&app, "pm-race-b").await; + credit_points(&app.state, uuid(&user_a), 1_000).await; + credit_points(&app.state, uuid(&user_b), 1_000).await; + + let (a, b) = tokio::join!( + redeem(&app, &token_a, &id, 1), + redeem(&app, &token_b, &id, 1), + ); + let wins = [a.status(), b.status()] + .iter() + .filter(|s| s.is_success()) + .count(); + assert_eq!(wins, 1, "exactly one redemption fits the last stock"); + let loser = if a.status().is_success() { b } else { a }; + assert_eq!(loser.status(), 409); + + assert_eq!(stock_of(&app, &id).await, 0, "no negative stock is stored"); + let orders: i64 = sqlx::query_scalar( + "SELECT count(*) FROM integral_orders o + JOIN integral_order_items i ON i.order_id = o.id + WHERE i.product_id = $1", + ) + .bind(uuid(&id)) + .fetch_one(&app.db) + .await + .unwrap(); + assert_eq!(orders, 1); +} + +#[tokio::test] +#[serial] +async fn history_is_owner_scoped_and_admin_visible() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let product = create_product(&app, &admin, 200, 5, true).await; + let id = product["id"].as_str().unwrap().to_string(); + + let (token_a, user_a) = register_customer(&app, "pm-own-a").await; + let (token_b, _user_b) = register_customer(&app, "pm-own-b").await; + credit_points(&app.state, uuid(&user_a), 1_000).await; + + let res = redeem(&app, &token_a, &id, 2).await; + assert_eq!(res.status(), 201, "redeem: {:?}", res.text().await); + let created: serde_json::Value = res.json().await.unwrap(); + assert_eq!(created["total_points"], 400, "2 x 200"); + assert_eq!(created["status"], "pending_fulfillment"); + assert_eq!(created["items"][0]["qty"], 2); + assert_eq!(created["items"][0]["points_price"], 200); + + let mine: Vec = client() + .get(app.url("/api/points/redemptions")) + .bearer_auth(&token_a) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(mine.len(), 1); + + let theirs: Vec = client() + .get(app.url("/api/points/redemptions")) + .bearer_auth(&token_b) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(theirs.is_empty(), "another customer sees no redemptions"); + + // The balance reflects the spend. + assert_eq!(points_balance(&app, uuid(&user_a)).await, 600); + + // Admin sees it, and a customer cannot reach the admin surface. + let page: serde_json::Value = client() + .get(app.url("/api/admin/points/orders")) + .bearer_auth(&admin) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(page["items"].as_array().unwrap().len() >= 1); + + let res = client() + .get(app.url("/api/admin/points/orders")) + .bearer_auth(&token_a) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 403, "customer is not a platform admin"); +} + +#[tokio::test] +#[serial] +async fn fulfillment_transitions_are_validated() { + let app = spawn_app().await; + let admin = login_admin(&app).await; + let product = create_product(&app, &admin, 300, 5, true).await; + let id = product["id"].as_str().unwrap().to_string(); + + let (token, user_id) = register_customer(&app, "pm-flow").await; + credit_points(&app.state, uuid(&user_id), 2_000).await; + + let first: serde_json::Value = redeem(&app, &token, &id, 1) + .await + .json() + .await + .unwrap(); + let first_id = first["id"].as_str().unwrap().to_string(); + + let res = client() + .post(app.url(&format!("/api/admin/points/orders/{first_id}/fulfill"))) + .bearer_auth(&admin) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let fulfilled: serde_json::Value = res.json().await.unwrap(); + assert_eq!(fulfilled["status"], "fulfilled"); + + // Neither transition repeats from a terminal state. + let res = client() + .post(app.url(&format!("/api/admin/points/orders/{first_id}/fulfill"))) + .bearer_auth(&admin) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 409); + let res = client() + .post(app.url(&format!("/api/admin/points/orders/{first_id}/cancel"))) + .bearer_auth(&admin) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 409); + + // A pending order can be cancelled once. + let second: serde_json::Value = redeem(&app, &token, &id, 1) + .await + .json() + .await + .unwrap(); + let second_id = second["id"].as_str().unwrap().to_string(); + let res = client() + .post(app.url(&format!("/api/admin/points/orders/{second_id}/cancel"))) + .bearer_auth(&admin) + .send() + .await + .unwrap(); + assert_eq!(res.status(), 200); + let cancelled: serde_json::Value = res.json().await.unwrap(); + assert_eq!(cancelled["status"], "cancelled"); +} diff --git a/apps/mall/mock/api.ts b/apps/mall/mock/api.ts index 7cadbad..da601cb 100644 --- a/apps/mall/mock/api.ts +++ b/apps/mall/mock/api.ts @@ -16,10 +16,14 @@ import type { CouponTemplate, CouponTemplateInput, HomeContent, + IntegralOrder, + IntegralProduct, + IntegralProductInput, Invoice, InvoiceKind, Order, Product, + RedeemPointsBody, Shipment, ShopProfile, User, @@ -31,6 +35,7 @@ import { MOCK_CATEGORIES, MOCK_COUPONS, MOCK_CURRENCIES, + INTEGRAL_PRODUCTS, MOCK_PROMOS, MOCK_QUICK_LINKS, MOCK_STORES, @@ -54,9 +59,13 @@ interface MockState { addresses: AddressBookEntry[]; /** In-memory only: claims made during this browser session. */ coupons: Coupon[]; + /** In-memory points catalog and redemptions for the fixed-data path. */ + pointsProducts: IntegralProduct[]; + redemptions: IntegralOrder[]; addressSeq: number; orderSeq: number; invoiceSeq: number; + redemptionSeq: number; } // v3: address book joined the persisted state. @@ -124,10 +133,36 @@ function seedCoupons(): Coupon[] { return MOCK_COUPONS.map(mockOwnedCoupon); } +function seedPointsProducts(): IntegralProduct[] { + return INTEGRAL_PRODUCTS.map((p) => ({ + id: p.id, + name: p.name, + subtitle: null, + content: null, + image: p.image, + points_price: p.points, + stock: p.stock, + published: true, + recommend: false, + position: 0, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + })); +} + function initialState(): MockState { const persisted = loadPersisted(); - // Coupons are session-only, so a restored snapshot re-seeds them. - if (persisted) return { token: null, ...persisted, coupons: seedCoupons() }; + // Coupons and points are session-only, so a restored snapshot re-seeds them. + if (persisted) { + return { + token: null, + ...persisted, + coupons: seedCoupons(), + pointsProducts: seedPointsProducts(), + redemptions: [], + redemptionSeq: 0, + }; + } const seed = seedOrders(MOCK_USER.id); const seededAddresses: AddressBookEntry[] = MOCK_ADDRESSES.map((a, i) => ({ id: a.id, @@ -150,9 +185,12 @@ function initialState(): MockState { invoices: seed.invoices, addresses: seededAddresses, coupons: seedCoupons(), + pointsProducts: seedPointsProducts(), + redemptions: [], addressSeq: 100, orderSeq: 100, invoiceSeq: 100, + redemptionSeq: 0, }; } @@ -587,6 +625,54 @@ export function createMockApi(): ApiClient { return Promise.resolve({ ...coupon }); }, + listPointsProducts: () => + Promise.resolve( + state.pointsProducts.filter((p) => p.published).map((p) => ({ ...p })), + ), + + listMyRedemptions: () => Promise.resolve(state.redemptions.map((o) => ({ ...o }))), + + redeemPoints: (body: RedeemPointsBody) => { + const product = state.pointsProducts.find( + (p) => p.id === body.product_id && p.published, + ); + if (!product) { + return Promise.reject(new ApiError(404, "NOT_FOUND", "Points product not found")); + } + if (body.qty <= 0 || body.qty > product.stock) { + return Promise.reject(new ApiError(409, "CONFLICT", "Insufficient points stock")); + } + const total = product.points_price * body.qty; + if (total > USER_STATS.points) { + return Promise.reject(new ApiError(409, "CONFLICT", "Insufficient points")); + } + state.redemptionSeq += 1; + const order: IntegralOrder = { + id: `po-${state.redemptionSeq}`, + order_no: `PM${String(state.redemptionSeq).padStart(8, "0")}`, + user_id: MOCK_USER.id, + status: "pending_fulfillment", + total_points: total, + shipping_address: body.shipping_address, + items: [ + { + id: `poi-${state.redemptionSeq}`, + order_id: `po-${state.redemptionSeq}`, + product_id: product.id, + name: product.name, + image: product.image, + points_price: product.points_price, + qty: body.qty, + }, + ], + created_at: new Date().toISOString(), + }; + product.stock -= body.qty; + state.redemptions = [order, ...state.redemptions]; + persist(); + return Promise.resolve({ ...order }); + }, + shop: { getMyShop: () => unsupported(), listMyProducts: () => unsupported(), @@ -623,6 +709,13 @@ export function createMockApi(): ApiClient { setShopProfile: () => unsupported(), getBrands: () => unsupported(), replaceBrands: () => unsupported(), + listPointsProducts: () => unsupported(), + createPointsProduct: (_body: IntegralProductInput) => unsupported(), + updatePointsProduct: (_id: string, _body: IntegralProductInput) => unsupported(), + setPointsProductPublished: (_id: string, _published: boolean) => unsupported(), + listPointsRedemptions: (_page?: number) => unsupported(), + fulfillRedemption: (_id: string) => unsupported(), + cancelRedemption: (_id: string) => unsupported(), }, }; } diff --git a/apps/mall/nuxt.config.ts b/apps/mall/nuxt.config.ts index caf06b4..aec03f0 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", "coupons"], + liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "account", "cart", "orders", "shipments", "invoices", "addresses", "coupons", "points"], appName: "mall", }, }, diff --git a/apps/mall/plugins/api.ts b/apps/mall/plugins/api.ts index b29d908..740e46f 100644 --- a/apps/mall/plugins/api.ts +++ b/apps/mall/plugins/api.ts @@ -20,7 +20,8 @@ type LiveDomain = | "shipments" | "invoices" | "addresses" - | "coupons"; + | "coupons" + | "points"; /** * Explicit per-domain method picks rather than a string allowlist: indexing @@ -72,6 +73,11 @@ const LIVE_PICKS = { listMyCoupons: a.listMyCoupons, claimCoupon: a.claimCoupon, }), + points: (a: ApiClient) => ({ + listPointsProducts: a.listPointsProducts, + listMyRedemptions: a.listMyRedemptions, + redeemPoints: a.redeemPoints, + }), } satisfies Record Partial>; const KNOWN_DOMAINS = Object.keys(LIVE_PICKS) as LiveDomain[]; @@ -91,6 +97,7 @@ const DEFAULT_LIVE_DOMAINS: LiveDomain[] = [ "invoices", "addresses", "coupons", + "points", ]; export default defineNuxtPlugin(() => { diff --git a/openspec/changes/add-points-mall/tasks.md b/openspec/changes/add-points-mall/tasks.md index 4a30f22..adca72f 100644 --- a/openspec/changes/add-points-mall/tasks.md +++ b/openspec/changes/add-points-mall/tasks.md @@ -1,13 +1,13 @@ ## 1. Prerequisite and persistence -- [ ] 1.1 Confirm `customer-accounts` is archived and use its guarded internal points-debit service. -- [ ] 1.2 Add additive migrations for platform points products, redemption orders, redemption items, status constraints, address snapshots, and indexes. -- [ ] 1.3 Implement the Rust points module repositories and services for published catalog reads, admin product management, atomic redemption, history, and validated fulfillment transitions. +- [x] 1.1 Confirm `customer-accounts` is archived and use its guarded internal points-debit service. +- [x] 1.2 Add additive migrations for platform points products, redemption orders, redemption items, status constraints, address snapshots, and indexes. +- [x] 1.3 Implement the Rust points module repositories and services for published catalog reads, admin product management, atomic redemption, history, and validated fulfillment transitions. ## 2. Contract and backend proof - [ ] 2.1 Register customer and platform routes with appropriate roles and add shared types, client methods, locales, and fixed-data adapter parity. -- [ ] 2.2 Add integration tests for unpublished-product rejection, insufficient points rollback, concurrent final stock, ownership filtering, and fulfillment transition validation. +- [x] 2.2 Add integration tests for unpublished-product rejection, insufficient points rollback, concurrent final stock, ownership filtering, and fulfillment transition validation. ## 3. Application surfaces @@ -18,4 +18,4 @@ ## 4. Verification and specification - [ ] 4.1 Seed deterministic points products and credit demo customer points through the archived customer-accounts credit path (append-only entry, never an absolute balance write). Browser-smoke admin publication and customer redemption. -- [ ] 4.2 Run cargo test for vmall-api, builds for mall and 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 admin, and strict validation for this OpenSpec change. diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index 5aad62f..002a8f6 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -14,6 +14,9 @@ import type { CouponTemplateInput, Currency, HomeContent, + IntegralOrder, + IntegralProduct, + IntegralProductInput, Invoice, InvoiceKind, LocalizedText, @@ -22,6 +25,7 @@ import type { Paged, Product, ProductStatus, + RedeemPointsBody, Shipment, Shop, ShopProfile, @@ -200,6 +204,10 @@ export interface ApiClient { listShopCouponTemplates(shopId: string): Promise; listMyCoupons(): Promise; claimCoupon(templateId: string): Promise; + /** Public points catalog: published products only. */ + listPointsProducts(): Promise; + listMyRedemptions(): Promise; + redeemPoints(body: RedeemPointsBody): Promise; shop: { getMyShop(): Promise; listMyProducts(q?: ShopProductQuery): Promise>; @@ -292,6 +300,9 @@ export function createApi(opts: ApiClientOptions): ApiClient { listShopCouponTemplates: (shopId) => r("GET", `/shops/${shopId}/coupon-templates`), listMyCoupons: () => r("GET", "/me/coupons"), claimCoupon: (templateId) => r("POST", "/me/coupons", { template_id: templateId }), + listPointsProducts: () => r("GET", "/points/products"), + listMyRedemptions: () => r("GET", "/points/redemptions"), + redeemPoints: (body) => r("POST", "/points/redemptions", body), shop: { getMyShop: () => r("GET", "/shop/profile"), listMyProducts: (q = {}) => r("GET", "/shop/products", undefined, { ...q }), @@ -337,6 +348,14 @@ export function createApi(opts: ApiClientOptions): ApiClient { /** Replaces the whole ordered brand list. */ getBrands: () => r("GET", "/brands"), replaceBrands: (items) => r("PUT", "/admin/brands", items), + listPointsProducts: () => r("GET", "/admin/points/products"), + createPointsProduct: (body) => r("POST", "/admin/points/products", body), + updatePointsProduct: (id, body) => r("PUT", `/admin/points/products/${id}`, body), + setPointsProductPublished: (id, published) => + r("POST", `/admin/points/products/${id}/${published ? "publish" : "unpublish"}`), + listPointsRedemptions: (page = 1) => r("GET", "/admin/points/orders", undefined, { page }), + fulfillRedemption: (id) => r("POST", `/admin/points/orders/${id}/fulfill`), + cancelRedemption: (id) => r("POST", `/admin/points/orders/${id}/cancel`), }, }; } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 7c2d24c..301816b 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -248,6 +248,68 @@ export interface Coupon { redeemed_at: string | null; } +// ---- points mall ---- + +export type IntegralOrderStatus = "pending_fulfillment" | "fulfilled" | "cancelled"; + +/** Platform-owned catalog item redeemable for points; never a SKU. */ +export interface IntegralProduct { + id: string; + name: LocalizedText; + subtitle: LocalizedText | null; + content: LocalizedText | null; + image: string | null; + points_price: number; + stock: number; + published: boolean; + recommend: boolean; + position: number; + created_at: string; + updated_at: string; +} + +/** Platform-admin create/update body for a points product. */ +export interface IntegralProductInput { + name: LocalizedText; + subtitle?: LocalizedText | null; + content?: LocalizedText | null; + image?: string | null; + points_price: number; + stock: number; + published?: boolean; + recommend?: boolean; + position?: number; +} + +export interface IntegralOrderItem { + id: string; + order_id: string; + /** Null once the catalog item is deleted; the snapshot still stands. */ + product_id: string | null; + name: LocalizedText; + image: string | null; + points_price: number; + qty: number; +} + +/** A redemption order; separate from cash orders and payments. */ +export interface IntegralOrder { + id: string; + order_no: string; + user_id: string; + status: IntegralOrderStatus; + total_points: number; + shipping_address: Address; + items: IntegralOrderItem[]; + created_at: string; +} + +export interface RedeemPointsBody { + product_id: string; + qty: number; + shipping_address: Address; +} + export type ShipmentStatus = "pending" | "shipped" | "delivered";export interface Shipment { id: string; shipment_no: string;