feat: wave 2 migration (P3, P5, P7 openspec changes)

Implements, verifies, and archives the three remaining Wave 2 changes from
openspec/MIGRATION-PLAN.md.

- add-wallet-settlement (P3): demo recharge, guarded withdrawal freeze and
  one-time admin review, paginated own fund entries, idempotent per-shop
  weekly/monthly settlement statements with commission rate and one-time
  payout confirmation.
- add-merchant-onboarding (P5): personal/enterprise applications with one live
  application per user, guarded review with mandatory rejection reason, and
  transactional shop + owner provisioning returning one-time credentials;
  mall onboarding/status pages and an admin review console.
- add-membership-messaging (P7): platform member levels, append-only growth
  accrual on order completion with guarded one-way leveling, order/shipment/
  refund system messages with unread/read state and soft deletion, plus the
  mall header unread badge.

Backend: migrations 0019-0023, new wallet, settlement, merchant_onboarding,
membership and messaging modules, event hooks in order/fulfillment/aftersale,
and integration suites for each. Shared contract extended and all three
frontends updated; code indexes, domain docs, backend guidelines and the
migration tracker synced.

Verification: cargo test -p vmall-api green twice consecutively; mall, admin
and shop-admin builds pass; browser smoke on every new surface; openspec
validate --all --strict green (33 passed).

The three changes share the @vmall/shared contract, the mall mock adapter and
per-app locale/nav files, so they are committed together to keep every commit
buildable.
This commit is contained in:
2026-09-25 15:25:29 +00:00
parent 772aafa3fb
commit 9904696e76
120 changed files with 14097 additions and 125 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-23
@@ -0,0 +1,28 @@
## Why
A shop can currently exist only when a platform admin creates it directly and assigns an owner by hand. There is no merchant-facing entry: prospective sellers cannot apply, nothing records their entity and qualification data, and admins have no review queue. As the 2B entry of the B2B2C mall, joining as a merchant must become an application-and-review flow instead of a manual admin back-office step.
## What Changes
- Add a public merchant onboarding application ("商家入驻") with two form kinds — personal (个人) and enterprise (企业) — covering entity information, operating categories, contact details, and qualification materials submitted as URL fields only.
- Require registration or sign-in before submission: the form can be filled anonymously, but only an authenticated user can submit, and a user holding a `pending` or `approved` application is deduplicated and rejected.
- Add a review state machine `pending -> approved | rejected`: platform admins review applications in a queue, rejection requires a reason, and approval creates the shop and a dedicated `shop_owner` account in the same transaction, issuing initial credentials displayed exactly once.
- Add a mall application status page so an applicant can track their own review state and rejection reason, with re-apply after rejection.
- Land the mall top-bar "商家入驻" entry on the onboarding page instead of the stores directory.
## Capabilities
### New Capabilities
- `merchant-onboarding`: Application submission with per-user deduplication, the review state machine, transactional approval provisioning with one-time credentials, and applicant/admin visibility.
### Modified Capabilities
- `frontend-mall`: Multi-step merchant onboarding form and application status page behind the top-bar "商家入驻" entry.
- `frontend-admin`: Merchant application list and review console with approve/reject actions.
## Non-goals
File upload storage, qualification OCR and real-name identity verification, onboarding fees, public notice (公示期) periods, supplier (non-shop) onboarding, and multiple active applications per user are excluded.
## Impact
Adds one Postgres migration (`merchant_applications`), a Rust merchant-onboarding module with customer and admin routes, shared types and API methods, a `merchant-onboarding` live-domain pick with fixed-adapter parity, Mall onboarding and status pages, and an admin review console. Approval reuses the existing identity and shop provisioning paths inside one transaction.
@@ -0,0 +1,20 @@
## ADDED Requirements
### Requirement: Merchant application review console
Platform admins SHALL review merchant onboarding applications in a dedicated console entry beside existing platform operations: a status-filtered paginated list and a detail view showing entity kind, entity information, operating categories, contact details, and qualification URL fields. Approving SHALL provision the shop and `shop_owner` account and display the one-time initial credentials exactly once, with copy stating the password cannot be retrieved again. Rejecting SHALL require a reason. Reviewed applications SHALL leave the pending queue immediately.
#### Scenario: review an enterprise application
- **WHEN** a platform admin opens a pending enterprise application
- **THEN** the company entity data, operating categories, contact details, and qualification URLs are visible for review
#### Scenario: approve shows one-time credentials
- **WHEN** a platform admin approves an application
- **THEN** the created shop owner's initial credentials are shown once and the application moves to the approved list
#### Scenario: reject requires a reason
- **WHEN** a platform admin attempts to reject without entering a reason
- **THEN** the action is blocked until a non-empty reason is provided
#### Scenario: entry beside platform operations
- **WHEN** an authenticated platform admin opens the admin console
- **THEN** merchant application review is reachable from the console navigation
@@ -0,0 +1,31 @@
## ADDED Requirements
### Requirement: Merchant onboarding multi-step form
The mall SHALL expose a merchant onboarding page ("商家入驻") reachable from the top-bar entry, with a multi-step form covering entity kind (personal 个人 / enterprise 企业), kind-specific entity information, operating categories from the published category tree, contact details, and qualification materials as URL input fields with no file-upload controls. The form SHALL be fillable while signed out, but submission SHALL require registration or sign-in and return the applicant to the completed form to submit through the shared selected API adapter. A duplicate-application conflict SHALL be surfaced inline. All copy SHALL come from the mall locale source in en and zh.
#### Scenario: anonymous fill then sign-in
- **WHEN** a signed-out visitor completes the form and submits
- **THEN** they are sent to register or sign in and, once signed in, returned to the completed form to submit
#### Scenario: enterprise kind shows company fields
- **WHEN** the applicant selects the enterprise entity kind
- **THEN** the company-specific entity and qualification fields replace the personal ones
#### Scenario: duplicate application surfaced
- **WHEN** a signed-in user holding a pending or approved application submits the form
- **THEN** the mall shows the conflict instead of silently creating a second application
#### Scenario: fixed adapter remains functional
- **WHEN** the merchant-onboarding domain is configured to fixed data
- **THEN** the form and status flows behave deterministically through the same shared client methods
### Requirement: Application status page
The mall SHALL show a signed-in applicant their latest merchant application state — status, submitted entity kind, timestamps, and the rejection reason when rejected — linked from the onboarding page, with a re-apply action after rejection. Anonymous visitors SHALL be sent to sign in first.
#### Scenario: applicant tracks review
- **WHEN** a signed-in applicant opens the status page while the application is `pending`
- **THEN** the pending state and submission summary are shown
#### Scenario: rejection explains reason
- **WHEN** a rejected applicant opens the status page
- **THEN** the rejection reason is displayed with an action to apply again
@@ -0,0 +1,68 @@
## ADDED Requirements
### Requirement: Merchant application submission
A prospective seller SHALL submit one merchant onboarding application as either a personal (个人) or an enterprise (企业) entity. Each kind SHALL require its own entity information, one or more operating categories referenced from the published category tree, contact details, and qualification materials submitted as URL fields. The API MUST reject submissions with missing kind-specific fields, unknown categories, or malformed qualification URLs. The form MAY be filled anonymously, but submission SHALL require an authenticated user.
#### Scenario: enterprise submission accepted
- **WHEN** an authenticated user submits an enterprise application with entity information, operating categories, contact details, and qualification URLs
- **THEN** the application is stored with status `pending` and returned to the applicant
#### Scenario: personal submission missing identity document
- **WHEN** a user submits a personal application without the required identity document URL
- **THEN** the API returns a validation error and stores no application row
#### Scenario: anonymous submit requires sign-in
- **WHEN** a signed-out visitor submits the completed form
- **THEN** no application is stored and the visitor must register or sign in before submitting
### Requirement: One active application per user
Submission SHALL be deduplicated per user: a user holding a `pending` or `approved` application MUST NOT create another one, enforced by service validation backed by a database partial unique index so concurrent submissions cannot both succeed. A user whose application was `rejected` MAY apply again.
#### Scenario: duplicate pending submission
- **WHEN** a user with a `pending` application submits again
- **THEN** the API rejects the request with a conflict and exactly one application row exists
#### Scenario: re-apply after rejection
- **WHEN** a user whose application was `rejected` submits a new application
- **THEN** a new `pending` application row is created
### Requirement: Review state machine
An application SHALL move only from `pending` to `approved` or `rejected`, executed as guarded updates matching the `pending` state so concurrent or repeated reviews of the same application fail with a conflict instead of overwriting each other. Rejecting SHALL require a non-empty reason recorded on the row, and `approved` and `rejected` are terminal states that MUST NOT transition again.
#### Scenario: double review conflict
- **WHEN** two platform admins review the same `pending` application concurrently
- **THEN** exactly one transition succeeds and the other receives a conflict
#### Scenario: rejection records a reason
- **WHEN** a platform admin rejects a `pending` application with a reason
- **THEN** the application becomes `rejected` with the reason stored on the row
#### Scenario: terminal state is immutable
- **WHEN** a platform admin reviews an already `approved` or `rejected` application
- **THEN** the API returns a conflict and the stored state is unchanged
### Requirement: Transactional approval provisioning
Approving an application SHALL, within a single database transaction, create the shop, create a dedicated `shop_owner` account scoped to that shop, and mark the application `approved` referencing the created shop. The generated initial password SHALL be returned exactly once in the approve response and MUST NOT be retrievable through any later read. If any step fails, the status change and all provisioning SHALL roll back so the application remains `pending`.
#### Scenario: approval provisions shop and account
- **WHEN** a platform admin approves a `pending` application
- **THEN** one transaction results in an `approved` application, an active shop, and a working `shop_owner` login scoped to that shop
#### Scenario: credentials shown once
- **WHEN** the approver reads the application again after approval
- **THEN** the initial password is absent from every subsequent response
#### Scenario: provisioning failure rolls back
- **WHEN** shop or account creation fails during approval
- **THEN** no shop or account persists and the application remains `pending`
### Requirement: Application visibility
An applicant SHALL read only their own application history, including status, submitted data, timestamps, and rejection reason. Platform admins SHALL list all applications with status filtering and pagination and read any application detail. Other users' applications MUST NOT be readable through customer routes.
#### Scenario: applicant checks status
- **WHEN** a signed-in applicant requests their application status
- **THEN** their own application state and rejection reason are returned
#### Scenario: other applicant is hidden
- **WHEN** one authenticated user requests another user's application through customer routes
- **THEN** the API returns no data about the other user's application
@@ -0,0 +1,33 @@
## 1. Persistence and shared contract
- [x] 1.1 Add migration `0021_merchant_applications.sql` (renumbered from the proposed 0017; 0019/0020 were taken by wallet/settlement): `merchant_applications` with applicant `user_id` FK, `entity_type` enum (`personal`, `enterprise`), kind-specific entity and contact columns, `category_ids`, qualification URL columns (identity document, business license, extra materials as a JSONB URL array), `status` enum (`pending`, `approved`, `rejected`), `rejection_reason`, `reviewed_by`/`reviewed_at`, `created_shop_id` FK, and a partial unique index on `user_id` `WHERE status IN ('pending', 'approved')`.
- [x] 1.2 Add shared discriminated application types (submit payloads for both entity kinds, application summary/detail with status and rejection reason, one-time approval credentials) and the `submitMerchantApplication`, `getMyMerchantApplications`, `adminListMerchantApplications`, `adminGetMerchantApplication`, `adminApproveMerchantApplication`, and `adminRejectMerchantApplication` methods to `@vmall/shared`.
## 2. Backend service and behavioral tests
- [x] 2.1 Implement `apps/api/src/modules/merchant_onboarding/` repository, service, DTO, handlers, and module registration: authenticated customer routes for submit and self status, and `platform_admin`-guarded admin routes for list, detail, approve, and reject. Services return `ApiResult<Dto>`.
- [x] 2.2 Enforce submission validation (kind-specific required fields, published category references, qualification URL shape) and duplicate rejection for users holding a `pending` or `approved` application, with the partial unique index as the concurrency backstop.
- [x] 2.3 Implement guarded status transitions (`UPDATE ... WHERE status = 'pending'`) with mandatory rejection reason and a 409 conflict for already-reviewed applications.
- [x] 2.4 Implement approval as a single transaction: create the shop, create a dedicated `shop_owner` account scoped to it, flip the application to `approved` with `created_shop_id`, and return the generated initial password exactly once; any failure rolls back the status flip and all provisioning.
- [x] 2.5 Add isolated API integration coverage in `apps/api/tests/merchant_applications.rs` (reuse the `tests/common/mod.rs` fixtures) for duplicate submission, double-review conflicts, terminal-state immutability, rejection-reason enforcement, and provisioning rollback.
## 3. Mall onboarding form
- [x] 3.1 Implement the six merchant-onboarding client methods in `apps/mall/mock/api.ts` with deterministic per-session fixture state mirroring dedupe conflicts, review status, and approval/rejection outcomes.
- [x] 3.2 Add the `merchant-onboarding` domain and exact shared-client method picks to Mall API selection (`apps/mall/plugins/api.ts` LIVE_PICKS) and enable it in the default live runtime configuration.
- [x] 3.3 Build the multi-step onboarding page (`/merchant/join`): entity-kind step (personal 个人 / enterprise 企业) switching kind-specific fields, entity information, operating categories from the published category tree, contact details, and qualification URL input fields with no upload controls; allow anonymous filling but gate submission behind registration/sign-in with return to the completed form.
- [x] 3.4 Build the application status page (`/merchant/status`) showing the applicant's latest application state, submitted entity kind, timestamps, and rejection reason, with a re-apply action after rejection.
- [x] 3.5 Land the top-bar "商家入驻" (`sellerJoin`) entry and the footer "Become a Seller" link on the onboarding page instead of the stores directory, and add bilingual onboarding, form, and status strings through the existing Mall locale source without per-page hard-coded copy.
## 4. Admin review console
- [x] 4.1 Add a platform-admin merchant applications page in `apps/admin` with a status-filtered paginated table and a detail view of entity kind, entity information, operating categories, contact details, and qualification URL fields through the shared contract.
- [x] 4.2 Add approve/reject actions: rejection requires a non-empty reason, approve shows a one-time initial-credentials dialog with explicit copy that the password cannot be retrieved again, and reviewed rows leave the pending queue immediately.
- [x] 4.3 Register the merchant applications entry in admin navigation beside existing platform operations and add bilingual console strings through the shared locale source.
## 5. Verification and tracker cleanup
- [x] 5.1 Add end-to-end API integration coverage in `apps/api/tests/merchant_applications.rs` (reuse the `tests/common/mod.rs` fixtures) for submit -> approve provisioning (the created `shop_owner` can log in and manage the linked shop) and submit -> reject -> re-apply, then run the focused merchant onboarding integration tests.
- [x] 5.2 Run the API plus Mall and browser-smoke: anonymous fill -> sign-in gate -> submit, duplicate submission conflict, mall status page after approve and after reject, admin queue filtering and detail, approve one-time credentials display, and reject-reason enforcement.
- [x] 5.3 Build all three frontends because the shared API contract changes: `pnpm --filter @vmall/mall build`, `pnpm --filter @vmall/shop-admin build`, and `pnpm --filter @vmall/admin build`.
- [x] 5.4 Update the README mock boundary for the merchant-onboarding adapter fallback, record any remaining fixture-driven onboarding surfaces in `docs/TBD-marketing.md`, check every OpenSpec task, and run `openspec change validate add-merchant-onboarding --strict` plus `openspec validate --all --strict`.
@@ -0,0 +1,30 @@
## Why
Customer accounts and the append-only entry ledger already exist (`0010_customer_accounts.sql`, the `customer-accounts` capability), but nothing in the product can reach them: buyers have no wallet surface, withdrawals have no lifecycle, and money moving between buyers, merchants, and the platform leaves no statement. Platform-mediated merchant settlement — confirmed-received orders minus refunds minus a platform commission — is the core B2B2C promise and is currently invisible. This change gives the existing ledger its entry points: a buyer wallet with simulated recharge and withdrawal review, and per-period merchant settlement statements whose confirmed payout is recorded in the ledger.
## What Changes
- Add a buyer wallet: available/frozen balance summary, paginated fund entries mapped to the existing ledger, simulated (demo) recharge that directly credits the available account with a ledger entry, and withdrawal applications that move the requested amount from available to frozen through guarded updates.
- Add platform-admin withdrawal review: approve deducts the frozen amount, reject returns it to available balance, each application reviewable exactly once through guarded status transitions.
- Add merchant settlement: manually generated per-period (week/month) reconciliation statements per shop, snapshotted from confirmed-received orders minus completed refunds and a platform-level commission rate, unique per shop and period.
- Add settlement payout confirmation: a platform admin confirms exactly once via a guarded `pending -> confirmed` transition, which records the payout in the ledger of the shop owner's account.
- Surface everything through `@vmall/shared`: a mall wallet page, admin withdrawal review / commission configuration / statement confirmation, and shop-admin statement list/detail plus shop-account withdrawal.
## Capabilities
### New Capabilities
- `wallet`: Demo recharge, guarded withdrawal freeze and one-time review lifecycle, and paginated fund entries over the existing customer-account ledger.
- `settlement`: Idempotent per-shop periodic settlement statements with immutable amount snapshots, a platform-level commission rate, and a `pending -> confirmed` payout state machine.
### Modified Capabilities
- `frontend-mall`: A buyer-center wallet page (balance, fund entries, demo recharge, withdrawal request) driven by the shared wallet contract with fixed-adapter fallback.
- `frontend-admin`: Withdrawal review queue, platform commission-rate configuration, and settlement statement generation and confirmation.
- `frontend-shop-admin`: Shop-scoped settlement statement list/detail with manual generation, and shop-account withdrawal requests.
## Non-goals
Real payment or withdrawal channels (bank cards, WeChat/Alipay payouts), WeChat profit-sharing (分账) APIs, invoice/tax linkage with settlement, automatic settlement batch timers (statements are generated manually), multi-currency wallet balances beyond the account's currency, withdrawal fee tiers, and per-shop commission rate overrides are excluded.
## Impact
Adds migrations for wallet recharge/withdrawal records, settlement statements, and the platform commission setting; new `wallet` and `settlement` Rust modules built on the existing `customer-accounts` primitives; `@vmall/shared` wallet and settlement contracts; a `wallet` mall domain with mock-adapter parity and `LIVE_PICKS` wiring; and wallet, review, and settlement pages across the mall, admin, and shop-admin frontends. Settlement generation consumes the refund rows introduced by `add-aftersale-refunds`.
@@ -0,0 +1,27 @@
## ADDED Requirements
### Requirement: Withdrawal review and commission configuration
The platform console SHALL list withdrawal applications and approve or reject each pending application through the shared API contract, surfacing review outcomes and conflicts (409 on a repeated review) without silent failure. It SHALL expose the platform commission rate as an integer basis-point setting that admins can read and update.
#### Scenario: reject returns funds
- **WHEN** an admin rejects a pending withdrawal application
- **THEN** the console shows the rejected status and the buyer's wallet reflects the amount back in available balance
#### Scenario: approve deducts frozen funds
- **WHEN** an admin approves a pending withdrawal application
- **THEN** the console shows the approved status and the frozen balance decreases by the requested amount
#### Scenario: set commission rate
- **WHEN** an admin updates the commission rate
- **THEN** settlement statements generated afterwards snapshot the new rate
### Requirement: Settlement statement confirmation
The platform console SHALL list settlement statements across shops with their amount snapshots and statuses, allow manual generation for a shop and closed period, and confirm payout exactly once per statement through the shared API contract, surfacing a 409 on repeated confirmation.
#### Scenario: confirm payout
- **WHEN** an admin confirms a pending statement
- **THEN** the console shows the statement confirmed and the shop owner's ledger records the payout
#### Scenario: manual generation is idempotent
- **WHEN** an admin generates a statement for a shop and period that already has one
- **THEN** the existing statement is shown and no duplicate is created
@@ -0,0 +1,20 @@
## ADDED Requirements
### Requirement: Buyer wallet surface
The mall SHALL render a buyer-center wallet page driven by the selected API adapter through `@vmall/shared`: available and frozen balance with currency, paginated fund entries, a clearly labeled demo recharge form, and a withdrawal request form. Balances and entries SHALL reflect backend state after each action rather than local-only state. The wallet SHALL be a mall API domain with fixed-adapter fallback methods and `LIVE_PICKS` wiring following the established per-domain adapter pattern.
#### Scenario: wallet page loads live state
- **WHEN** a signed-in buyer opens the wallet page
- **THEN** balances and the first page of fund entries render from the shared wallet contract
#### Scenario: demo recharge updates balance
- **WHEN** the buyer submits a demo recharge
- **THEN** the visible available balance reflects the credit without a reload and the form is visibly marked simulated
#### Scenario: withdrawal freezes visibly
- **WHEN** the buyer submits a withdrawal request
- **THEN** the visible summary shows available decreased and frozen increased by the requested amount, and the request appears in the withdrawal list as pending
#### Scenario: fixed adapter remains functional
- **WHEN** the wallet domain is configured to fixed data
- **THEN** the wallet page behaves deterministically through the same shared client methods
@@ -0,0 +1,23 @@
## ADDED Requirements
### Requirement: Merchant settlement statements
Shop-admin SHALL list and open the signed-in shop's settlement statements — period, amount snapshot, and status — through the shared API contract, and allow manual generation for a closed period of the own shop. Shop-scoped pages SHALL never expose another shop's statements or their order/refund breakdown.
#### Scenario: statements list and detail
- **WHEN** a merchant opens the settlement page
- **THEN** only their own shop's statements are listed and each opens into its snapshot breakdown of orders, refunds, commission, and payable amount
#### Scenario: generation is idempotent in the UI
- **WHEN** the merchant generates a statement for a period that already has one
- **THEN** the existing statement appears without duplication
### Requirement: Shop-account withdrawal
The shop owner SHALL view the shop account summary (available and frozen balance of the shop owner's account) and apply to withdraw from it through the shared API contract, seeing the request as pending until the platform reviews it.
#### Scenario: shop-account withdrawal freezes funds
- **WHEN** the shop owner submits a withdrawal request from the shop-account page
- **THEN** the summary reflects the frozen amount and the request lists as pending
#### Scenario: reviewed request reflects outcome
- **WHEN** the platform rejects the shop owner's pending withdrawal
- **THEN** the shop-account summary shows the amount returned to available balance and the request shows as rejected
@@ -0,0 +1,56 @@
## ADDED Requirements
### Requirement: Idempotent periodic statement generation
A settlement statement SHALL be generated manually for one shop, one period kind (week or month), and one closed period. For the same shop, period kind, and period start at most one statement SHALL exist, enforced by the database. A repeated generation request SHALL return the existing statement unchanged instead of recomputing or duplicating it. No scheduled batch job SHALL generate statements.
#### Scenario: repeat generation is idempotent
- **WHEN** a statement already exists for a shop and period and generation is requested again
- **THEN** the existing statement is returned unchanged and no second row is created
#### Scenario: generation is manual only
- **WHEN** no generation request is made for a closed period
- **THEN** no statement exists for that period
### Requirement: Immutable amount snapshot
A generated statement SHALL snapshot the contributing order count, the gross total of confirmed-received orders in the period, the deduction total of completed refunds against those orders (`aftersales` rows with status `refunded`, per-order totals from `add-aftersale-refunds`), the platform commission rate at generation time, the commission amount, and the payable amount where payable = gross − refunds − commission. All amounts SHALL be integer minor units computed with integer arithmetic, and the commission rate SHALL be an integer basis-point value. The snapshot SHALL NOT change after generation even when later orders, refunds, or commission-rate changes occur.
#### Scenario: refunds reduce the payable amount
- **WHEN** a confirmed-received order in the period has a completed refund of 2000 minor units
- **THEN** the statement's refund deduction includes those 2000 minor units and the payable amount is reduced accordingly
#### Scenario: commission arithmetic is integral
- **WHEN** the commission rate is 500 basis points and gross minus refunds is 10000 minor units
- **THEN** the commission snapshot is 500 minor units and the payable snapshot is 9500 minor units with no floating-point arithmetic
#### Scenario: snapshot survives rate changes
- **WHEN** the platform commission rate changes after a statement was generated
- **THEN** the generated statement keeps its snapshotted rate and amounts
### Requirement: Platform commission rate configuration
The commission rate SHALL be a single platform-level setting stored as integer basis points and readable and editable by platform admins. A rate change SHALL affect only statements generated after the change.
#### Scenario: new rate applies to new statements
- **WHEN** an admin sets the commission rate and a statement is generated afterwards
- **THEN** that statement snapshots the new rate
### Requirement: One-time payout confirmation
A statement SHALL transition exactly once from `pending` to `confirmed` through a guarded status transition requiring the `pending` status, triggered by a platform admin confirming the payout; a repeated confirmation SHALL return 409. Confirmation SHALL record the confirming admin and timestamp and SHALL credit the payable amount to the shop owner's available account with exactly one ledger entry referencing the statement.
#### Scenario: confirm pays out once
- **WHEN** an admin confirms a pending statement
- **THEN** the statement becomes confirmed and the shop owner's available balance increases by the payable amount with one ledger entry referencing the statement
#### Scenario: double confirmation conflicts
- **WHEN** an admin confirms an already confirmed statement
- **THEN** the API returns 409 and no second ledger entry is written
### Requirement: Shop-scoped statement visibility
Statements and their breakdown SHALL be scoped to one shop. Through the shop's own-shop scope, shop users SHALL read and generate statements only for their own shop and SHALL never observe another shop's statements, contributing orders, or refund lines; platform admins SHALL observe every shop.
#### Scenario: merchant sees own shop only
- **WHEN** a shop user lists settlement statements
- **THEN** only statements of their own shop appear
#### Scenario: detail breakdown is shop-scoped
- **WHEN** a shop user opens a statement detail
- **THEN** the contributing orders and refunded amounts are visible for that shop only
@@ -0,0 +1,49 @@
## ADDED Requirements
### Requirement: Demo wallet recharge
An authenticated user SHALL recharge their wallet through a simulated demo flow that records a wallet recharge row and credits the available monetary account with one signed ledger entry in one transaction, without invoking any external payment channel. The API payload and the wallet UI SHALL clearly label the recharge as simulated/demo. Amounts SHALL be positive integer minor units in the account's currency, and monetary arithmetic SHALL NOT use floating point.
#### Scenario: demo recharge credits balance
- **WHEN** a signed-in buyer submits a demo recharge of 5000 minor units
- **THEN** the available balance increases by exactly 5000 minor units and exactly one ledger entry with reason `wallet_recharge` records the resulting balance
#### Scenario: flow is labeled as demo
- **WHEN** a buyer opens the recharge flow
- **THEN** it is visibly marked as a simulated recharge and no external payment provider is contacted
### Requirement: Guarded withdrawal freeze
An authenticated user SHALL apply to withdraw a positive amount in their account currency. The application SHALL atomically move the amount from available to frozen balance through guarded conditional updates that succeed only when available balance covers the amount, and each balance change SHALL be paired with a ledger entry in the same transaction. When available balance is insufficient the request SHALL fail without changing any balance, and concurrent applications SHALL never overdraw or produce a negative balance.
#### Scenario: withdrawal freezes funds
- **WHEN** a buyer applies to withdraw 1000 minor units
- **THEN** available balance decreases and frozen balance increases by exactly 1000 minor units and two ledger entries record the move
#### Scenario: concurrent applications cannot overdraw
- **WHEN** two withdrawal applications together exceed the available balance
- **THEN** at most one application succeeds and all balances remain non-negative
### Requirement: One-time withdrawal review
Platform admins SHALL list pending withdrawal applications and approve or reject each exactly once through a guarded status transition that requires the `pending` status and returns 409 on a repeated review. Approve SHALL deduct the frozen amount with a ledger entry recording that the funds left the platform. Reject SHALL return the frozen amount to available balance with a ledger entry. Every review SHALL record the reviewing admin, timestamp, and optional note.
#### Scenario: reject returns funds
- **WHEN** an admin rejects a pending withdrawal application
- **THEN** the frozen amount returns to available balance and the application status becomes rejected
#### Scenario: approve consumes frozen funds
- **WHEN** an admin approves a pending withdrawal application
- **THEN** frozen balance decreases by the requested amount and the application status becomes approved
#### Scenario: repeated review conflicts
- **WHEN** an admin reviews an application that was already reviewed
- **THEN** the API returns 409 and no balance or ledger row changes
### Requirement: Paginated fund entries
An authenticated user SHALL page through their own monetary account entries newest first. Each entry SHALL carry the signed delta minor, resulting balance minor, reason, optional business reference, and timestamp, mapped from the existing append-only ledger rows. Entries SHALL never expose or mutate another user's ledger.
#### Scenario: entries page maps the ledger
- **WHEN** the buyer requests a page of fund entries
- **THEN** only their own available/frozen ledger rows appear with signed deltas and resulting balances
#### Scenario: entries are user-isolated
- **WHEN** one user requests fund entries
- **THEN** no entry belonging to another user is ever returned
@@ -0,0 +1,38 @@
## 1. Persistence and shared contract
- [x] 1.1 Add migration `0019_wallet.sql` adapting tigshop's `user_recharge_order` and `user_withdraw_apply`: `wallet_recharges` (user, currency, amount_minor BIGINT, status, timestamps) and `wallet_withdrawals` (user, currency, amount_minor BIGINT, account_details JSONB, status pending/approved/rejected with CHECK, reviewing admin, reviewed_at, review_note, timestamps) with foreign keys, non-negative amount CHECKs, and customer-facing indexes. (Renumbered from the proposed 0018: 0016-0018 were taken by aftersales/freight/reviews.)
- [x] 1.2 Add migration `0020_settlement.sql` adapting tigshop's `vendor_settlement_order`: `settlement_statements` (shop, period_kind week/month, period_start, period_end, order_count, gross_minor, refund_minor, commission_rate_bps, commission_minor, payable_minor, status pending/confirmed with CHECK, generated_by, confirmed_by, confirmed_at, timestamps) with a unique index on (shop_id, period_kind, period_start), plus a platform-level settings row for `settlement.commission_rate_bps`. Also adds `orders.completed_at` (settlement attributes an order to the period it was confirmed received; `refunds` bump `updated_at`) and sets it in the existing completion transition.
- [x] 1.3 Add `@vmall/shared` wallet types and customer-client methods: `getWallet` (available/frozen minor with currency), `listWalletEntries` (paged signed ledger entries), `rechargeWallet` (demo), `applyWithdrawal`, and `listMyWithdrawals`.
- [x] 1.4 Add `@vmall/shared` settlement and review methods: admin client `listWithdrawalApplications`, `reviewWithdrawal`, `getCommissionRate`, `setCommissionRate`, `listSettlementStatements`, `getSettlementStatement`, `generateSettlementStatement`, `confirmSettlementStatement`; shop client `listShopSettlementStatements`, `getShopSettlementStatement`, `generateShopSettlementStatement`. All money fields are i64 minor units; no floating-point amounts cross the contract.
## 2. Wallet backend
- [x] 2.1 Implement `apps/api/src/modules/wallet/` (repo, service returning `ApiResult<Dto>`, DTOs, handlers, module registration) with user-scoped `/wallet` routes and `/admin/wallet/withdrawals` review routes declaring the platform-admin role.
- [x] 2.2 Implement demo recharge: one transaction records a `wallet_recharges` row and credits the available account through the existing `customer-accounts` primitives with a `wallet_recharge` ledger entry; the DTO carries an explicit demo marker.
- [x] 2.3 Implement the withdrawal lifecycle: application freezes funds via guarded `UPDATE ... WHERE balance_minor >= $amount` moving available to frozen with paired ledger entries; admin approve deducts frozen balance with a ledger entry; admin reject returns frozen balance to available with a ledger entry; each review is guarded by `UPDATE ... WHERE status = 'pending'`, returns 409 on repeat, and records reviewer, timestamp, and note.
- [x] 2.4 Implement paginated fund-entry listing mapped to `customer_account_entries` for the caller's monetary accounts (newest first; signed delta, resulting balance, reason, reference) with ownership filtering.
- [x] 2.5 Add `apps/api/tests/wallet.rs` covering concurrent withdrawal overdraw, insufficient-balance no-op, double-review 409, reject unfreeze, approve frozen deduction, recharge ledger pairing, and entry pagination isolation, reusing the `tests/common/mod.rs` fixtures.
## 3. Settlement backend
- [x] 3.1 Implement `apps/api/src/modules/settlement/` (repo, service returning `ApiResult<Dto>`, DTOs, handlers, module registration) with `/shop/settlement/*` routes scoped through `AuthUser::own_shop` and `/admin/settlement/*` routes declaring the platform-admin role.
- [x] 3.2 Implement idempotent statement generation for a shop and closed week/month period: snapshot order count and gross totals of confirmed-received orders in the period, deduct completed refunds (`aftersales` rows with status `refunded`, per-order totals maintained by `add-aftersale-refunds` — if developed in parallel with P0, merge the P0 `aftersales` migration first), apply the platform commission rate in integer basis points with integer minor-unit arithmetic (payable = gross − refunds − commission), and return the existing statement untouched on repeat generation.
- [x] 3.3 Implement statement listing and detail with per-order and refund breakdown, with merchant routes restricted to the own shop and platform-admin routes seeing every shop.
- [x] 3.4 Implement `pending -> confirmed` payout confirmation: guarded `UPDATE ... WHERE status = 'pending'` (409 on repeat) crediting the payable amount to the shop owner's available account with exactly one ledger entry referencing the statement.
- [x] 3.5 Implement platform commission-rate configuration (integer basis points), applied only to statements generated after a change; generated statements keep their snapshot.
- [x] 3.6 Add `apps/api/tests/settlement.rs` covering generation idempotency and period uniqueness, refund deduction, commission snapshot immutability after a rate change, double-confirmation 409 with exactly one payout ledger entry, and cross-shop isolation, reusing the `tests/common/mod.rs` fixtures.
## 4. Frontend surfaces
- [x] 4.1 Add the mall buyer-center wallet page (available/frozen balance, paginated fund entries, clearly labeled demo recharge, withdrawal request and history) through `@vmall/shared`, with bilingual strings in the existing Mall locale source.
- [x] 4.2 Add the `wallet` domain to the Mall fixed-data adapter (`apps/mall/mock/api.ts`) with matching behavior, and wire `wallet` into the Mall API selection `LIVE_PICKS` and default live domains (`apps/mall/plugins/api.ts`).
- [x] 4.3 Add admin pages for withdrawal review (approve/reject with outcome and 409 feedback), commission-rate configuration, and settlement statement list/detail with manual generation and one-time payout confirmation.
- [x] 4.4 Add shop-admin pages for own-shop settlement statement list/detail with manual generation and shop-account summary plus withdrawal request/history.
- [x] 4.5 Keep all three frontends on the `@vmall/shared` contract only (no page-level fetch of wallet/settlement endpoints) and render page chrome with `@vmall/ui` primitives.
## 5. Verification and tracker cleanup
- [x] 5.1 Run the wallet and settlement integration tests in `apps/api/tests/wallet.rs` and `apps/api/tests/settlement.rs` (reusing the `tests/common/mod.rs` fixtures), then `cargo test -p vmall-api` to prove the suite stays green against the shared test database.
- [x] 5.2 Build all three frontends because the shared API contract changes: `pnpm --filter @vmall/mall build`, `pnpm --filter @vmall/admin build`, and `pnpm --filter @vmall/shop-admin build`.
- [x] 5.3 Browser-smoke the API plus frontends: demo recharge updates the wallet; withdrawal freeze, admin reject unfreeze, and admin approve deduction round trip; wallet entries pagination; commission-rate configuration; idempotent statement generation with refund deduction; one-time payout confirmation writing one ledger entry; and shop-admin own-shop statement isolation.
- [x] 5.4 Check every OpenSpec task, then run `openspec change validate add-wallet-settlement --strict` and `openspec validate --all --strict`.
@@ -0,0 +1,30 @@
## Why
Members have no growth ladder and no reach-out channel: a customer's spending never converts into status, and order events (payment, dispatch, refund) are invisible unless the shopper re-opens the buyer center. tigshop's level page is itself a half-finished stub, so vmall defines its own member-level and growth-value design instead of porting that page, and pairs it with a system in-site message inbox so transaction events actually reach the customer.
## What Changes
- Add platform-managed member levels with bilingual name, icon, growth-value threshold, and bilingual benefits description.
- Accrue growth value from an order's realized paid amount when the customer confirms receipt, recorded in an append-only growth ledger in the same style as the points ledger.
- Automatically upgrade a customer's level inside the accrual transaction, writing `users.level` with a guarded update from the highest threshold the customer's growth qualifies for.
- Emit system in-site messages on order payment, shipment dispatch, and refund completion events.
- Give customers a message inbox with unread/read state, mark-all-read, soft deletion, and an unread count endpoint for the header badge.
- Surface all of it through `@vmall/shared`: a Mall member-center level page, a message center, and a top-bar unread badge (with fixed-adapter fallback and live picks for the new domains), plus a member-level management page in the admin console.
## Capabilities
### New Capabilities
- `membership`: Member level catalog, growth-value accrual ledger, automatic upgrade-only leveling, and level benefits display.
- `messaging`: Order and after-sale event-driven system messages, per-user unread/read state with soft deletion, and unread counting.
### Modified Capabilities
- `frontend-mall`: Member-center level page, message center, and top-bar unread badge use the selected API adapter instead of absent or local state.
- `frontend-admin`: Member level management beside the existing platform operations.
## Non-goals
Paid level purchases, level-exclusive pricing, SMS/email/WeChat-template push delivery, real-time WebSocket push (polling or fetch-on-entry is enough), and rich-text message bodies are excluded.
## Impact
Adds one Postgres migration, Rust `membership` and `messaging` modules with order/fulfillment event hooks, shared types and API methods, `membership` and `messaging` Mall domains with fixed adapters and live picks, Mall pages `user/membership` and `user/messages` plus the shell badge, and an admin `member-levels` page.
@@ -0,0 +1,20 @@
## ADDED Requirements
### Requirement: Member level management
Platform admins SHALL manage member levels from a dedicated admin console page through the shared API contract: levels listed in growth-threshold order, and create, edit, and delete actions over name, icon, growth threshold, and benefits with both locales editable. Deleting a level in use SHALL surface the API rejection instead of silently succeeding, and member-level management SHALL be reachable from the authenticated console navigation beside existing platform operations.
#### Scenario: manage a member level
- **WHEN** a platform admin creates a level with bilingual name and benefits, an icon, and a growth threshold
- **THEN** it appears in threshold order and is available for automatic leveling
#### Scenario: edit a growth threshold
- **WHEN** a platform admin changes a level's growth threshold to a unique value
- **THEN** the level persists with the new threshold and the bilingual content unchanged
#### Scenario: deleting a level in use fails visibly
- **WHEN** a platform admin deletes a level that customers hold
- **THEN** the console surfaces the rejection and the level and its members remain unchanged
#### Scenario: level management appears in admin navigation
- **WHEN** an authenticated platform admin opens the admin console
- **THEN** member-level management is reachable from the console nav
@@ -0,0 +1,46 @@
## ADDED Requirements
### Requirement: Member center level page
The buyer center SHALL render a membership level page from the selected API adapter: the current level's name, icon, and benefits, the total growth value, progress toward the next level's threshold, and the customer's growth history from the growth ledger. The page SHALL NOT derive level state from fixtures or local state, and it SHALL behave deterministically when the `membership` domain is configured to fixed data.
#### Scenario: level page reflects backend state
- **WHEN** a signed-in shopper whose growth qualifies for a level opens the member-center level page
- **THEN** the current level, benefits, growth total, and remaining growth to the next level render from the adapter without per-entry requests
#### Scenario: growth history lists ledger entries
- **WHEN** a shopper opens the level page
- **THEN** recent growth ledger entries with delta, reason, and time render paginated from the adapter
#### Scenario: fixed adapter remains functional
- **WHEN** the `membership` domain is configured to fixed data
- **THEN** the level page renders deterministic fixed level and growth data through the same shared client methods
### Requirement: Message center
The mall SHALL render a user message center from the selected API adapter with a paginated message list and an unread-only filter. Opening or explicitly marking a message read, marking all read, and deleting a message SHALL persist through the API and update the visible list and counts without local-only mutation. Each message SHALL render its title and body in the active locale from the shared bilingual contract. Listing and refresh SHALL happen on entry and on demand; no push transport is required.
#### Scenario: unread filter and marking
- **WHEN** a shopper filters the message center to unread and marks one message read
- **THEN** the message persists as read and leaves the unread-only view with the unread count reduced
#### Scenario: mark all read
- **WHEN** a shopper uses mark-all-read in the message center
- **THEN** every unread message becomes read and the unread-only view empties
#### Scenario: delete a message
- **WHEN** a shopper deletes a message
- **THEN** it disappears from the list and the unread count through the API state
#### Scenario: fixed adapter remains functional
- **WHEN** the `messaging` domain is configured to fixed data
- **THEN** message listing, read marking, mark-all-read, and deletion behave deterministically through the same shared client methods
### Requirement: Top-bar unread badge
The mall shell SHALL show the authenticated customer's unread message count as a badge on its message entry, read from the shared unread-count contract. The badge SHALL refresh on page entry and after read, mark-all-read, and delete actions, and SHALL be absent for anonymous shoppers.
#### Scenario: badge reflects unread count
- **WHEN** a signed-in shopper with three unread messages loads any mall page
- **THEN** the message entry badge shows three
#### Scenario: badge clears after mark-all-read
- **WHEN** a shopper marks all messages read and returns to the shell
- **THEN** the badge shows no unread count
@@ -0,0 +1,61 @@
## ADDED Requirements
### Requirement: Member level catalog
The platform SHALL manage member levels through admin-only APIs. Each level SHALL carry a bilingual name (`{en, zh}`), an icon key, an integer growth-value threshold, and a bilingual benefits description (`{en, zh}`). Growth thresholds SHALL be unique across levels, and the effective ordering of levels SHALL follow the threshold. Deleting a level that any customer currently holds SHALL be rejected instead of reassigning or orphaning members.
#### Scenario: create a level
- **WHEN** a platform admin creates a level with name, icon, growth threshold, and benefits in both locales
- **THEN** the level is listed with its bilingual content and threshold ordering
#### Scenario: duplicate threshold is rejected
- **WHEN** a platform admin creates or edits a level to reuse another level's growth threshold
- **THEN** the request is rejected and the existing levels are unchanged
#### Scenario: deleting a level in use is rejected
- **WHEN** a platform admin deletes a level that at least one customer holds
- **THEN** the request is rejected and no customer's level changes
### Requirement: Growth value accrual ledger
When a customer confirms receipt and an order reaches completed, the customer SHALL earn growth value equal to that order's realized paid amount converted to the base currency and truncated to whole units through integer minor-unit arithmetic using the base currency exponent, with no floating-point computation. Accrual SHALL append exactly one entry per order to a growth ledger that, like the points ledger, is append-only and records the delta, running growth total, reason, and order reference. Retried or repeated completion events SHALL NOT create a second entry or a second accrual.
#### Scenario: confirm receipt accrues growth
- **WHEN** a customer confirms receipt of an order whose realized paid amount converts to 120 whole base-currency units
- **THEN** the growth ledger gains one entry with delta 120 referencing that order and the customer's growth total rises by 120
#### Scenario: repeated completion is idempotent
- **WHEN** the completion handling for the same order runs again
- **THEN** no second ledger entry exists and the growth total is unchanged
#### Scenario: ledger entries are immutable
- **WHEN** any code path handles growth after an entry was written
- **THEN** the entry is only ever appended to, never updated or deleted
### Requirement: Automatic level upgrade
Inside the growth accrual transaction, the customer's level SHALL be re-derived as the level with the highest growth threshold less than or equal to the customer's growth total and written to `users.level` with a guarded update. Leveling SHALL be one-way: a customer is only ever moved to a level with a strictly higher threshold than the current one, and never demoted automatically. A growth total exactly at a threshold qualifies for that level, and when several thresholds are passed the highest qualifying level wins. A customer below every threshold SHALL hold no level.
#### Scenario: growth at the threshold upgrades
- **WHEN** an accrual brings a customer's growth total exactly to a level's threshold
- **THEN** the customer's level becomes that level in the same transaction as the ledger entry
#### Scenario: jumping past intermediate levels
- **WHEN** an accrual passes two levels' thresholds at once
- **THEN** the customer holds the highest qualifying level, not the intermediate one
#### Scenario: no level below every threshold
- **WHEN** a customer's growth total is below the lowest defined threshold
- **THEN** the customer holds no level and a later qualifying accrual assigns one
### Requirement: Level benefits display
An authenticated customer SHALL read their own membership status: current level with name, icon, and benefits, total growth value, the next level's threshold and remaining growth to reach it, and a paginated view of their own growth ledger. The status SHALL re-derive the displayed level against current thresholds, and one customer SHALL never read another customer's growth history.
#### Scenario: progress to the next level
- **WHEN** a customer between two thresholds opens their membership status
- **THEN** the response shows the current level's benefits and the exact growth remaining to the next level
#### Scenario: top level has no next level
- **WHEN** a customer at or above the highest threshold reads their membership status
- **THEN** the response contains no next level and no remaining growth target
#### Scenario: growth history is own-only
- **WHEN** a customer requests their growth ledger
- **THEN** only entries of the authenticated customer are returned, newest first and paginated
@@ -0,0 +1,50 @@
## ADDED Requirements
### Requirement: System event messages
An order payment success, a shipment dispatch, and a refund completion (including after-sale refunds) SHALL each create one system message for the customer who owns the order. Each message SHALL record its kind, a bilingual title and body (`{en, zh}`) naming the affected order, and the order (and refund) reference it was triggered by. Emission SHALL be idempotent per customer, kind, and reference, so re-running an event handler never writes a duplicate message.
#### Scenario: paying an order notifies the customer
- **WHEN** a customer's order payment succeeds
- **THEN** an `order_paid` message referencing that order appears in the customer's inbox
#### Scenario: dispatch notifies the customer
- **WHEN** a shop marks a shipment of the customer's order as shipped
- **THEN** an `order_shipped` message referencing that order appears in the customer's inbox
#### Scenario: refund completion notifies the customer
- **WHEN** a refund for the customer's order completes
- **THEN** a `refund_completed` message referencing that order and refund appears in the customer's inbox
#### Scenario: re-run event handler does not duplicate
- **WHEN** the same event handler for the same order, kind, and reference runs twice
- **THEN** exactly one message exists for that customer, kind, and reference
### Requirement: User message state machine
A message SHALL belong to exactly one customer and move through `unread` and `read` states plus a soft deletion. Messages are created `unread`; marking one read or all read SHALL use a guarded update that touches only `unread` rows, so repeated marking never rewrites already-read rows. Deletion SHALL set a soft-delete marker rather than removing the row, SHALL be idempotent, and SHALL make the message invisible to listing and counting. Customers SHALL only ever read, mark, or delete their own messages.
#### Scenario: mark one message read
- **WHEN** a customer marks their unread message read twice
- **THEN** the message is read and only the first call changed its state
#### Scenario: mark all read touches only unread
- **WHEN** a customer with three unread and two read messages marks all read
- **THEN** exactly the three unread rows become read and the two read rows are untouched
#### Scenario: deleted messages disappear
- **WHEN** a customer deletes a message
- **THEN** it is absent from their list and unread count while its row remains for audit
#### Scenario: foreign messages are unreachable
- **WHEN** a customer addresses another customer's message for read or delete
- **THEN** the request fails without changing that message
### Requirement: Unread count endpoint
An authenticated customer SHALL read their unread message count through a dedicated endpoint that excludes soft-deleted messages. The count SHALL reflect read, mark-all-read, and delete actions immediately and SHALL only ever count the requesting customer's messages.
#### Scenario: count drops after mark-all-read
- **WHEN** a customer with four unread messages marks all read
- **THEN** the unread count endpoint returns zero
#### Scenario: deleting an unread message drops the count
- **WHEN** a customer deletes one of three unread messages
- **THEN** the unread count endpoint returns two
@@ -0,0 +1,34 @@
## 1. Persistence and shared contract
- [x] 1.1 Add migration `0023_membership_messaging.sql` (renumbered from the proposed 0016; 0019-0022 were taken): `member_levels` (bilingual `name` JSONB `{en, zh}`, `icon`, unique integer `growth_threshold`, bilingual `benefits` JSONB `{en, zh}`), `growth_logs` (append-only `user_id`, `delta`, running `growth_total`, `reason`, `reference_type`/`reference_id` order reference with a partial unique index per user and reference), `messages` (`user_id`, `kind`, bilingual `title`/`body` JSONB `{en, zh}`, `reference_type`/`reference_id`, `status` `unread`/`read`, `deleted_at` soft delete, partial unique index per user/kind/reference), a `users.level` column referencing `member_levels`, and listing/counting indexes.
- [x] 1.2 Add shared types (`MemberLevel`, `MemberLevelInput`, `MembershipStatus`, `GrowthLogEntry`, `Message`, `MessageListQuery`) and `@vmall/shared` methods `getMembership`, `listGrowthLogs`, `listMessages`, `markMessageRead`, `markAllMessagesRead`, `deleteMessage`, `getUnreadCount`, and `admin.listMemberLevels`, `admin.createMemberLevel`, `admin.updateMemberLevel`, `admin.deleteMemberLevel` with all amounts and growth values as integers and i18n content as `{en, zh}` JSONB.
- [x] 1.3 Implement `apps/api/src/modules/membership/` repository, service, DTO, handlers, and module registration: admin-role-gated member-level CRUD routes and customer-scoped membership status and growth-ledger routes, services returning `ApiResult<Dto>`.
- [x] 1.4 Implement `apps/api/src/modules/messaging/` repository, service, DTO, handlers, and module registration: customer-scoped message list (paginated, optional unread-only filter), mark-read, mark-all-read, soft delete, and unread-count routes returning `ApiResult<Dto>`.
## 2. Services and behavioral tests
- [x] 2.1 Implement growth accrual on order completion (customer confirms receipt): convert the order's realized paid amount to the base currency with integer minor-unit arithmetic (truncated whole units, no floating point), append exactly one ledger entry per order, and update `users.level` in the same transaction with a guarded `UPDATE ... WHERE` that only moves the customer to a strictly higher-threshold level.
- [x] 2.2 Wire idempotent message emission into the order, fulfillment, and refund (after-sale) transitions: order payment success emits `order_paid`, shipment dispatch emits `order_shipped`, and refund completion emits `refund_completed`, each a guarded insert keyed by user, kind, and reference with bilingual `{en, zh}` title and body naming the order.
- [x] 2.3 Add behavioral coverage in `apps/api/tests/membership.rs` and `apps/api/tests/messaging.rs` (fixtures from `tests/common/mod.rs`) for upgrade boundaries: growth exactly at a threshold upgrades, growth below every threshold holds no level, one accrual jumping two thresholds lands on the highest qualifying level, and repeated completion events accrue and upgrade exactly once.
- [x] 2.4 Extend the behavioral coverage for event triggers and read semantics: each of the three events creates exactly one correctly referenced message, re-run handlers do not duplicate, mark-read touches only `unread` rows (idempotent single and all-read), soft delete is idempotent and excluded from lists and unread counts, and cross-user message access fails.
## 3. Mall member and message surfaces
- [x] 3.1 Implement the `getMembership`, `listGrowthLogs`, `listMessages`, `markMessageRead`, `markAllMessagesRead`, `deleteMessage`, and `getUnreadCount` methods in `apps/mall/mock/api.ts` with deterministic per-session fixture state and the same idempotent read/delete semantics.
- [x] 3.2 Add the `membership` and `messaging` domains with their exact shared-client method picks to the Mall API selection and enable them in the default live runtime configuration.
- [x] 3.3 Add or adjust bilingual level, growth, message, badge, and failure strings through the existing Mall locale source without per-page hard-coded copy.
- [x] 3.4 Build `apps/mall/pages/user/membership.vue`: current level name/icon/benefits, growth total, progress to the next threshold, and paginated growth history from the shared contract, with fixed-adapter parity and no fixture imports.
- [x] 3.5 Build `apps/mall/pages/user/messages.vue`: paginated message list with unread-only filter, open/mark-read, mark-all-read, and delete actions that persist through the API and refresh list and counts.
- [x] 3.6 Add the top-bar unread badge on the shell message entry: unread count on page entry and after read/mark-all/delete actions, absent for anonymous shoppers, linking to the message center.
## 4. Admin member-level management
- [x] 4.1 Build `apps/admin/pages/member-levels.vue`: levels in threshold order with create/edit/delete forms over bilingual name, icon, growth threshold, and benefits through the shared `admin` level methods, surfacing the delete rejection for levels in use.
- [x] 4.2 Register the member-levels entry in the authenticated admin console navigation beside existing platform operations.
## 5. Verification
- [x] 5.1 Run the `apps/api/tests/` integration suites with the `tests/common/mod.rs` fixtures — the new `membership.rs` and `messaging.rs` plus the affected `orders.rs`, `order_service.rs`, and `points.rs` suites — proving upgrade boundaries, event triggers, and read semantics against the shared test database.
- [x] 5.2 Browser-smoke the running API, Mall, and admin console: confirm receipt upgrades the level and appends the growth entry, the three system events land one message each, message-center read/mark-all/delete update lists and counts, the top-bar badge tracks unread count, and admin level CRUD with in-use delete rejection works end to end.
- [x] 5.3 Build the affected frontends because the shared API contract changes: `pnpm --filter @vmall/mall build`, `pnpm --filter @vmall/admin build`, and `pnpm --filter @vmall/shop-admin build`.
- [x] 5.4 Run `openspec change validate add-membership-messaging --strict` and `openspec validate --all --strict` and fix findings until both pass.