feat(api): group buying with payment-time seat claims

Shop-owned activities on one SKU, concrete groups with an
open/successful/expired/cancelled lifecycle, and one paid membership row per
paid order. Checkout accepts a single-SKU quantity-1 intent, snapshots the group
price and identity on the pending order, and opens or references a group; a paid
seat is claimed only at payment, which locks the group and fills it exactly at
capacity.

Pending-payment cancellation restores SKU stock only and never rolls back paid
seats; an unpaid opener cancelling closes a still-empty group. Coupons are
refused on a group shop order, and the flash-sale exclusion is now enforced in
both directions because the activity table exists, which activates the guard
add-flash-sales shipped dormant. The activity column names follow the contract
recorded in this change's design.

Surfaces (shop-admin, mall) and seeding follow.
This commit is contained in:
2026-09-18 13:16:28 +00:00
parent 705cbe249a
commit a7bc476251
15 changed files with 1563 additions and 48 deletions
@@ -17,6 +17,8 @@ The group-buying page presents fixed participant counts. Unlike a simple price r
### Activities and groups have separate state
`group_buying_activities` stores shop, SKU, localized presentation, group price minor/currency, required paid members, active window, and group lifetime. Each membership order MAY include the activity SKU at quantity 1 only. `collective_groups` references an activity and records leader order, paid-member count, expiry, and `open | successful | expired | cancelled` state. `collective_group_members` is one row per paid order and group, with a uniqueness constraint per order and customer membership policy.
**Column contract with the archived flash-sales guard:** the activity columns MUST be named `sku_id`, `enabled`, `starts_at`, and `ends_at`. `add-flash-sales` ships a dormant overlap check that queries exactly those names once this table exists; any other naming turns that guard into a runtime error on flash-item creation. Both directions of the exclusion are then live: flash rejects a SKU with an overlapping activity, and this change rejects an activity for a SKU with an overlapping flash item.
### Membership finalizes at payment, not cart checkout
Checkout accepts an optional group-buy intent for exactly one activity SKU at quantity 1 and snapshots the activity price plus group ID on the pending order. Opening creates an empty open group; joining references an open group. Unpaid orders do not occupy a paid seat. `pay` locks the group and membership rows, expires due groups first, conditionally claims a paid seat, and transitions the group to successful exactly when its paid count reaches the required count. A full, expired, or cancelled group leaves the order pending payment and returns Conflict.
+6 -6
View File
@@ -1,14 +1,14 @@
## 1. Group-buying state model and contract
- [ ] 1.1 Add additive migrations for shop activities, concrete groups, paid memberships, expiry/state constraints, and order group-price snapshots.
- [ ] 1.2 Implement the Rust group-buying module with shop-scoped activity management, active discovery, deterministic expiry, and open-group queries.
- [x] 1.1 Add additive migrations for shop activities, concrete groups, paid memberships, expiry/state constraints, and order group-price snapshots.
- [x] 1.2 Implement the Rust group-buying module with shop-scoped activity management, active discovery, deterministic expiry, and open-group queries.
- [ ] 1.3 Add shared group-buying types, checkout intent (single SKU, quantity 1), API-client methods, locales, and fixed-data adapter parity.
## 2. Checkout and payment integration
- [ ] 2.1 Validate a single-SKU quantity-1 group-buying intent during checkout, create or reference an open group, and snapshot group price and identity on the pending order. Reject overlapping flash eligibility and coupon selection on that shop order; restate coupon and flash handling for other shop orders in the same checkout.
- [ ] 2.2 Extend payment to atomically expire due groups, claim a paid seat, enforce capacity, and transition a filled group to successful. Pending-payment cancellation restores SKU stock only and does not roll back paid membership. Cancelling the opening unpaid order while paid count is zero marks the group cancelled.
- [ ] 2.3 Add integration tests for activity/shop validation, open versus join flow, quantity-1 enforcement, concurrent final-seat payment, full/expired/cancelled-group conflicts, coupon rejection on group shop orders, overlapping flash rejection, cancel-without-membership-rollback, unpaid-opener empty-group cancel, and visibility of expired paid groups.
- [x] 2.1 Validate a single-SKU quantity-1 group-buying intent during checkout, create or reference an open group, and snapshot group price and identity on the pending order. Reject overlapping flash eligibility and coupon selection on that shop order; restate coupon and flash handling for other shop orders in the same checkout.
- [x] 2.2 Extend payment to atomically expire due groups, claim a paid seat, enforce capacity, and transition a filled group to successful. Pending-payment cancellation restores SKU stock only and does not roll back paid membership. Cancelling the opening unpaid order while paid count is zero marks the group cancelled.
- [x] 2.3 Add integration tests for activity/shop validation, open versus join flow, quantity-1 enforcement, concurrent final-seat payment, full/expired/cancelled-group conflicts, coupon rejection on group shop orders, overlapping flash rejection, cancel-without-membership-rollback, unpaid-opener empty-group cancel, and visibility of expired paid groups.
## 3. Merchant and customer surfaces
@@ -19,4 +19,4 @@
## 4. Verification and specification
- [ ] 4.1 Seed an active deterministic group activity and browser-smoke discovery, opening, joining, and successful payment.
- [ ] 4.2 Run cargo test for vmall-api, builds for mall and shop-admin, and strict validation for this OpenSpec change.
- [ ] 4.2 Run cargo test for vmall-api, builds for mall and shop-admin, and strict validation for this OpenSpec change.