feat(api): shop coupons with per-shop checkout redemption
Templates belong to a shop; claiming copies their terms into a customer-owned snapshot so a later edit or disable cannot rewrite a held coupon. Claim stock is taken with a guarded decrement after locking the template, and a unique (user, template) index makes a duplicate claim a 409. Deleting a template leaves claimed snapshots standing via ON DELETE SET NULL. Checkout accepts at most one owned coupon per generated shop order, locks the selected coupons by primary key after the SKU locks, and resolves eligibility and the discount server-side (ownership, shop, status, window, converted threshold). The realized discount and coupon id land on the order, and a pending-payment cancellation restores the coupon in the same transaction as stock. The shared contract gains the coupon types, claim/list/manage methods, and the checkout coupon map; the fixed-data adapter implements the same surface. Surfaces (shop-admin management, mall coupon pages, checkout selection) and seeding still follow in tasks 3.1-4.2.
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
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(())
|
||||
}
|
||||
Reference in New Issue
Block a user