Files
vmall/openspec/specs/order/spec.md
T

111 lines
8.6 KiB
Markdown

# 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, and requested coupon redemption 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. For each remaining cart line without group intent the server SHALL resolve an eligible active flash-sale item as specified by flash-sales, snapshot the final unit price and activity identity, and charge standard price for ineligible quantity. The request MAY select at most one owned coupon per generated shop order; the server SHALL persist `coupon_id`, `discount_minor`, and post-discount `total_minor` only after ownership, shop, status, window, and threshold validation. The client SHALL NOT provide a discount amount. The server SHALL reject a coupon (409) for any shop order that applied flash-sale pricing or carries group-buy intent.
#### 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
### Requirement: Order lifecycle
Status transitions SHALL be: pending_payment → paid → fulfilling → shipped → completed; cancellable only from pending_payment, which MUST restore SKU stock, any reserved flash-sale activity stock consumed by the order, and a redeemed coupon to claimed status when the order has one. Pending-payment cancellation SHALL NOT change group paid-member counts. When the cancelled order opened the group and paid member count is zero, the group SHALL become cancelled. Payment of an order with group-buying intent SHALL also validate and atomically claim an open group seat; full, expired, or cancelled groups SHALL return 409 and leave the order pending_payment.
#### Scenario: group payment wins final seat
- **WHEN** payment fills the final available group seat
- **THEN** the payment succeeds and the group state becomes successful
#### Scenario: cancel restores stock
- **WHEN** a customer cancels a pending_payment group-intent order
- **THEN** SKU stock increases by the ordered qty, status is cancelled, and no paid group membership is removed
#### Scenario: unpaid opener cancel closes an empty group
- **WHEN** the opening pending_payment order is cancelled and the group has zero paid members
- **THEN** the group status becomes cancelled and a later payment against that group returns 409
#### 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
### 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_minor` equals 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