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:
2026-09-18 12:03:00 +00:00
parent 39d0158f29
commit 23955434c6
20 changed files with 1363 additions and 46 deletions
+57
View File
@@ -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,