190 lines
5.9 KiB
Rust
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(())
|
|
}
|