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> { 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> { repo::list_claimable(&state.db, shop_id).await } pub async fn create( state: &AppState, shop_id: Uuid, body: CouponTemplateInput, ) -> ApiResult { 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 { 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> { 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 { 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> { 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 { 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 { 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(()) }