Files
vmall/apps/api/src/modules/coupon/service.rs
T

190 lines
5.9 KiB
Rust

use chrono::Utc;
use serde_json::Value;
use sqlx::PgConnection;
use uuid::Uuid;
use crate::error::{unique_conflict, ApiError, ApiResult};
use crate::models::{Coupon, CouponStatus, CouponTemplate, Currency};
use crate::money::convert_minor;
use crate::state::AppState;
use super::dto::CouponTemplateInput;
use super::repo;
// ---- shop-side management ----
pub async fn list_for_shop(state: &AppState, shop_id: Uuid) -> ApiResult<Vec<CouponTemplate>> {
repo::list_for_shop(&state.db, shop_id).await
}
/// Public: what a customer may claim from this shop right now.
pub async fn list_claimable(state: &AppState, shop_id: Uuid) -> ApiResult<Vec<CouponTemplate>> {
repo::list_claimable(&state.db, shop_id).await
}
pub async fn create(
state: &AppState,
shop_id: Uuid,
body: CouponTemplateInput,
) -> ApiResult<CouponTemplate> {
validate_input(&body)?;
let mut tx = state.db.begin().await?;
ensure_currency(&mut tx, &body.currency).await?;
let template = repo::insert(&mut tx, shop_id, &body).await?;
tx.commit().await?;
Ok(template)
}
pub async fn update(
state: &AppState,
shop_id: Uuid,
id: Uuid,
body: CouponTemplateInput,
) -> ApiResult<CouponTemplate> {
validate_input(&body)?;
let mut tx = state.db.begin().await?;
ensure_currency(&mut tx, &body.currency).await?;
let template = repo::update(&mut tx, shop_id, id, &body).await?;
tx.commit().await?;
Ok(template)
}
pub async fn delete(state: &AppState, shop_id: Uuid, id: Uuid) -> ApiResult<()> {
let mut tx = state.db.begin().await?;
repo::delete(&mut tx, shop_id, id).await?;
tx.commit().await?;
Ok(())
}
// ---- customer-side ----
pub async fn list_mine(state: &AppState, user_id: Uuid) -> ApiResult<Vec<Coupon>> {
repo::expire_due_for_user(&state.db, user_id).await?;
repo::list_for_user(&state.db, user_id).await
}
/// Claim a template once: guarded stock decrement plus a unique claim per customer.
pub async fn claim(state: &AppState, user_id: Uuid, template_id: Uuid) -> ApiResult<Coupon> {
let mut tx = state.db.begin().await?;
let template = repo::lock_template(&mut tx, template_id).await?;
let now = Utc::now();
if !template.enabled
|| template.stock <= 0
|| now < template.starts_at
|| now > template.ends_at
{
return Err(ApiError::Conflict("coupon is not claimable".into()));
}
repo::decrement_claim_stock(&mut tx, template.id).await?;
let coupon = repo::insert_claim(&mut tx, user_id, &template)
.await
.map_err(|e| unique_conflict(e, "coupon already claimed"))?;
tx.commit().await?;
Ok(coupon)
}
// ---- checkout composition ----
/// Lock the customer's selections before validating them. Ordered by id, like
/// the SKU locks, so concurrent checkouts cannot deadlock.
pub async fn lock_selected(
tx: &mut PgConnection,
user_id: Uuid,
coupon_ids: &[Uuid],
) -> ApiResult<Vec<Coupon>> {
repo::lock_owned_for_update(tx, user_id, coupon_ids).await
}
/// Server-authoritative eligibility and discount. The client never sends money.
pub fn checkout_discount(
coupon: &Coupon,
shop_id: Uuid,
subtotal_minor: i64,
target: &Currency,
currencies: &[Currency],
) -> ApiResult<i64> {
if coupon.status != CouponStatus::Claimed {
return Err(ApiError::Conflict("coupon is not redeemable".into()));
}
if coupon.shop_id != shop_id {
return Err(ApiError::BadRequest(
"coupon was not issued by this shop".into(),
));
}
let now = Utc::now();
if now < coupon.starts_at || now > coupon.ends_at {
return Err(ApiError::Conflict(
"coupon is outside its active window".into(),
));
}
let from = currencies
.iter()
.find(|c| c.code == coupon.currency)
.ok_or_else(|| ApiError::BadRequest(format!("currency {} is disabled", coupon.currency)))?;
let amount = convert_minor(coupon.amount_minor, from, target)?;
let threshold = convert_minor(coupon.threshold_minor, from, target)?;
if subtotal_minor < threshold {
return Err(ApiError::Conflict(
"order subtotal does not reach the coupon threshold".into(),
));
}
// A discount can never exceed the shop order it applies to.
Ok(amount.min(subtotal_minor))
}
pub async fn redeem(tx: &mut PgConnection, coupon_id: Uuid, order_id: Uuid) -> ApiResult<Coupon> {
repo::redeem(tx, coupon_id, order_id).await
}
/// Restore a redeemed coupon when its pending order is cancelled.
pub async fn restore_for_order(tx: &mut PgConnection, order_id: Uuid) -> ApiResult<()> {
repo::restore_for_order(tx, order_id).await
}
// ---- validation helpers ----
fn validate_input(body: &CouponTemplateInput) -> ApiResult<()> {
bilingual(&body.title, "title")?;
if body.amount_minor <= 0 {
return Err(ApiError::BadRequest("amount_minor must be positive".into()));
}
if body.threshold_minor < 0 {
return Err(ApiError::BadRequest(
"threshold_minor must not be negative".into(),
));
}
if body.stock < 0 {
return Err(ApiError::BadRequest("stock must not be negative".into()));
}
if body.ends_at < body.starts_at {
return Err(ApiError::BadRequest(
"ends_at must not precede starts_at".into(),
));
}
Ok(())
}
async fn ensure_currency(tx: &mut PgConnection, code: &str) -> ApiResult<()> {
if !repo::currency_enabled(&mut *tx, &code.to_uppercase()).await? {
return Err(ApiError::BadRequest(
"currency is unknown or disabled".into(),
));
}
Ok(())
}
fn bilingual(label: &Value, field: &str) -> ApiResult<()> {
let ok = ["en", "zh"].iter().all(|code| {
label
.get(code)
.and_then(Value::as_str)
.is_some_and(|s| !s.trim().is_empty())
});
if !ok {
return Err(ApiError::BadRequest(format!(
"{field} needs non-empty en and zh"
)));
}
Ok(())
}