chore(openspec): archive add-shop-coupons

Sync the shop-coupons capability plus the order, frontend-mall, and
frontend-shop-admin requirements into the main specs, and write the capability
Purpose the archive leaves as a placeholder.

Renaming the coupon scenario in the flash-sales and group-buying order deltas
to the exact name now in the main order spec keeps those changes archivable,
since a MODIFIED requirement replaces the whole block.
This commit is contained in:
2026-09-18 12:13:49 +00:00
parent c7fad42105
commit cc07ac3230
14 changed files with 73 additions and 10 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-18
@@ -0,0 +1,36 @@
## Context
Checkout already splits a Redis cart into one database order per shop, converts SKU prices to the buyer-selected currency, and locks SKU rows. Mall coupon pages still read fixtures. The new capability crosses merchant management, customer claim/listing, and order creation.
## Goals / Non-Goals
**Goals:**
- Make shop coupons live, auditable, scoped, and safe under concurrent claims and checkout.
- Let a customer select no more than one coupon per generated shop order.
- Persist the realized minor-unit discount on the order.
**Non-Goals:**
- Platform coupons, threshold reductions, customer targeting, or composing coupons with activity prices in this change. Later flash-sale and group-buying changes reject coupons on activity-priced shop orders.
## Decisions
### Coupon data is template plus owned snapshot
`coupon_templates` owns `shop_id`, localized title, amount/threshold minor, currency, claim stock, active window, and enabled state. `coupons` owns `user_id`, `template_id`, `shop_id`, copied terms/window, `status`, and nullable `order_id`. A unique `(user_id, template_id)` prevents duplicate claims. The snapshot preserves a claim if a template is edited or disabled later.
### Checkout receives choices, not money
The shared checkout body adds `coupon_by_shop: Record<shop UUID, coupon UUID>`. The server rejects an unknown shop, a coupon not owned by the customer or shop, an unavailable or expired coupon, or an unmet threshold. It converts coupon amount and threshold to the buyer-selected order currency with the same enabled-currency conversion used for SKU prices. The server stores `coupon_id` and `discount_minor`; clients never submit a discount.
### One transaction protects stock and redemption
Checkout locks SKU rows in UUID order, then selected coupon rows in UUID order. It conditionally decrements template claim stock on claim and moves a claimed coupon to redeemed only while all eligibility predicates hold. Pending-payment cancellation restores the coupon in the same transaction as SKU stock restoration. Expiry is evaluated at claim/list/checkout and recorded lazily; no scheduler is required.
### Ownership and surfaces follow existing boundaries
Customer routes use `AuthUser`; shop routes require shop roles and `own_shop`. The contract remains exclusively in `@vmall/shared`; Mall replaces direct fixture imports and shop-admin gains template CRUD plus a nav entry. Checkout, payment, and order-detail surfaces show the server `discount_minor`; clients never invent it.
### Later activities do not stack with coupons
This change is coupon-only: threshold and discount use the shop merchandise subtotal after catalog (or converted) line prices. When a later change introduces flash-sale or group-buy line prices, checkout MUST reject `coupon_by_shop` for any generated shop order that applied an activity price. Subsequent `order` spec deltas MUST copy this change's coupon validation, persistence, and pending-payment restore clauses instead of replacing the whole checkout requirement with activity-only text.
## Risks / Trade-offs
- Exchange-rate changes can change an unredeemed coupon's converted value; the realized `discount_minor` is immutable once ordered. Fixing value across currencies would need a new rate-snapshot policy.
- Activity-priced shop orders reject coupons rather than stack. Threshold reductions remain unspecified until a later change.
- Checkout contract expansion requires the fixed-data adapter to implement the same surface.
@@ -0,0 +1,29 @@
## Why
The mall displays fixed coupon fixtures although checkout has no promotion boundary. Shop-issued coupons are the smallest marketing capability that can use the existing per-shop checkout split without introducing a generic promotion engine.
## What Changes
- Add shop-scoped coupon templates with localized content, fixed minor-unit discount, threshold, currency, claim stock, active window, and lifecycle controls.
- Add customer coupon claiming, listing, and a user-held coupon snapshot with claimed/redeemed/expired state and order binding.
- Extend checkout to select at most one eligible coupon for each generated shop order; calculate and persist a server-authoritative discount.
- Record exclusive composition with later activity pricing: a shop order that applies flash-sale or group-buy prices MUST reject a coupon. Later order changes that rewrite checkout SHALL restate coupon redemption, cancellation restore, and this exclusion.
- Add shop-admin template management and replace the mall coupon fixtures with live data, checkout selection, and order-surface discount display.
## Capabilities
### New Capabilities
- `shop-coupons`: Shop coupon issuance, customer claims, eligibility, redemption, and cancellation restoration.
### Modified Capabilities
- `order`: Checkout accepts per-shop coupon selections and persists the resulting discount.
- `frontend-mall`: Coupon surfaces and checkout use the shared live contract.
- `frontend-shop-admin`: Merchants manage coupons only within their own shop.
## Non-goals
Threshold reductions, stacking rules across promotion types, platform coupons, campaign targeting, and voucher transfers are excluded. Activity-priced checkout (flash sales, group buying) is out of this change; those later changes must reject coupons on activity-priced shop orders rather than leave stacking undefined.
## Impact
Adds Rust coupon module and migrations, shared types/API/locales, mall and shop-admin screens, and a checkout request/response contract change.
@@ -0,0 +1,16 @@
## ADDED Requirements
### Requirement: Live shop coupon surfaces
The mall SHALL load a product's currently claimable shop coupons and the signed-in customer's coupon list through the shared API client, replacing direct coupon fixtures. Checkout SHALL present eligible owned coupons separately for each shop order and show the server-returned discount after submission. Payment and order-detail views SHALL show the persisted `discount_minor` and post-discount total from the order payload.
#### Scenario: claim from a product page
- **WHEN** a signed-in shopper claims an active coupon with remaining stock from a product page
- **THEN** it appears in the shopper's coupon list without a fixture import
#### Scenario: select coupon by shop
- **WHEN** a cart spans two shops and the shopper selects a coupon for one shop
- **THEN** checkout submits the choice only for that shop and the other shop remains undiscounted
#### Scenario: order shows realized discount
- **WHEN** checkout succeeds with a coupon on one shop order
- **THEN** that order's payment and detail views show the server discount and reduced total without a client-calculated amount
@@ -0,0 +1,12 @@
## ADDED Requirements
### Requirement: Merchant coupon management
Shop users SHALL manage only their shop's coupon templates from shop-admin, including localized title, amount and threshold in minor units, currency, claim stock, active window, and enabled state. Shop-admin SHALL expose a coupons navigation entry beside existing shop operations.
#### Scenario: create an active template
- **WHEN** a shop owner creates a valid coupon template
- **THEN** the template is available for eligible customers to claim during its active window
#### Scenario: coupons appear in shop navigation
- **WHEN** an authenticated shop user opens shop-admin
- **THEN** a coupons management entry is reachable without leaving the shop-scoped console
@@ -0,0 +1,31 @@
## MODIFIED Requirements
### Requirement: Checkout splits by shop
`POST /api/orders/checkout` SHALL create one order per distinct shop in the cart, in a single database transaction: stock decrement, order + item insert with price snapshots, cart clear, and requested coupon redemption. All amounts use the cart's SKU currencies converted into the buyer-chosen display currency at checkout time. The request MAY select at most one owned coupon per generated shop order; the server SHALL validate its ownership, shop, status, validity window, threshold, and converted amount, then persist `coupon_id`, `discount_minor`, and the post-discount `total_minor`. The client SHALL NOT provide a discount amount. This requirement does not define activity prices; a later flash-sale or group-buying change that rewrites checkout SHALL restate these coupon clauses and SHALL reject a coupon on any shop order that applied an activity price.
#### Scenario: two shops → two orders
- **WHEN** the cart contains SKUs from shops A and B
- **THEN** two orders are created, each with only its shop's items, and the cart is empty
#### Scenario: insufficient stock
- **WHEN** any line's qty exceeds SKU stock
- **THEN** the whole checkout returns 409 and no order is created and stock is unchanged
#### Scenario: coupon applies to one matching shop
- **WHEN** the customer selects an eligible coupon issued by shop A for a cart containing shops A and B
- **THEN** only shop A's order records that coupon and its server-calculated discount
### Requirement: Order lifecycle
Status transitions SHALL be: pending_payment → paid → fulfilling → shipped → completed; cancellable only from pending_payment, which MUST restore stock and restore a redeemed coupon to claimed status when the order has one.
#### Scenario: cancel restores stock
- **WHEN** a customer cancels a pending_payment order
- **THEN** stock of each SKU increases by the ordered qty and status is cancelled
#### Scenario: cancel restores coupon
- **WHEN** a customer cancels a pending_payment order with a redeemed coupon
- **THEN** the coupon is detached from the order and becomes claimed
#### Scenario: illegal transition rejected
- **WHEN** cancelling a paid order via the customer endpoint
- **THEN** the API returns 409
@@ -0,0 +1,22 @@
## ADDED Requirements
### Requirement: Shop coupon template management
A shop_owner or shop_staff SHALL create, list, update, disable, and delete only that shop's coupon templates. A template SHALL contain localized title, fixed `amount_minor`, `threshold_minor`, enabled ISO currency, non-negative claim stock, and an inclusive active window; amount MUST be positive and threshold MUST NOT be negative.
#### Scenario: cross-shop template is hidden
- **WHEN** a shop user requests or mutates another shop's coupon template
- **THEN** the API returns 404
### Requirement: Customer coupon claim and listing
An authenticated customer SHALL list owned coupons and claim an active template at most once. Claiming SHALL atomically create a user-held snapshot and decrement claim stock only when stock remains; listing SHALL distinguish claimed, redeemed, and expired coupons.
#### Scenario: concurrent final claim
- **WHEN** two customers claim the final remaining template instance concurrently
- **THEN** exactly one claim succeeds and the other receives 409
### Requirement: Coupon redemption lifecycle
A claimed coupon SHALL be redeemable only by its owner, for its issuing shop, within its snapshot window, and when the shop-order subtotal reaches its snapshot threshold. Redemption SHALL bind the coupon to one order; cancellation of that pending-payment order SHALL restore it to claimed. Later activity-priced shop orders (flash sale or group buy) SHALL NOT redeem a coupon; those changes restated this exclusion on checkout.
#### Scenario: cancelled order restores coupon
- **WHEN** a customer cancels a pending-payment order redeemed with a coupon
- **THEN** the coupon again has claimed status and no order binding
@@ -0,0 +1,23 @@
## 1. Coupon domain and contract
- [x] 1.1 Add additive Postgres migrations for coupon templates, owned coupon snapshots, order coupon binding, and realized discount minor values.
- [x] 1.2 Implement the Rust coupon module (DTOs, repository, service, handlers, router registration) with customer and shop RBAC/ownership boundaries.
- [x] 1.3 Add `@vmall/shared` coupon types, API-client methods, localized UI strings, and matching fixed-data adapter methods.
## 2. Atomic claims and checkout redemption
- [x] 2.1 Implement conditional template stock claim and per-customer duplicate protection with valid-window checks.
- [x] 2.2 Extend checkout request handling with per-shop coupon choices, server-side currency conversion and threshold validation, and persisted discounts.
- [x] 2.3 Restore redeemed coupons atomically with stock when a pending-payment order is cancelled.
- [x] 2.4 Add API integration coverage for claim contention, cross-shop/expired/ineligible rejection, multi-shop redemption, and cancellation restoration.
## 3. Merchant and customer surfaces
- [x] 3.1 Add shop-admin coupon-template management and a coupons nav entry scoped to the authenticated shop.
- [x] 3.2 Replace mall product-detail coupon strip, buyer coupon list, and checkout coupon selection with shared live-client calls; show persisted discount on payment and order-detail views.
- [x] 3.3 Remove the affected direct coupon fixture imports while retaining the complete fixed-data adapter rollback surface.
## 4. Verification and specification
- [x] 4.1 Seed deterministic coupon templates and verify the live claim-to-checkout-to-cancel flow against the API.
- [x] 4.2 Run cargo test for vmall-api, builds for mall and shop-admin, and strict validation for this OpenSpec change.