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:
@@ -189,6 +189,10 @@ pub struct Order {
|
||||
pub status: OrderStatus,
|
||||
pub currency: String,
|
||||
pub total_minor: i64,
|
||||
/// Realized coupon discount, converted to the order currency server-side.
|
||||
pub discount_minor: i64,
|
||||
/// The coupon this order redeemed, if any.
|
||||
pub coupon_id: Option<Uuid>,
|
||||
pub shipping_address: serde_json::Value,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
@@ -286,6 +290,59 @@ pub struct CustomerAccountEntry {
|
||||
|
||||
pub const CUSTOMER_ACCOUNT_ENTRY_COLUMNS: &str = "id, account_id, delta_minor, balance_minor, reason, reference_type, reference_id, created_at";
|
||||
|
||||
/// Lifecycle of a customer-owned coupon snapshot.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[sqlx(type_name = "coupon_status", rename_all = "snake_case")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CouponStatus {
|
||||
Claimed,
|
||||
Redeemed,
|
||||
Expired,
|
||||
}
|
||||
|
||||
/// Shop-issued coupon definition. Editing one never changes claims already made.
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct CouponTemplate {
|
||||
pub id: Uuid,
|
||||
pub shop_id: Uuid,
|
||||
pub title: serde_json::Value,
|
||||
pub amount_minor: i64,
|
||||
pub threshold_minor: i64,
|
||||
pub currency: String,
|
||||
/// Remaining claim stock; decremented conditionally on claim.
|
||||
pub stock: i32,
|
||||
pub enabled: bool,
|
||||
pub starts_at: DateTime<Utc>,
|
||||
pub ends_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
pub const COUPON_TEMPLATE_COLUMNS: &str = "id, shop_id, title, amount_minor, threshold_minor, \
|
||||
currency, stock, enabled, starts_at, ends_at, created_at";
|
||||
|
||||
/// Customer-owned snapshot of a template's terms at claim time.
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct Coupon {
|
||||
pub id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
/// Null once the issuing template is deleted; the snapshot still stands.
|
||||
pub template_id: Option<Uuid>,
|
||||
pub shop_id: Uuid,
|
||||
pub title: serde_json::Value,
|
||||
pub amount_minor: i64,
|
||||
pub threshold_minor: i64,
|
||||
pub currency: String,
|
||||
pub starts_at: DateTime<Utc>,
|
||||
pub ends_at: DateTime<Utc>,
|
||||
pub status: CouponStatus,
|
||||
pub order_id: Option<Uuid>,
|
||||
pub claimed_at: DateTime<Utc>,
|
||||
pub redeemed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub const COUPON_COLUMNS: &str = "id, user_id, template_id, shop_id, title, amount_minor, \
|
||||
threshold_minor, currency, starts_at, ends_at, status, order_id, claimed_at, redeemed_at";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
||||
pub struct AddressBookEntry {
|
||||
pub id: Uuid,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Shop-side create/update body. The snapshot a customer claims copies these
|
||||
/// terms, so later edits never rewrite a held coupon.
|
||||
#[derive(Deserialize)]
|
||||
pub struct CouponTemplateInput {
|
||||
pub title: serde_json::Value,
|
||||
pub amount_minor: i64,
|
||||
pub threshold_minor: i64,
|
||||
pub currency: String,
|
||||
pub stock: i32,
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
pub starts_at: DateTime<Utc>,
|
||||
pub ends_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
routing::{get, put},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::AuthUser;
|
||||
use crate::error::ApiResult;
|
||||
use crate::models::{Coupon, CouponTemplate};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::dto::CouponTemplateInput;
|
||||
use super::service;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
// Public: coupons a shopper could claim from a shop right now.
|
||||
.route("/shops/{shop_id}/coupon-templates", get(list_claimable))
|
||||
.route("/me/coupons", get(list_mine).post(claim))
|
||||
.route("/shop/coupon-templates", get(shop_list).post(shop_create))
|
||||
.route(
|
||||
"/shop/coupon-templates/{id}",
|
||||
put(shop_update).delete(shop_delete),
|
||||
)
|
||||
}
|
||||
|
||||
async fn list_claimable(
|
||||
State(state): State<AppState>,
|
||||
Path(shop_id): Path<Uuid>,
|
||||
) -> ApiResult<Json<Vec<CouponTemplate>>> {
|
||||
Ok(Json(service::list_claimable(&state, shop_id).await?))
|
||||
}
|
||||
|
||||
async fn list_mine(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> ApiResult<Json<Vec<Coupon>>> {
|
||||
auth.require_customer()?;
|
||||
Ok(Json(service::list_mine(&state, auth.id).await?))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ClaimBody {
|
||||
template_id: Uuid,
|
||||
}
|
||||
|
||||
async fn claim(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(body): Json<ClaimBody>,
|
||||
) -> ApiResult<(StatusCode, Json<Coupon>)> {
|
||||
auth.require_customer()?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(service::claim(&state, auth.id, body.template_id).await?),
|
||||
))
|
||||
}
|
||||
|
||||
async fn shop_list(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
) -> ApiResult<Json<Vec<CouponTemplate>>> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
Ok(Json(service::list_for_shop(&state, shop_id).await?))
|
||||
}
|
||||
|
||||
async fn shop_create(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Json(body): Json<CouponTemplateInput>,
|
||||
) -> ApiResult<(StatusCode, Json<CouponTemplate>)> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(service::create(&state, shop_id, body).await?),
|
||||
))
|
||||
}
|
||||
|
||||
async fn shop_update(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<CouponTemplateInput>,
|
||||
) -> ApiResult<Json<CouponTemplate>> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
Ok(Json(service::update(&state, shop_id, id, body).await?))
|
||||
}
|
||||
|
||||
async fn shop_delete(
|
||||
State(state): State<AppState>,
|
||||
auth: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
let shop_id = auth.require_shop()?;
|
||||
service::delete(&state, shop_id, id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
mod dto;
|
||||
mod handlers;
|
||||
mod repo;
|
||||
pub mod service;
|
||||
|
||||
use axum::Router;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
handlers::router()
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
use sqlx::{PgConnection, PgExecutor};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::models::{Coupon, CouponTemplate, COUPON_COLUMNS, COUPON_TEMPLATE_COLUMNS};
|
||||
|
||||
use super::dto::CouponTemplateInput;
|
||||
|
||||
// ---- shop-owned templates ----
|
||||
|
||||
pub async fn currency_enabled<'e, E: PgExecutor<'e>>(exec: E, code: &str) -> ApiResult<bool> {
|
||||
Ok(sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM currencies WHERE code = $1 AND enabled = TRUE)",
|
||||
)
|
||||
.bind(code)
|
||||
.fetch_one(exec)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn list_for_shop<'e, E: PgExecutor<'e>>(
|
||||
exec: E,
|
||||
shop_id: Uuid,
|
||||
) -> ApiResult<Vec<CouponTemplate>> {
|
||||
Ok(sqlx::query_as::<_, CouponTemplate>(&format!(
|
||||
"SELECT {COUPON_TEMPLATE_COLUMNS} FROM coupon_templates
|
||||
WHERE shop_id = $1 ORDER BY created_at DESC"
|
||||
))
|
||||
.bind(shop_id)
|
||||
.fetch_all(exec)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Templates a customer may claim right now: enabled, in window, stock left.
|
||||
pub async fn list_claimable<'e, E: PgExecutor<'e>>(
|
||||
exec: E,
|
||||
shop_id: Uuid,
|
||||
) -> ApiResult<Vec<CouponTemplate>> {
|
||||
Ok(sqlx::query_as::<_, CouponTemplate>(&format!(
|
||||
"SELECT {COUPON_TEMPLATE_COLUMNS} FROM coupon_templates
|
||||
WHERE shop_id = $1 AND enabled = TRUE AND stock > 0
|
||||
AND now() BETWEEN starts_at AND ends_at
|
||||
ORDER BY amount_minor DESC"
|
||||
))
|
||||
.bind(shop_id)
|
||||
.fetch_all(exec)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn insert(
|
||||
tx: &mut PgConnection,
|
||||
shop_id: Uuid,
|
||||
body: &CouponTemplateInput,
|
||||
) -> ApiResult<CouponTemplate> {
|
||||
Ok(sqlx::query_as::<_, CouponTemplate>(&format!(
|
||||
"INSERT INTO coupon_templates
|
||||
(shop_id, title, amount_minor, threshold_minor, currency, stock, enabled,
|
||||
starts_at, ends_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING {COUPON_TEMPLATE_COLUMNS}"
|
||||
))
|
||||
.bind(shop_id)
|
||||
.bind(&body.title)
|
||||
.bind(body.amount_minor)
|
||||
.bind(body.threshold_minor)
|
||||
.bind(body.currency.to_uppercase())
|
||||
.bind(body.stock)
|
||||
.bind(body.enabled)
|
||||
.bind(body.starts_at)
|
||||
.bind(body.ends_at)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
tx: &mut PgConnection,
|
||||
shop_id: Uuid,
|
||||
id: Uuid,
|
||||
body: &CouponTemplateInput,
|
||||
) -> ApiResult<CouponTemplate> {
|
||||
sqlx::query_as::<_, CouponTemplate>(&format!(
|
||||
"UPDATE coupon_templates
|
||||
SET title = $3, amount_minor = $4, threshold_minor = $5, currency = $6,
|
||||
stock = $7, enabled = $8, starts_at = $9, ends_at = $10, updated_at = now()
|
||||
WHERE id = $1 AND shop_id = $2
|
||||
RETURNING {COUPON_TEMPLATE_COLUMNS}"
|
||||
))
|
||||
.bind(id)
|
||||
.bind(shop_id)
|
||||
.bind(&body.title)
|
||||
.bind(body.amount_minor)
|
||||
.bind(body.threshold_minor)
|
||||
.bind(body.currency.to_uppercase())
|
||||
.bind(body.stock)
|
||||
.bind(body.enabled)
|
||||
.bind(body.starts_at)
|
||||
.bind(body.ends_at)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("coupon template".into()))
|
||||
}
|
||||
|
||||
pub async fn delete(tx: &mut PgConnection, shop_id: Uuid, id: Uuid) -> ApiResult<()> {
|
||||
let result = sqlx::query("DELETE FROM coupon_templates WHERE id = $1 AND shop_id = $2")
|
||||
.bind(id)
|
||||
.bind(shop_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::NotFound("coupon template".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lock the template row for a claim so two customers cannot both take the last one.
|
||||
pub async fn lock_template(
|
||||
tx: &mut PgConnection,
|
||||
id: Uuid,
|
||||
) -> ApiResult<CouponTemplate> {
|
||||
sqlx::query_as::<_, CouponTemplate>(&format!(
|
||||
"SELECT {COUPON_TEMPLATE_COLUMNS} FROM coupon_templates WHERE id = $1 FOR UPDATE"
|
||||
))
|
||||
.bind(id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::NotFound("coupon template".into()))
|
||||
}
|
||||
|
||||
/// Guarded counter decrement: never drives claim stock below zero.
|
||||
pub async fn decrement_claim_stock(tx: &mut PgConnection, id: Uuid) -> ApiResult<()> {
|
||||
let result = sqlx::query(
|
||||
"UPDATE coupon_templates SET stock = stock - 1, updated_at = now()
|
||||
WHERE id = $1 AND stock >= 1",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(ApiError::Conflict("coupon is no longer available".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- customer-owned snapshots ----
|
||||
|
||||
pub async fn insert_claim(
|
||||
tx: &mut PgConnection,
|
||||
user_id: Uuid,
|
||||
template: &CouponTemplate,
|
||||
) -> Result<Coupon, sqlx::Error> {
|
||||
sqlx::query_as::<_, Coupon>(&format!(
|
||||
"INSERT INTO coupons
|
||||
(user_id, template_id, shop_id, title, amount_minor, threshold_minor, currency,
|
||||
starts_at, ends_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING {COUPON_COLUMNS}"
|
||||
))
|
||||
.bind(user_id)
|
||||
.bind(template.id)
|
||||
.bind(template.shop_id)
|
||||
.bind(&template.title)
|
||||
.bind(template.amount_minor)
|
||||
.bind(template.threshold_minor)
|
||||
.bind(&template.currency)
|
||||
.bind(template.starts_at)
|
||||
.bind(template.ends_at)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Record expiry lazily on read; no scheduler is involved.
|
||||
pub async fn expire_due_for_user<'e, E: PgExecutor<'e>>(exec: E, user_id: Uuid) -> ApiResult<()> {
|
||||
sqlx::query(
|
||||
"UPDATE coupons SET status = 'expired'
|
||||
WHERE user_id = $1 AND status = 'claimed' AND ends_at < now()",
|
||||
)
|
||||
.bind(user_id)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_for_user<'e, E: PgExecutor<'e>>(
|
||||
exec: E,
|
||||
user_id: Uuid,
|
||||
) -> ApiResult<Vec<Coupon>> {
|
||||
Ok(sqlx::query_as::<_, Coupon>(&format!(
|
||||
"SELECT {COUPON_COLUMNS} FROM coupons
|
||||
WHERE user_id = $1
|
||||
ORDER BY CASE status WHEN 'claimed' THEN 0 WHEN 'redeemed' THEN 1 ELSE 2 END,
|
||||
ends_at DESC"
|
||||
))
|
||||
.bind(user_id)
|
||||
.fetch_all(exec)
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Lock the customer's chosen coupons in primary-key order for checkout.
|
||||
pub async fn lock_owned_for_update(
|
||||
tx: &mut PgConnection,
|
||||
user_id: Uuid,
|
||||
ids: &[Uuid],
|
||||
) -> ApiResult<Vec<Coupon>> {
|
||||
Ok(sqlx::query_as::<_, Coupon>(&format!(
|
||||
"SELECT {COUPON_COLUMNS} FROM coupons
|
||||
WHERE id = ANY($1) AND user_id = $2
|
||||
ORDER BY id
|
||||
FOR UPDATE"
|
||||
))
|
||||
.bind(ids)
|
||||
.bind(user_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn redeem(
|
||||
tx: &mut PgConnection,
|
||||
coupon_id: Uuid,
|
||||
order_id: Uuid,
|
||||
) -> ApiResult<Coupon> {
|
||||
sqlx::query_as::<_, Coupon>(&format!(
|
||||
"UPDATE coupons SET status = 'redeemed', order_id = $2, redeemed_at = now()
|
||||
WHERE id = $1 AND status = 'claimed'
|
||||
RETURNING {COUPON_COLUMNS}"
|
||||
))
|
||||
.bind(coupon_id)
|
||||
.bind(order_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::Conflict("coupon cannot be redeemed".into()))
|
||||
}
|
||||
|
||||
/// Pending-payment cancellation returns a redeemed coupon to claimed.
|
||||
pub async fn restore_for_order(tx: &mut PgConnection, order_id: Uuid) -> ApiResult<()> {
|
||||
sqlx::query(
|
||||
"UPDATE coupons SET status = 'claimed', order_id = NULL, redeemed_at = NULL
|
||||
WHERE order_id = $1 AND status = 'redeemed'",
|
||||
)
|
||||
.bind(order_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod billing;
|
||||
pub mod cart;
|
||||
pub mod catalog;
|
||||
pub mod content;
|
||||
pub mod coupon;
|
||||
pub mod currency;
|
||||
pub mod fulfillment;
|
||||
pub mod health;
|
||||
@@ -25,6 +26,7 @@ pub fn api_router() -> Router<AppState> {
|
||||
.merge(catalog::router())
|
||||
.merge(content::router())
|
||||
.merge(cart::router())
|
||||
.merge(coupon::router())
|
||||
.merge(order::router())
|
||||
.merge(shop::router())
|
||||
.merge(fulfillment::router())
|
||||
|
||||
@@ -57,6 +57,10 @@ async fn get_order(
|
||||
struct CheckoutBody {
|
||||
shipping_address: AddressBody,
|
||||
currency: String,
|
||||
/// At most one owned coupon per generated shop order. The client sends the
|
||||
/// choice, never a discount amount.
|
||||
#[serde(default)]
|
||||
coupon_by_shop: std::collections::HashMap<Uuid, Uuid>,
|
||||
}
|
||||
|
||||
async fn checkout(
|
||||
@@ -67,7 +71,14 @@ async fn checkout(
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(
|
||||
service::checkout(&state, auth.id, body.shipping_address, body.currency).await?,
|
||||
service::checkout(
|
||||
&state,
|
||||
auth.id,
|
||||
body.shipping_address,
|
||||
body.currency,
|
||||
body.coupon_by_shop,
|
||||
)
|
||||
.await?,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::models::{Order, OrderItem, OrderStatus};
|
||||
use super::dto::{OrderScope, OrderView};
|
||||
|
||||
const ORDER_COLS: &str = "id, order_no, shop_id, user_id, status, currency, total_minor,
|
||||
shipping_address, created_at, updated_at";
|
||||
discount_minor, coupon_id, shipping_address, created_at, updated_at";
|
||||
const ORDER_ITEM_COLS: &str =
|
||||
"id, order_id, sku_id, product_name, sku_code, image, unit_price_minor, qty";
|
||||
|
||||
@@ -140,18 +140,23 @@ pub async fn insert_order(
|
||||
user_id: Uuid,
|
||||
currency: &str,
|
||||
total: i64,
|
||||
discount_minor: i64,
|
||||
coupon_id: Option<Uuid>,
|
||||
address: &serde_json::Value,
|
||||
) -> ApiResult<Order> {
|
||||
Ok(sqlx::query_as::<_, Order>(&format!(
|
||||
"INSERT INTO orders (order_no, shop_id, user_id, currency, total_minor, shipping_address)
|
||||
"INSERT INTO orders (order_no, shop_id, user_id, currency, total_minor, discount_minor,
|
||||
coupon_id, shipping_address)
|
||||
VALUES ('VM' || to_char(now(), 'YYMMDD') || lpad(nextval('order_no_seq')::text, 6, '0'),
|
||||
$1, $2, $3, $4, $5)
|
||||
$1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING {ORDER_COLS}"
|
||||
))
|
||||
.bind(shop_id)
|
||||
.bind(user_id)
|
||||
.bind(currency)
|
||||
.bind(total)
|
||||
.bind(discount_minor)
|
||||
.bind(coupon_id)
|
||||
.bind(address)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{ApiError, ApiResult};
|
||||
use crate::http::{clamp_page, clamp_per_page, Paged};
|
||||
use crate::models::OrderStatus;
|
||||
use crate::money::convert_minor;
|
||||
use crate::modules::cart;
|
||||
use crate::modules::{cart, coupon};
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::dto::{AddressBody, OrderScope, OrderView};
|
||||
@@ -54,6 +56,7 @@ pub async fn checkout(
|
||||
user_id: Uuid,
|
||||
shipping_address: AddressBody,
|
||||
currency: String,
|
||||
coupon_by_shop: HashMap<Uuid, Uuid>,
|
||||
) -> ApiResult<Vec<OrderView>> {
|
||||
shipping_address.validate()?;
|
||||
let target_currency = currency.to_uppercase();
|
||||
@@ -73,7 +76,7 @@ pub async fn checkout(
|
||||
"some cart items are no longer purchasable".into(),
|
||||
));
|
||||
}
|
||||
let qty_by_sku: std::collections::HashMap<Uuid, i32> = entries.iter().copied().collect();
|
||||
let qty_by_sku: HashMap<Uuid, i32> = entries.iter().copied().collect();
|
||||
for row in &rows {
|
||||
let qty = qty_by_sku.get(&row.sku_id).copied().unwrap_or(0);
|
||||
if qty > row.stock {
|
||||
@@ -90,35 +93,78 @@ pub async fn checkout(
|
||||
shop_order.push(row.shop_id);
|
||||
}
|
||||
}
|
||||
for shop_id in coupon_by_shop.keys() {
|
||||
if !shop_order.contains(shop_id) {
|
||||
return Err(ApiError::BadRequest(
|
||||
"coupon selected for a shop that is not in the cart".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Per-shop merchandise subtotal in the buyer's currency.
|
||||
let mut subtotal_by_shop: HashMap<Uuid, i64> = HashMap::new();
|
||||
let mut line_prices: Vec<(Uuid, i64, i32)> = Vec::new();
|
||||
for row in &rows {
|
||||
let qty = qty_by_sku.get(&row.sku_id).copied().unwrap_or(0);
|
||||
let from = all_currencies
|
||||
.iter()
|
||||
.find(|c| c.code == row.currency)
|
||||
.ok_or_else(|| ApiError::BadRequest(format!("currency {} disabled", row.currency)))?;
|
||||
let unit = convert_minor(row.price_minor, from, &target)?;
|
||||
*subtotal_by_shop.entry(row.shop_id).or_insert(0) += unit * qty as i64;
|
||||
line_prices.push((row.sku_id, unit, qty));
|
||||
}
|
||||
|
||||
// Lock every selected coupon in id order, then prove the customer owns it.
|
||||
let mut coupon_ids: Vec<Uuid> = coupon_by_shop.values().copied().collect();
|
||||
coupon_ids.sort_unstable();
|
||||
coupon_ids.dedup();
|
||||
let locked = coupon::service::lock_selected(&mut tx, user_id, &coupon_ids).await?;
|
||||
if locked.len() != coupon_ids.len() {
|
||||
return Err(ApiError::NotFound("coupon".into()));
|
||||
}
|
||||
let mut coupons: HashMap<Uuid, crate::models::Coupon> =
|
||||
locked.into_iter().map(|c| (c.id, c)).collect();
|
||||
|
||||
let address = serde_json::to_value(&shipping_address).map_err(ApiError::internal)?;
|
||||
let mut created = Vec::new();
|
||||
for shop_id in shop_order {
|
||||
let shop_rows: Vec<&repo::CheckoutRow> =
|
||||
rows.iter().filter(|r| r.shop_id == shop_id).collect();
|
||||
let mut total: i64 = 0;
|
||||
let mut line_prices: Vec<(Uuid, i64, i32)> = Vec::new();
|
||||
for row in &shop_rows {
|
||||
let qty = qty_by_sku.get(&row.sku_id).copied().unwrap_or(0);
|
||||
let from = all_currencies
|
||||
.iter()
|
||||
.find(|c| c.code == row.currency)
|
||||
.ok_or_else(|| {
|
||||
ApiError::BadRequest(format!("currency {} disabled", row.currency))
|
||||
})?;
|
||||
let unit = convert_minor(row.price_minor, from, &target)?;
|
||||
total += unit * qty as i64;
|
||||
line_prices.push((row.sku_id, unit, qty));
|
||||
}
|
||||
let subtotal = subtotal_by_shop.get(&shop_id).copied().unwrap_or(0);
|
||||
let selected_coupon = coupon_by_shop.get(&shop_id).copied();
|
||||
// Eligibility and the discount are resolved server-side; the client
|
||||
// only ever sends which coupon, never how much.
|
||||
let discount = match selected_coupon {
|
||||
Some(id) => {
|
||||
let coupon = coupons
|
||||
.remove(&id)
|
||||
.ok_or_else(|| ApiError::Conflict("coupon cannot be redeemed".into()))?;
|
||||
coupon::service::checkout_discount(
|
||||
&coupon,
|
||||
shop_id,
|
||||
subtotal,
|
||||
&target,
|
||||
&all_currencies,
|
||||
)?
|
||||
}
|
||||
None => 0,
|
||||
};
|
||||
|
||||
let order = repo::insert_order(
|
||||
&mut tx,
|
||||
shop_id,
|
||||
user_id,
|
||||
&target.code,
|
||||
total,
|
||||
subtotal - discount,
|
||||
discount,
|
||||
selected_coupon,
|
||||
&address,
|
||||
)
|
||||
.await?;
|
||||
if let Some(id) = selected_coupon {
|
||||
coupon::service::redeem(&mut tx, id, order.id).await?;
|
||||
}
|
||||
for row in &shop_rows {
|
||||
let (_, unit, qty) = line_prices
|
||||
.iter()
|
||||
@@ -155,6 +201,8 @@ pub async fn cancel(state: &AppState, user_id: Uuid, id: Uuid) -> ApiResult<Orde
|
||||
let mut tx = state.db.begin().await?;
|
||||
let order = repo::cancel(&mut tx, user_id, id).await?;
|
||||
repo::restore_stock(&mut tx, order.id).await?;
|
||||
// Stock and the redeemed coupon come back in the same transaction.
|
||||
coupon::service::restore_for_order(&mut tx, order.id).await?;
|
||||
tx.commit().await?;
|
||||
let mut views = repo::attach_items(&state.db, vec![order]).await?;
|
||||
Ok(views.remove(0))
|
||||
|
||||
Reference in New Issue
Block a user