Files
Chengdong Zhang 9e3abf95ef feat(marketing): add threshold promotions (满减)
Shop-scoped threshold rules with bilingual names, integer
threshold/reduction (reduction < threshold), active windows and display
order. Checkout applies the best eligible rule per shop with currency
conversion; coupon/threshold/activity pricing are mutually exclusive per
shop order. Reduction persists in orders.discount_minor +
threshold_promotion_id and flows into payment totals; checkout
idempotency covers the threshold selection. Delete archives referenced
rules, hard-deletes unreferenced ones.

- migration 0026: threshold_promotions + orders.threshold_promotion_id
  + coupon/threshold exclusivity CHECK
- api: modules/promotion (CRUD, preview, checkout integration)
- shared: ThresholdPromotion types, shop CRUD + preview client methods,
  checkout threshold_by_shop
- shop-admin: promotions page + nav + en/zh locales
- mall: exclusive promotion radio on checkout, live preview wiring,
  threshold label on order detail
- tests: promotions.rs (scoping, validation, best-rule, exclusivity,
  activity-priced 409, totals, replay, archive-vs-delete)
- openspec: archived 2026-10-19-add-threshold-promotions; spec
  threshold-promotions created, order/frontend specs updated
2026-10-19 18:14:38 +08:00

238 lines
6.8 KiB
Rust

mod common;
use serial_test::serial;
use uuid::Uuid;
use vmall_api::error::ApiError;
use vmall_api::modules::cart;
use vmall_api::modules::identity::service::{self as identity, RegisterInput};
use vmall_api::modules::order::{self, AddressBody};
use vmall_api::state::AppState;
fn address() -> AddressBody {
AddressBody {
recipient: "Test".into(),
phone: "123".into(),
country: "US".into(),
region: "CA".into(),
city: "SJ".into(),
line1: "1 Way".into(),
postal_code: "95131".into(),
}
}
async fn register_user(state: &AppState, label: &str) -> Uuid {
let email = format!("{label}-{}@test.local", Uuid::new_v4());
let (user, _) = identity::register(
state,
RegisterInput {
email,
password: "password123".into(),
display_name: label.into(),
},
)
.await
.unwrap();
user.id
}
async fn sellable_sku(state: &AppState, slug: &str, price_minor: i64, stock: i32) -> Uuid {
let slug = format!("{slug}-{}", &Uuid::new_v4().simple().to_string()[..8]);
let shop_id: Uuid =
sqlx::query_scalar("INSERT INTO shops (name, slug) VALUES ($1, $2) RETURNING id")
.bind(serde_json::json!({"en": slug, "zh": slug}))
.bind(&slug)
.fetch_one(&state.db)
.await
.unwrap();
let product_id: Uuid = sqlx::query_scalar(
"INSERT INTO products (shop_id, slug, name, status)
VALUES ($1, $2, $3, 'published') RETURNING id",
)
.bind(shop_id)
.bind(&slug)
.bind(serde_json::json!({"en": slug, "zh": slug}))
.fetch_one(&state.db)
.await
.unwrap();
sqlx::query_scalar(
"INSERT INTO skus (product_id, sku_code, price_minor, currency, stock, active)
VALUES ($1, $2, $3, 'USD', $4, TRUE) RETURNING id",
)
.bind(product_id)
.bind(format!("{slug}-sku"))
.bind(price_minor)
.bind(stock)
.fetch_one(&state.db)
.await
.unwrap()
}
#[tokio::test]
#[serial]
async fn checkout_rejects_empty_cart() {
let state = common::spawn_state().await;
let user_id = register_user(&state, "empty-cart").await;
let err = order::checkout(
&state,
user_id,
&uuid::Uuid::new_v4().to_string(),
address(),
"USD".into(),
Default::default(),
Default::default(),
None,
)
.await
.unwrap_err();
assert!(matches!(err, ApiError::BadRequest(m) if m.contains("empty")));
}
#[tokio::test]
#[serial]
async fn decrement_stock_rejects_when_guard_fails() {
let state = common::spawn_state().await;
let sku_id = sellable_sku(&state, "guard", 100, 1).await;
let mut tx = state.db.begin().await.unwrap();
let err = order::repo::decrement_stock(&mut tx, sku_id, 2)
.await
.unwrap_err();
tx.rollback().await.unwrap();
assert!(matches!(err, ApiError::Conflict(m) if m.contains("insufficient stock")));
let left: i32 = sqlx::query_scalar("SELECT stock FROM skus WHERE id = $1")
.bind(sku_id)
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(left, 1);
}
#[tokio::test]
#[serial]
async fn checkout_rejects_insufficient_stock() {
let state = common::spawn_state().await;
let user_id = register_user(&state, "low-stock").await;
let sku_id = sellable_sku(&state, "low", 1000, 1).await;
cart::service::add_item(&state, user_id, sku_id, 2)
.await
.unwrap();
let err = order::checkout(
&state,
user_id,
&uuid::Uuid::new_v4().to_string(),
address(),
"USD".into(),
Default::default(),
Default::default(),
None,
)
.await
.unwrap_err();
assert!(matches!(err, ApiError::Conflict(m) if m.contains("insufficient stock")));
}
#[tokio::test]
#[serial]
async fn checkout_splits_per_shop() {
let state = common::spawn_state().await;
let user_id = register_user(&state, "split").await;
let sku_a = sellable_sku(&state, "sa", 1000, 5).await;
let sku_b = sellable_sku(&state, "sb", 2000, 5).await;
cart::service::add_item(&state, user_id, sku_a, 2)
.await
.unwrap();
cart::service::add_item(&state, user_id, sku_b, 1)
.await
.unwrap();
let body = order::checkout(
&state,
user_id,
&uuid::Uuid::new_v4().to_string(),
address(),
"USD".into(),
Default::default(),
Default::default(),
None,
)
.await
.unwrap();
let orders = body["orders"].as_array().unwrap();
assert_eq!(orders.len(), 2);
let totals: Vec<i64> = orders
.iter()
.map(|o| o["total_minor"].as_i64().unwrap())
.collect();
assert!(totals.contains(&2000) && totals.contains(&2000));
}
#[tokio::test]
#[serial]
async fn pay_and_cancel_require_pending_payment() {
let state = common::spawn_state().await;
let user_id = register_user(&state, "status").await;
let sku_id = sellable_sku(&state, "st", 500, 3).await;
cart::service::add_item(&state, user_id, sku_id, 1)
.await
.unwrap();
let body = order::checkout(
&state,
user_id,
&uuid::Uuid::new_v4().to_string(),
address(),
"USD".into(),
Default::default(),
Default::default(),
None,
)
.await
.unwrap();
let orders = body["orders"].as_array().unwrap();
let id = uuid::Uuid::parse_str(orders[0]["id"].as_str().unwrap()).unwrap();
vmall_api::modules::wallet::service::recharge(
&state,
user_id,
vmall_api::modules::wallet::dto::RechargeBody { amount_minor: 500 },
)
.await
.unwrap();
let payment = vmall_api::modules::payment::service::resolve_payment(&state, user_id, &[id])
.await
.unwrap();
let channel_id = sqlx::query_scalar::<_, uuid::Uuid>(
"SELECT id FROM payment_channels WHERE adapter = 'wallet' LIMIT 1",
)
.fetch_one(&state.db)
.await
.unwrap();
let body = vmall_api::modules::payment::dto::AttemptBody {
channel_id,
return_surface: None,
};
let attempt = vmall_api::modules::payment::service::initiate_attempt(
&state,
user_id,
payment.id,
&body,
&uuid::Uuid::new_v4().to_string(),
)
.await
.unwrap();
assert_eq!(
attempt.status,
vmall_api::modules::payment::dto::AttemptState::Settled
);
let err = vmall_api::modules::payment::service::initiate_attempt(
&state,
user_id,
payment.id,
&body,
&uuid::Uuid::new_v4().to_string(),
)
.await
.unwrap_err();
assert!(matches!(err, ApiError::Conflict(_)));
let err = order::service::cancel(&state, user_id, id)
.await
.unwrap_err();
assert!(matches!(err, ApiError::Conflict(_)));
}