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.
111 lines
5.9 KiB
Markdown
111 lines
5.9 KiB
Markdown
# 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`.
|