feat(aftersale): per-line refund/return flow with ledger-backed completion (add-aftersale-refunds)

This commit is contained in:
Chengdong Zhang
2026-09-23 16:56:30 +08:00
parent 93e5a05d48
commit 2c2b54c21c
43 changed files with 4889 additions and 32 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ P2 freight ───────────┘(改 checkout/order totals,
| # | Change | 依赖 | 状态 | 归档日期 |
|---|---|---|---|---|
| P0 | `add-aftersale-refunds` | — | proposed | — |
| P0 | `add-aftersale-refunds` | — | archived | 2026-09-23 |
| P1 | `add-product-reviews` | — | proposed | — |
| P2 | `add-freight-templates` | —(与 P0 串行) | proposed | — |
| P3 | `add-wallet-settlement` | P0 | proposed | — |
@@ -1,31 +1,31 @@
## 1. Persistence and shared contract
- [ ] 1.1 Add migration `0017_aftersales.sql` with `aftersales` and append-only `aftersale_messages` tables, order/customer/shop/order-item foreign keys, `refund_only`/`return_refund` type checks, status checks, i64 minor-unit amounts, evidence URL arrays, localized JSONB reason/content fields, timestamps, ownership indexes, and a partial unique index allowing only one active aftersale per `order_item_id`.
- [ ] 1.2 Add shared aftersale enums, DTOs, paginated summaries, state/action types, bilingual reason/message fields, and API client methods for customer, shop-admin, and platform-admin flows. Keep all money as integer minor units and expose `refund_total_minor`/remaining refundable amounts from the contract.
- [x] 1.1 Add migration `0016_aftersales.sql` with `aftersales` and append-only `aftersale_messages` tables, order/customer/shop/order-item foreign keys, `refund_only`/`return_refund` type checks, status checks, i64 minor-unit amounts, evidence URL arrays, localized JSONB reason/content fields, timestamps, ownership indexes, and a partial unique index allowing only one active aftersale per `order_item_id`.
- [x] 1.2 Add shared aftersale enums, DTOs, paginated summaries, state/action types, bilingual reason/message fields, and API client methods for customer, shop-admin, and platform-admin flows. Keep all money as integer minor units and expose `refund_total_minor`/remaining refundable amounts from the contract.
## 2. Rust aftersale module and behavioral proof
- [ ] 2.1 Implement `apps/api/src/modules/aftersale/` repository, service, DTO, handlers, routes, and module registration for customer applications/list/detail/cancel/reopen/messages/return tracking, shop processing, and platform arbitration.
- [ ] 2.2 Enforce paid-or-shipped order-item eligibility, configured post-confirmation window, amount not exceeding the guarded remaining refundable amount, evidence URL validation, one active aftersale per line, customer ownership, `own_shop` scope for shop roles, and platform-admin arbitration.
- [ ] 2.3 Implement guarded state transitions: `pending` to `approved` or `rejected`; return-refund `approved` to `buyer_shipping` to `merchant_confirmed` to `refunded`; cancellation from any non-terminal pending state; one rejected-to-`pending` reopen appeal. Reject stale transitions with 409.
- [ ] 2.4 Complete refunds transactionally: conditionally update the aftersale and order refund total, append one `customer_accounts` ledger credit, emit the hookable `refund_completed` event, and make retries idempotent so no duplicate credit is possible.
- [ ] 2.5 Add focused API integration tests under `apps/api/tests/` reusing `tests/common/mod.rs` for eligibility/window boundaries, amount guards, uniqueness, ownership/RBAC, every transition and illegal transition, one-time reopen, messages, ledger/order totals, concurrent completion, and idempotent retry.
- [x] 2.1 Implement `apps/api/src/modules/aftersale/` repository, service, DTO, handlers, routes, and module registration for customer applications/list/detail/cancel/reopen/messages/return tracking, shop processing, and platform arbitration.
- [x] 2.2 Enforce paid-or-shipped order-item eligibility, configured post-confirmation window, amount not exceeding the guarded remaining refundable amount, evidence URL validation, one active aftersale per line, customer ownership, `own_shop` scope for shop roles, and platform-admin arbitration.
- [x] 2.3 Implement guarded state transitions: `pending` to `approved` or `rejected`; return-refund `approved` to `buyer_shipping` to `merchant_confirmed` to `refunded`; cancellation from any non-terminal pending state; one rejected-to-`pending` reopen appeal. Reject stale transitions with 409.
- [x] 2.4 Complete refunds transactionally: conditionally update the aftersale and order refund total, append one `customer_accounts` ledger credit, emit the hookable `refund_completed` event, and make retries idempotent so no duplicate credit is possible.
- [x] 2.5 Add focused API integration tests under `apps/api/tests/` reusing `tests/common/mod.rs` for eligibility/window boundaries, amount guards, uniqueness, ownership/RBAC, every transition and illegal transition, one-time reopen, messages, ledger/order totals, concurrent completion, and idempotent retry.
## 3. Mall customer surfaces and adapter parity
- [ ] 3.1 Add the aftersale methods and deterministic mutable fixtures to `apps/mall/mock/api.ts`, preserving the shared contract for applications, messages, cancellation, reopen, return tracking, and refund totals.
- [ ] 3.2 Add the aftersale domain and exact method picks to Mall `LIVE_PICKS`; keep fixed fallback behavior and localized en/zh labels in `@vmall/shared`.
- [ ] 3.3 Add Mall order-detail eligible-item entry, aftersale list/detail, reason/amount/evidence form, bilateral message thread, cancel/reopen actions, and return-shipping tracking form, with sign-in redirect and reload-safe state.
- [x] 3.1 Add the aftersale methods and deterministic mutable fixtures to `apps/mall/mock/api.ts`, preserving the shared contract for applications, messages, cancellation, reopen, return tracking, and refund totals.
- [x] 3.2 Add the aftersale domain and exact method picks to Mall `LIVE_PICKS`; keep fixed fallback behavior and localized en/zh labels in `@vmall/shared`.
- [x] 3.3 Add Mall order-detail eligible-item entry, aftersale list/detail, reason/amount/evidence form, bilateral message thread, cancel/reopen actions, and return-shipping tracking form, with sign-in redirect and reload-safe state.
## 4. Merchant and platform console surfaces
- [ ] 4.1 Add shop-admin aftersale workspace with shop-scoped list/detail filters, item and evidence display, bilingual messages, approve/reject actions, return-receipt-and-refund action, and guarded error states.
- [ ] 4.2 Add platform-admin read-only aftersale list/detail and dispute arbitration actions for terminal refund or rejection, with role-protected routes and visible ledger/order-total outcomes.
- [ ] 4.3 Route all three frontends through `@vmall/shared` contracts and `@vmall/ui` primitives; do not duplicate API types or bypass `own_shop` authorization.
- [x] 4.1 Add shop-admin aftersale workspace with shop-scoped list/detail filters, item and evidence display, bilingual messages, approve/reject actions, return-receipt-and-refund action, and guarded error states.
- [x] 4.2 Add platform-admin read-only aftersale list/detail and dispute arbitration actions for terminal refund or rejection, with role-protected routes and visible ledger/order-total outcomes.
- [x] 4.3 Route all three frontends through `@vmall/shared` contracts and `@vmall/ui` primitives; do not duplicate API types or bypass `own_shop` authorization.
## 5. Verification
- [ ] 5.1 Run the aftersale integration tests in `apps/api/tests/` with the shared `tests/common/mod.rs` fixtures and exercise customer, merchant, and platform paths.
- [ ] 5.2 Build affected frontends: `pnpm --filter @vmall/mall build`, `pnpm --filter @vmall/shop-admin build`, and `pnpm --filter @vmall/admin build`.
- [ ] 5.3 Browser-smoke the Mall application/detail/message/cancel/return-tracking/refund-history flow, shop-admin processing, and platform arbitration against the local stack, including fixed-adapter fallback.
- [ ] 5.4 Run `openspec change validate add-aftersale-refunds --strict` and `openspec validate --all --strict`.
- [x] 5.1 Run the aftersale integration tests in `apps/api/tests/` with the shared `tests/common/mod.rs` fixtures and exercise customer, merchant, and platform paths.
- [x] 5.2 Build affected frontends: `pnpm --filter @vmall/mall build`, `pnpm --filter @vmall/shop-admin build`, and `pnpm --filter @vmall/admin build`.
- [x] 5.3 Browser-smoke the Mall application/detail/message/cancel/return-tracking/refund-history flow, shop-admin processing, and platform arbitration against the local stack, including fixed-adapter fallback.
- [x] 5.4 Run `openspec change validate add-aftersale-refunds --strict` and `openspec validate --all --strict`.
+83
View File
@@ -0,0 +1,83 @@
# aftersale Specification
## Purpose
TBD - created by archiving change add-aftersale-refunds. Update Purpose after archive.
## Requirements
### Requirement: Per-line aftersale application
An authenticated customer SHALL apply for after-sale against one owned order item from an order that is paid or shipped and within the configured after-sale window. The application SHALL choose exactly `refund_only` or `return_refund`, include a localized reason, an integer minor-unit refund amount greater than zero and no greater than the line's remaining refundable amount, and zero or more evidence image URLs. The API SHALL reject unavailable, already fully refunded, out-of-window, or cross-customer items.
#### Scenario: customer requests a partial refund
- **WHEN** a customer submits a `refund_only` application for a paid order item with an amount within the remaining refundable minor-unit balance
- **THEN** one `pending` aftersale is created with the customer, order, shop, and item ownership captured
#### Scenario: amount cannot exceed the line balance
- **WHEN** the requested amount is greater than the order item's paid amount less previously completed refunds
- **THEN** the API returns 409 and creates no application
#### Scenario: evidence URLs are retained
- **WHEN** a customer submits evidence image URLs with an application
- **THEN** the detail response returns the same validated URL list without storing binary image data
### Requirement: Guarded aftersale state machine
Aftersales SHALL use guarded status transitions: `pending` to `approved` or `rejected`; for `refund_only`, `approved` SHALL transition to `refunded` through the ledger-backed completion path; `return_refund` SHALL proceed from `approved` to `buyer_shipping` to `merchant_confirmed` to `refunded`. A customer MAY cancel any non-terminal pending state before refund completion. A rejected application MAY be reopened to `pending` at most once. Every transition SHALL condition its update on the expected previous status and return 409 for stale or illegal actions.
#### Scenario: merchant approves a return
- **WHEN** the owning shop approves a pending `return_refund` application
- **THEN** its status becomes `approved` and the customer can submit return tracking
#### Scenario: merchant confirms returned goods
- **WHEN** the owning shop confirms receipt for a `buyer_shipping` application and completes the refund action
- **THEN** the service records the `merchant_confirmed` step and reaches `refunded` only through the guarded refund-completion transaction
#### Scenario: buyer cancels before completion
- **WHEN** the customer cancels a still-pending aftersale before refund completion
- **THEN** the status becomes `cancelled` and a later merchant transition returns 409
#### Scenario: rejected appeal is limited
- **WHEN** a customer reopens a rejected application for the first time
- **THEN** it returns to `pending`; a second reopen attempt returns 409
### Requirement: Unique active aftersale per order item
The persistence layer SHALL permit at most one active aftersale for an order item at a time. Active statuses SHALL include every non-terminal application state, while `refunded`, `rejected`, and `cancelled` records remain historical. A reopened rejection SHALL reuse its record and count as the one active application.
#### Scenario: duplicate active application
- **WHEN** two requests concurrently apply for aftersale on the same order item
- **THEN** at most one succeeds and the other returns 409 without a second active row
#### Scenario: historical record does not block a new request
- **WHEN** a prior application is rejected or cancelled and no other active application exists
- **THEN** the customer can create a new application subject to the remaining refundable amount and window
### Requirement: Bilateral aftersale messages
Customers and the owning shop's authorized users SHALL append messages to an aftersale message log as buyer or merchant, with localized JSONB content, optional evidence URLs, and immutable author/timestamp metadata. Readers SHALL be limited to the customer, the owning shop under `own_shop`, and platform administrators.
#### Scenario: buyer adds a message
- **WHEN** the customer posts a message on their aftersale
- **THEN** the message is appended and appears in chronological detail history
#### Scenario: unrelated shop cannot read messages
- **WHEN** a shop user requests an aftersale belonging to another shop
- **THEN** the API returns 404 or 403 and reveals no message content
### Requirement: Ledger-backed refund completion
A refund completion SHALL run in one transaction with a guarded status update, a guarded increment of the order's integer `refund_total_minor`, and one append-only available-balance credit in `customer_accounts` using the order currency and aftersale reference. The `refund_completed` hook SHALL be emitted after commit. Retrying a completed action SHALL not create another ledger entry or increase the order total twice.
#### Scenario: completed refund credits the customer
- **WHEN** a valid aftersale reaches `refunded`
- **THEN** the customer's account balance and immutable ledger entry increase by exactly the requested minor-unit amount, and the order refund total increases by the same amount
#### Scenario: concurrent completion is idempotent
- **WHEN** two workers attempt to complete the same merchant-confirmed aftersale
- **THEN** one guarded transition performs the credit and the other returns the already-completed result without a duplicate entry
### Requirement: Optional platform arbitration
A platform administrator SHALL view aftersale applications across shops and MAY resolve an escalated application with a terminal refund or rejection. Arbitration SHALL enforce the same ownership-independent guarded transitions, amount limits, order-total and ledger invariants, and immutable audit/message record as merchant processing.
#### Scenario: platform grants a disputed refund
- **WHEN** a platform administrator resolves an eligible dispute in the customer's favor
- **THEN** the application reaches `refunded` through the same ledger-backed completion path
#### Scenario: platform rejects a dispute
- **WHEN** a platform administrator rejects an escalated application
- **THEN** it reaches terminal `rejected` and cannot be refunded without the one permitted customer reopen
+15
View File
@@ -72,3 +72,18 @@ Platform admins SHALL manage the ordered brand registry on a brands page reachab
- **WHEN** a save succeeds or the API refuses the list
- **THEN** the page shows a success confirmation or keeps the edited rows with a failure message, respectively
### Requirement: Platform aftersale monitoring and arbitration
The platform admin console SHALL provide a read-only cross-shop aftersale list and detail view with customer, shop, order-item, amount, evidence, status, message history, and refund-total context. A platform administrator SHALL be able to resolve an escalated dispute with a terminal refund or rejection through the guarded service path, without directly editing balances or bypassing shop ownership checks.
#### Scenario: admin inspects a cross-shop application
- **WHEN** a platform administrator opens the aftersale workspace
- **THEN** applications from all shops are listed with filters and authoritative status and refund amounts
#### Scenario: admin grants terminal refund
- **WHEN** a platform administrator resolves an eligible dispute in favor of the customer
- **THEN** the aftersale reaches refunded, the customer ledger is credited once, and the order refund total refreshes from the API
#### Scenario: admin rejects terminal dispute
- **WHEN** a platform administrator rejects an escalated dispute
- **THEN** the aftersale reaches rejected and the console shows that only the permitted one-time customer reopen can resume it
+23
View File
@@ -240,3 +240,26 @@ Mall pages SHALL style layout with Tailwind utilities and shared primitives. Mal
- **WHEN** a shopper views the home page
- **THEN** brand red, the 1200px grid, and the 450px hero height come from the shared theme keys rather than `mall.css`
### Requirement: Mall aftersale customer flow
The Mall SHALL use the shared selected API adapter to expose an after-sale action for eligible order items, an aftersale list and detail view, chronological buyer/merchant messages, cancellation and one-time rejected appeal, and return-refund shipping tracking. The form SHALL show the localized reason/type, integer minor-unit amount and remaining limit, evidence URL inputs, current status, and authoritative refund result. Anonymous actions SHALL redirect to sign-in with the current route as return destination.
#### Scenario: apply from an eligible order line
- **WHEN** a signed-in shopper opens a paid or shipped order inside its after-sale window and submits a valid item application
- **THEN** the Mall creates the aftersale through the shared client and shows its pending status without fixture-only state
#### Scenario: return shipping is recorded
- **WHEN** an approved return-refund shopper submits carrier and tracking data
- **THEN** the detail view shows the buyer-shipping status and the persisted tracking information after reload
#### Scenario: shopper cancels an application
- **WHEN** a shopper cancels a non-terminal pending aftersale
- **THEN** the API state and list/detail views show cancelled and merchant actions are no longer offered
#### Scenario: shopper messages the merchant
- **WHEN** a shopper sends a localized message with optional evidence URLs
- **THEN** it appears in chronological detail history and remains after a reload
#### Scenario: fixed adapter remains usable
- **WHEN** the aftersale domain is configured for fixed data
- **THEN** Mall list, detail, messages, cancellation, tracking, and refund-history flows behave deterministically through the same shared methods
@@ -83,3 +83,22 @@ Shop users SHALL edit their own shop's profile from a shop-admin page reachable
- **WHEN** the API refuses an update
- **THEN** the page keeps the entered values and shows a failure message
### Requirement: Shop-scoped aftersale workspace
Shop-admin SHALL provide an aftersale list and detail workspace filtered to the authenticated shop's `own_shop` resources. Authorized shop users SHALL inspect order-item evidence and messages, approve or reject pending applications, and confirm returned goods with the guarded refund action. The UI SHALL show status transitions, remaining amount, ledger-backed refund result, and stale-action errors through the shared API contract.
#### Scenario: merchant approves a request
- **WHEN** a shop user opens a pending application for an item belonging to their shop and approves it
- **THEN** the status advances according to the selected aftersale type and the customer can see the persisted result
#### Scenario: merchant confirms return and refunds
- **WHEN** a shop user confirms receipt of a buyer-shipped return
- **THEN** the service records merchant confirmation, credits the customer's account once, and displays the refunded status and order total
#### Scenario: shop scope is enforced
- **WHEN** a shop user requests or mutates an aftersale for another shop
- **THEN** the API denies the operation and the workspace exposes no cross-shop data
#### Scenario: merchant messages buyer
- **WHEN** an authorized shop user appends a localized message
- **THEN** the message appears in the same chronological aftersale thread visible to the buyer
+27 -2
View File
@@ -2,9 +2,7 @@
## 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.
@@ -65,3 +63,30 @@ Customers SHALL see only their own orders; shop roles only their shop's orders;
#### 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