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
+4 -4
View File
@@ -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;