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,
|
||||
|
||||
Reference in New Issue
Block a user