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
+39
View File
@@ -33,8 +33,39 @@ UPDATE skus SET stock = stock - $2 WHERE id = $1 AND stock >= $2
-- one-time flag (reply, reopen): guard on the empty state
UPDATE product_reviews SET reply = $2 WHERE id = $1 AND reply IS NULL
-- one-time review out of a pending state (wallet withdrawals, payouts)
UPDATE wallet_withdrawals SET status = $2, reviewed_by = $3, reviewed_at = now()
WHERE id = $1 AND status = 'pending' -- 0 rows → ApiError::Conflict
```
Idempotent generation guarded by a unique index (settlement statements): insert
with `ON CONFLICT (<cols>) DO NOTHING RETURNING id`; a `None` result means a
repeat or a racing request won, so re-read the existing row and return it
unchanged instead of recomputing.
"At most one live row per owner" (merchant applications: one `pending`/`approved`
per user) is a **partial unique index** — `UNIQUE (user_id) WHERE status IN (...)`
— checked first in the service for a friendly 409 and relied on as the
concurrency backstop via `unique_conflict`. Transactional provisioning (approval
creating a shop + owner account) reads the row `FOR UPDATE`, provisions, then
runs the guarded status flip last so any failure rolls the whole thing back.
Event side effects (membership growth, system messages) run **inside the
transition's transaction** and are made idempotent by a partial unique index plus
`ON CONFLICT DO NOTHING`, so a retried handler cannot double-write:
```sql
-- one message per customer, kind, and reference
INSERT INTO messages (...) VALUES (...)
ON CONFLICT (user_id, kind, reference_type, reference_id)
WHERE reference_id IS NOT NULL DO NOTHING
```
A per-customer ledger total (growth) serializes on `SELECT id FROM users WHERE
id = $1 FOR UPDATE`, then reads `COALESCE(SUM(delta),0)::bigint` and appends the
entry carrying the new running total.
Multi-row `FOR UPDATE` must `ORDER BY` primary key. Ledger writes are
append-only: change a balance only via `account::service::credit/debit`
inside the caller's transaction, never by writing an absolute balance.
@@ -74,3 +105,11 @@ endpoints especially. Mind pipefail: `cargo test | grep` hides failures.
3. Add the column to **every** SELECT/RETURNING list for that table.
4. Extend INSERT binds if writable.
5. Mirror in `packages/shared/src/types.ts`.
Nuance: a column that only one module writes and reads may stay out of the
shared `models.rs` row when adding the field would force every existing
`SELECT`/`RETURNING` list for that table to change. Example:
`orders.completed_at` (settlement period attribution) is written by the order
completion transition and read by `modules/settlement/repo.rs`; `models::Order`
does not carry it. Also remember `updated_at` is not a completion timestamp —
refunds bump it — so period attribution needs a dedicated column.