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;