Files
vmall/docs/domains/platform.md
T
james 9904696e76 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.
2026-09-25 15:25:29 +00:00

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.