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:
@@ -73,7 +73,7 @@ async fn sellable_sku(state: &AppState, slug: &str, price_minor: i64, stock: i32
|
||||
async fn checkout_rejects_empty_cart() {
|
||||
let state = common::spawn_state().await;
|
||||
let user_id = register_user(&state, "empty-cart").await;
|
||||
let err = order::checkout(&state, user_id, address(), "USD".into())
|
||||
let err = order::checkout(&state, user_id, address(), "USD".into(), Default::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ApiError::BadRequest(m) if m.contains("empty")));
|
||||
@@ -107,7 +107,7 @@ async fn checkout_rejects_insufficient_stock() {
|
||||
cart::service::add_item(&state, user_id, sku_id, 2)
|
||||
.await
|
||||
.unwrap();
|
||||
let err = order::checkout(&state, user_id, address(), "USD".into())
|
||||
let err = order::checkout(&state, user_id, address(), "USD".into(), Default::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, ApiError::Conflict(m) if m.contains("insufficient stock")));
|
||||
@@ -126,7 +126,7 @@ async fn checkout_splits_per_shop() {
|
||||
cart::service::add_item(&state, user_id, sku_b, 1)
|
||||
.await
|
||||
.unwrap();
|
||||
let orders = order::checkout(&state, user_id, address(), "USD".into())
|
||||
let orders = order::checkout(&state, user_id, address(), "USD".into(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(orders.len(), 2);
|
||||
@@ -143,7 +143,7 @@ async fn pay_and_cancel_require_pending_payment() {
|
||||
cart::service::add_item(&state, user_id, sku_id, 1)
|
||||
.await
|
||||
.unwrap();
|
||||
let orders = order::checkout(&state, user_id, address(), "USD".into())
|
||||
let orders = order::checkout(&state, user_id, address(), "USD".into(), Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let id = orders[0].order.id;
|
||||
|
||||
Reference in New Issue
Block a user