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.
5.9 KiB
Domain: Accounts, Content, Shops, Identity
Customer accounts (modules/account/)
Three buckets per customer: available and frozen (monetary, per currency)
plus points (no currency). Balances change only through
credit/debit/freeze/release, each appending one immutable
customer_account_entries row in the same transaction as the business write.
The ledger is the audit trail; balances are never set absolutely. Credits in
a currency the customer never held lazily create the zero row
(ensure_monetary_account).
Wallet (modules/wallet/)
Buyer- and shop-owner-facing entry points over the ledger. GET /wallet is the
available/frozen summary in the platform base currency; GET /wallet/entries
pages the caller's own customer_account_entries (money kinds only) newest
first with signed deltas and resulting balances. POST /wallet/recharges is a
simulated demo credit (demo: true on the payload, no payment provider),
and POST /wallet/withdrawals freezes the requested amount out of available
balance through the account module's guarded transfer. Platform admins list and
review pending applications via /admin/wallet/withdrawals: approve consumes
the frozen funds, reject returns them to available, and the
pending -> approved|rejected transition is guarded so a repeat review is a
409. Every movement pairs with a ledger entry in the same transaction.
Settlement (modules/settlement/)
Per-shop, per-period reconciliation statements generated manually for a closed
week or month (/shop/settlement/* for the own shop, /admin/settlement/* for
the platform). Generation is idempotent per (shop, period_kind, period_start)
— enforced by a unique index — and snapshots the contributing confirmed-received
orders (orders.completed_at inside the period), their gross and completed
refunds converted into the platform base currency, the platform commission rate
in integer basis points, and payable = gross - refunds - commission, all in
integer minor units. The platform rate lives in platform_settings
(settlement.commission_rate_bps) and only affects statements generated after a
change. Confirmation is a guarded pending -> confirmed transition that credits
the payable amount to the shop owner's available account with exactly one
settlement_payout ledger entry.
Storefront content (modules/content/)
Four ordered home-content kinds (banners, promos, quick links, floor
adverts), platform-managed by whole-list replacement (PUT /admin/content/{kind} reindexes positions atomically). Quick-link glyphs are
inline SVG path data.
Shops (modules/shop/)
Platform creates/suspends shops; shop profiles are a side table with
bilingual address/notice/after-sale and platform-owned scores. Merchants edit
their own profile via PUT /api/shop/profile — scores are platform-only and
the merchant upsert never touches them.
Identity (modules/identity/, src/auth.rs)
Email+password register/login, JWT bearer tokens. AuthUser carries
id/role/shop_id; require(&[roles]) for RBAC, own_shop() /
require_shop() for tenant scoping (cross-shop resources return 404).
ensure_accounts runs inside the registration transaction.
Merchant onboarding (modules/merchant_onboarding/)
The B2B entry that replaces manual admin shop creation: an authenticated user
submits one application as personal or enterprise (kind-specific entity
fields, one or more reference categories, contact details, qualification URLs
only). One live application per user is enforced by service validation plus a
partial unique index on (user_id) WHERE status IN ('pending','approved'), so
a rejected applicant may re-apply. The review state machine is
pending -> approved | rejected through guarded updates; rejection requires a
non-empty reason, and both terminal states are immutable (409 on repeat).
Approval is one transaction — shop::service::create_in_tx, a dedicated
shop_owner user via identity::repo::insert_user, its zero-balance accounts,
and the guarded status flip — and returns the generated initial password
exactly once; any failure rolls the whole provisioning back and leaves the
application pending. Applicants only ever read their own history; platform
admins list/filter all applications.
Membership (modules/membership/)
Platform-managed member_levels (bilingual name and benefits, icon, globally
unique integer growth threshold) plus an append-only growth_logs ledger and
users.level. When the buyer confirms receipt and the order reaches
completed, membership::service::accrue_for_order runs inside that
transaction: it converts the order's realized paid amount (total minus completed
refunds) into the base currency with money::convert_minor, truncates to whole
units via the base exponent, appends exactly one entry (ON CONFLICT DO NOTHING
on the (user_id, reference_type, reference_id) partial unique index), and
moves users.level with a guarded update that only ever raises the threshold.
Leveling is one-way; the status read re-derives the displayed level against the
current thresholds so an admin edit is reflected without rewriting members.
Growth history is own-only and paginated.
Messaging (modules/messaging/)
Per-customer system messages emitted by order events: payment success
(order_paid), shipment dispatch (order_shipped), and refund completion
(refund_completed, referenced by the after-sale row and naming the order).
Each emit is a guarded insert keyed by (user_id, kind, reference_type, reference_id) inside the transition's transaction, so a retried handler cannot
duplicate a message and a soft-deleted row still occupies its slot. Messages are
created unread; marking one or all read uses guarded updates that only touch
unread rows, deletion is a soft-delete marker that is idempotent and excludes
the row from listing and counting, and a dedicated unread-count endpoint feeds
the mall's top-bar badge.
Key files
See docs/code_index/platform.md.