Shop-scoped threshold rules with bilingual names, integer threshold/reduction (reduction < threshold), active windows and display order. Checkout applies the best eligible rule per shop with currency conversion; coupon/threshold/activity pricing are mutually exclusive per shop order. Reduction persists in orders.discount_minor + threshold_promotion_id and flows into payment totals; checkout idempotency covers the threshold selection. Delete archives referenced rules, hard-deletes unreferenced ones. - migration 0026: threshold_promotions + orders.threshold_promotion_id + coupon/threshold exclusivity CHECK - api: modules/promotion (CRUD, preview, checkout integration) - shared: ThresholdPromotion types, shop CRUD + preview client methods, checkout threshold_by_shop - shop-admin: promotions page + nav + en/zh locales - mall: exclusive promotion radio on checkout, live preview wiring, threshold label on order detail - tests: promotions.rs (scoping, validation, best-rule, exclusivity, activity-priced 409, totals, replay, archive-vs-delete) - openspec: archived 2026-10-19-add-threshold-promotions; spec threshold-promotions created, order/frontend specs updated
10 KiB
order Specification
Purpose
Checkout splitting a cart into per-shop orders, plus order lifecycle and ownership.
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, active flash-sale resolution when no group-buy intent applies to the SKU, requested group-buying intent validation, requested coupon redemption when eligible, and requested threshold-promotion application when eligible. All amounts use the cart's SKU currencies converted into the buyer-chosen display currency at checkout time. A group-buy intent SHALL contain exactly one active activity SKU at quantity 1 and either an open group identifier or a request to open a group; the server SHALL snapshot the group price and group identity on the resulting pending-payment order. Group intent SHALL return 409 when that SKU is also eligible for an overlapping flash sale. Each shop order SHALL apply at most one of coupon, threshold promotion, or activity pricing; conflicting selections SHALL return 409 and create no orders. Checkout idempotency hashes SHALL cover the coupon and threshold selections so a changed selection replays as 409 and an identical selection replays the durable result.
Scenario: two shops → two orders
- WHEN the cart contains SKUs from shops A and B without group-buy intent
- 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 or applicable flash-sale stock
- THEN the whole checkout returns 409 and no order is created and stock is unchanged
Scenario: active flash price applied
- WHEN checkout contains a SKU with an active eligible flash-sale item and no group-buy intent applies to it
- THEN the order item records the server-calculated flash-sale price and activity identity
Scenario: coupon rejected on a flash-priced shop order
- WHEN the customer selects a coupon for a shop whose generated order applied flash-sale pricing on any line
- THEN checkout returns 409 and creates no orders
Scenario: open a group at checkout
- WHEN a customer checks out one eligible group-buying SKU at quantity 1 with open-group intent
- THEN a pending-payment order and an open group identity are created with the server-resolved group price
Scenario: coupon rejected on a group shop order
- WHEN the customer selects a coupon for the shop that carries group-buy intent
- THEN checkout returns 409 and creates no orders
Scenario: coupon applies to one matching shop
- WHEN the customer selects an eligible shop coupon and that shop's generated order has no flash-priced lines and no group-buy intent
- THEN the order records that coupon and its server-calculated discount
Scenario: threshold applies to one matching shop
- WHEN the customer selects an eligible threshold rule and that shop's generated order has no activity-priced lines, no group-buy intent and no selected coupon
- THEN the order records that rule and its server-calculated reduction
Scenario: coupon and threshold conflict
- WHEN the customer selects both a coupon and a threshold rule for the same shop
- THEN checkout returns 409 and creates no orders
Requirement: Order lifecycle
Status transitions SHALL be pending_payment → paid → fulfilling → shipped → completed. An order SHALL become paid only through application of its payment's funded receipt, with all member orders applied atomically. Cancellation SHALL be allowed only from pending_payment when the payment has no unresolved or applied collection; cancelling one member SHALL cancel its entire unpaid payment group atomically and restore SKU stock, reserved flash-sale stock and redeemed coupons exactly once. Unknown provider outcomes SHALL block cancellation with 409. Pending-payment cancellation SHALL NOT change group paid-member counts. When a cancelled order opened a group with zero paid members, that group SHALL become cancelled. Payment SHALL atomically validate and claim any required open group seats. A wallet attempt against a full, expired or cancelled group SHALL return 409 without collection or order changes. External collection that cannot claim a seat SHALL retain its receipt, leave every member unpaid and schedule compensation rather than imply funds were rolled back.
Scenario: group payment wins final seat
- WHEN funded payment claims the final available group seat
- THEN all member orders become paid together and the group becomes successful
Scenario: cancel restores stock
- WHEN a customer cancels a pending-payment group whose attempts are conclusively non-collecting
- THEN all member orders become cancelled, their stock is restored once and no paid group membership is removed
Scenario: unpaid opener cancel closes an empty group
- WHEN a cancelled payment contains an opening order whose group has zero paid members
- THEN that group becomes cancelled and later collection cannot apply an order against it
Scenario: cancel restores coupon
- WHEN a safely cancelled payment includes a coupon-bearing order
- THEN the coupon is detached from that order and restored to claimed once
Scenario: illegal transition rejected
- WHEN a customer tries to cancel an already paid order through the cancellation endpoint
- THEN the API returns 409 and no payment, stock or coupon state changes
Scenario: timeout blocks cancellation
- WHEN an external attempt has an unknown collection outcome
- THEN cancelling any member returns 409 until authoritative reconciliation establishes a safe outcome
Requirement: Order ownership
Customers SHALL see only their own orders; shop roles only their shop's orders; platform_admin sees all.
Scenario: cross-customer read denied
- WHEN customer X requests customer Y's order id
- THEN the API returns 404
Requirement: Order aftersale eligibility and refund summary
Order and order-item responses SHALL expose the configured after-sale deadline after confirmation of delivery, whether each line has an active aftersale, its remaining refundable integer minor-unit amount, and the order's authoritative refund_total_minor. The API SHALL allow applications for paid or shipped orders while the configured window is open, and SHALL reject new applications after the deadline. The refund summary SHALL be sourced from completed aftersales records and updated only by a guarded refund-completion transaction.
Scenario: confirmed order remains eligible during the window
- WHEN a customer views a completed order before the configured N-day after-sale window expires
- THEN the order detail includes an after-sale deadline and each refundable line exposes its remaining amount and application action
Scenario: expired window hides the action
- WHEN the configured after-sale deadline has passed
- THEN the order and item responses mark after-sale unavailable and an application returns 409
Scenario: order refund total is authoritative
- WHEN an aftersale completes a refund
- THEN the order response's
refund_total_minorequals the sum of completed aftersale amounts and is not client-calculated
Requirement: Order item ownership and state safety
After-sale eligibility SHALL resolve the order item through the authenticated customer's order ownership (or the owning shop's own_shop scope for merchant actions), and every refund total update SHALL use a conditional status/amount guard so concurrent completions cannot refund more than the paid order amount.
Scenario: cross-customer item is hidden
- WHEN a customer submits another customer's order item id
- THEN the API returns 404 without disclosing order or refund data
Scenario: concurrent refunds stay within paid amount
- WHEN concurrent aftersales would make completed refunds exceed the line or order paid amount
- THEN the guarded update rejects the excess completion and stored refund totals remain within the paid amount
Requirement: Checkout computes per-shop shipping fees
The checkout request SHALL carry the destination address, and in the same transaction that creates the shop orders the server SHALL compute each shop order's shipping fee from the shipping capability's resolution and calculation rules and add it to that order's total. Per-shop fees and their sum SHALL be returned in the checkout response. Client-supplied shipping fees or totals SHALL NOT be persisted.
Scenario: two shops pay two fees
- WHEN a checkout covers two shops whose templates both charge shipping to the destination
- THEN each created order carries its own computed shipping fee and the response reports both plus the sum
Scenario: address change recomputes
- WHEN the buyer submits the same cart to a destination whose region rule raises the fee
- THEN the created orders' shipping fees reflect the new destination rather than any earlier quote
Requirement: Order shipping and pricing snapshots
Each order SHALL persist its shipping fee in i64 minor units alongside its other amounts, and each order item SHALL snapshot the freight pricing method and template applied at checkout. Order detail, payment, and history views SHALL read these snapshots so later template edits cannot change historical orders.
Scenario: detail shows the persisted fee
- WHEN a customer or shop user opens an order created before a template's fees changed
- THEN the order detail shows the shipping fee and item pricing methods snapshotted at checkout
Requirement: Payment-backed checkout contract
Checkout SHALL create its payment and immutable per-shop order memberships in the same transaction as the existing stock, pricing, coupon and order writes, and SHALL return {orders, payment_id}. Payment detail SHALL expose the original complete group to the owning buyer across reloads. The status-only per-order pay endpoint SHALL be removed; payment initiation SHALL use the payment contract and the selected configured channel. A member's order-detail action SHALL resolve its payment, not independently charge a subset.
Scenario: checkout returns recoverable payment
- WHEN a two-shop checkout succeeds and the browser reloads
- THEN its payment id resolves both orders and their server-snapshotted totals without an in-memory order-id list