Files
vmall/openspec/changes/add-shop-coupons/design.md
T
Chengdong ZhangandCursor 53548196f8 docs(openspec): plan shop coupons, accounts, points, flash sales, and group buying
Capture exclusive promotion composition, empty-group cancel, and mall surface scope so the five marketing changes can be implemented without stacking or lifecycle ambiguity.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 17:32:08 +08:00

3.4 KiB

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.