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
+370
View File
@@ -0,0 +1,370 @@
<script setup lang="ts">
import { ApiError, formatMoney } from "@vmall/shared";
import type { WalletEntry, WalletSummary, WalletWithdrawal, WithdrawalStatus } from "@vmall/shared";
definePageMeta({ middleware: "auth" });
const { locale, t } = useI18n();
const { $api } = useNuxtApp();
const { ensureCurrencies, exponentFor } = usePrice();
const wallet = ref<WalletSummary | null>(null);
const entries = ref<WalletEntry[]>([]);
const entryPage = ref(1);
const entryTotal = ref(0);
const entryPerPage = ref(20);
const withdrawals = ref<WalletWithdrawal[]>([]);
const loading = ref(true);
const error = ref("");
const entryError = ref("");
// Demo recharge form state.
const rechargeMajor = ref("");
const recharging = ref(false);
const rechargeError = ref("");
const rechargeNotice = ref("");
// Withdrawal request form state.
const withdrawMajor = ref("");
const withdrawMethod = ref("bank");
const withdrawAccount = ref("");
const withdrawHolder = ref("");
const withdrawing = ref(false);
const withdrawError = ref("");
const withdrawNotice = ref("");
const walletCurrency = computed(() => wallet.value?.currency ?? "");
const REASON_KEYS: Record<string, string> = {
wallet_recharge: "wallet.reason_wallet_recharge",
wallet_withdrawal_freeze: "wallet.reason_wallet_withdrawal_freeze",
wallet_withdrawal_approved: "wallet.reason_wallet_withdrawal_approved",
wallet_withdrawal_rejected: "wallet.reason_wallet_withdrawal_rejected",
order_payment: "wallet.reason_order_payment",
aftersale_refund: "wallet.reason_aftersale_refund",
settlement_payout: "wallet.reason_settlement_payout",
opening_balance: "wallet.reason_opening_balance",
};
const METHOD_KEYS: Record<string, string> = {
bank: "wallet.withdrawMethodBank",
alipay: "wallet.withdrawMethodAlipay",
wechat: "wallet.withdrawMethodWechat",
};
const STATUS_KEYS: Record<WithdrawalStatus, string> = {
pending: "wallet.status_pending",
approved: "wallet.status_approved",
rejected: "wallet.status_rejected",
};
/** Currency-aware formatting; the exponent comes from the currency table. */
function money(amountMinor: number, code: string): string {
if (!code) return String(amountMinor);
return formatMoney(amountMinor, code, exponentFor(code), locale.value);
}
function reasonLabel(reason: string): string {
const key = REASON_KEYS[reason];
return key ? t(key) : reason;
}
function methodLabel(method: string): string {
const key = METHOD_KEYS[method];
return key ? t(key) : method;
}
function statusTone(status: WithdrawalStatus): "green" | "orange" | "red" {
if (status === "approved") return "green";
if (status === "rejected") return "red";
return "orange";
}
async function loadWallet(): Promise<void> {
wallet.value = await $api.getWallet();
}
async function loadEntries(page: number): Promise<void> {
const result = await $api.listWalletEntries(page);
entries.value = result.items;
entryPage.value = result.page;
entryTotal.value = result.total;
entryPerPage.value = result.per_page;
}
async function loadWithdrawals(): Promise<void> {
withdrawals.value = await $api.listMyWithdrawals();
}
/** Re-read backend truth after any mutation so no balance is local-only. */
async function refreshAll(): Promise<void> {
await Promise.all([loadWallet(), loadEntries(1), loadWithdrawals()]);
}
async function load(): Promise<void> {
loading.value = true;
error.value = "";
try {
// Currencies carry the exponent (JPY is 0), so load them before formatting.
await ensureCurrencies();
await refreshAll();
} catch {
error.value = t("wallet.loadFailed");
} finally {
loading.value = false;
}
}
async function changeEntryPage(page: number): Promise<void> {
entryError.value = "";
try {
await loadEntries(page);
} catch {
entryError.value = t("wallet.entriesFailed");
}
}
/** Major-unit input to integer minor units using the account currency exponent. */
function toMinor(major: string): number | null {
const raw = Number(major);
if (!Number.isFinite(raw) || raw <= 0) return null;
const code = walletCurrency.value;
if (!code) return null;
const minor = Math.round(raw * 10 ** exponentFor(code));
return Number.isSafeInteger(minor) && minor > 0 ? minor : null;
}
async function submitRecharge(): Promise<void> {
rechargeError.value = "";
rechargeNotice.value = "";
const amount = toMinor(rechargeMajor.value);
if (amount === null) {
rechargeError.value = t("wallet.validationRequired");
return;
}
recharging.value = true;
try {
const result = await $api.rechargeWallet(amount);
await refreshAll();
rechargeNotice.value = t("wallet.rechargeSuccess", {
amount: money(result.amount_minor, result.currency),
});
rechargeMajor.value = "";
} catch (err: unknown) {
const conflict = err instanceof ApiError && err.status === 409;
rechargeError.value = conflict ? t("wallet.rechargeConflict") : t("wallet.rechargeFailed");
// A conflict means the server state moved; converge on it instead of retrying.
if (conflict) await refreshAll();
} finally {
recharging.value = false;
}
}
async function submitWithdrawal(): Promise<void> {
withdrawError.value = "";
withdrawNotice.value = "";
const amount = toMinor(withdrawMajor.value);
const account = withdrawAccount.value.trim();
if (amount === null || !withdrawMethod.value.trim() || !account) {
withdrawError.value = t("wallet.validationRequired");
return;
}
withdrawing.value = true;
try {
const holder = withdrawHolder.value.trim();
await $api.applyWithdrawal(amount, {
method: withdrawMethod.value,
account,
...(holder ? { holder } : {}),
});
await refreshAll();
withdrawNotice.value = t("wallet.withdrawSuccess");
withdrawMajor.value = "";
withdrawAccount.value = "";
withdrawHolder.value = "";
} catch (err: unknown) {
const conflict = err instanceof ApiError && err.status === 409;
withdrawError.value = conflict ? t("wallet.withdrawConflict") : t("wallet.withdrawFailed");
if (conflict) await refreshAll();
} finally {
withdrawing.value = false;
}
}
onMounted(() => void load());
</script>
<template>
<VCard class="min-h-[560px]">
<h1 class="text-text mb-4 text-xl font-bold">{{ t("wallet.title") }}</h1>
<p
v-if="error"
role="alert"
class="border-danger/30 bg-danger/10 text-danger mb-4 border px-3.5 py-2.5 text-sm"
>
{{ error }}
</p>
<div v-else-if="loading" class="text-muted py-10">{{ t("common.loading") }}</div>
<p v-else-if="!wallet" class="text-muted py-10">{{ t("wallet.loadFailed") }}</p>
<template v-else>
<div class="mb-6 grid gap-3 sm:grid-cols-2">
<div class="border-border bg-primary-soft border p-[18px]">
<span class="text-muted block text-xs">{{ t("wallet.available") }}</span>
<strong class="text-primary mt-2.5 block text-xl">{{
money(wallet.available_minor, wallet.currency)
}}</strong>
<span class="text-muted mt-1 block text-xs">{{ wallet.currency }}</span>
</div>
<div class="border-border bg-bg border p-[18px]">
<span class="text-muted block text-xs">{{ t("wallet.frozen") }}</span>
<strong class="text-warning mt-2.5 block text-xl">{{
money(wallet.frozen_minor, wallet.currency)
}}</strong>
<span class="text-muted mt-1 block text-xs">{{ wallet.currency }}</span>
</div>
</div>
<section class="mb-6">
<h2 class="text-text mb-3 text-lg font-semibold">{{ t("wallet.entriesTitle") }}</h2>
<p v-if="entryError" role="alert" class="text-danger mb-3 text-sm">{{ entryError }}</p>
<UiEmptyState v-if="entries.length === 0" :text="t('wallet.entriesEmpty')" />
<template v-else>
<VTable>
<thead>
<tr>
<th>{{ t("wallet.entryDate") }}</th>
<th>{{ t("wallet.entryAccount") }}</th>
<th>{{ t("wallet.entryReason") }}</th>
<th class="text-right!">{{ t("wallet.entryDelta") }}</th>
<th class="text-right!">{{ t("wallet.entryBalance") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in entries" :key="entry.id">
<td class="text-muted text-xs whitespace-nowrap">
{{ entry.created_at.slice(0, 10) }}
</td>
<td>
{{
entry.account_kind === "available"
? t("wallet.accountAvailable")
: t("wallet.accountFrozen")
}}
</td>
<td>{{ reasonLabel(entry.reason) }}</td>
<td
class="text-right font-semibold"
:class="entry.delta_minor < 0 ? 'text-danger' : 'text-success'"
>
{{ entry.delta_minor < 0 ? "−" : "+"
}}{{ money(Math.abs(entry.delta_minor), wallet.currency) }}
</td>
<td class="text-right">{{ money(entry.balance_minor, wallet.currency) }}</td>
</tr>
</tbody>
</VTable>
<UiPagination
:page="entryPage"
:total="entryTotal"
:per-page="entryPerPage"
@change="changeEntryPage"
/>
</template>
</section>
<section class="border-border mb-6 rounded-md border p-4">
<div class="mb-1 flex flex-wrap items-center gap-2">
<h2 class="text-text m-0 text-lg font-semibold">{{ t("wallet.rechargeTitle") }}</h2>
<VBadge tone="orange">{{ t("wallet.rechargeDemoBadge") }}</VBadge>
</div>
<p class="text-muted mt-1 mb-3 text-xs">{{ t("wallet.rechargeDemoNote") }}</p>
<form class="grid max-w-[520px] gap-2" @submit.prevent="submitRecharge">
<VField :label="t('wallet.rechargeAmount', { currency: wallet.currency })">
<VInput v-model="rechargeMajor" inputmode="decimal" placeholder="0.00" />
<p class="text-muted m-0 mt-1 text-xs">
{{ t("wallet.rechargeAmountHint", { currency: wallet.currency }) }}
</p>
</VField>
<p v-if="rechargeError" role="alert" class="text-danger m-0 text-xs">
{{ rechargeError }}
</p>
<p v-if="rechargeNotice" role="status" class="text-success m-0 text-xs">
{{ rechargeNotice }}
</p>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="recharging">
{{ t("wallet.rechargeSubmit") }}
</VBtn>
</form>
</section>
<section class="border-border mb-6 rounded-md border p-4">
<h2 class="text-text mb-1 text-lg font-semibold">{{ t("wallet.withdrawTitle") }}</h2>
<p class="text-muted mt-1 mb-3 text-xs">{{ t("wallet.withdrawHint") }}</p>
<form class="grid max-w-[520px] gap-2" @submit.prevent="submitWithdrawal">
<VField :label="t('wallet.withdrawAmount', { currency: wallet.currency })">
<VInput v-model="withdrawMajor" inputmode="decimal" placeholder="0.00" />
<p class="text-muted m-0 mt-1 text-xs">{{ t("wallet.withdrawAmountHint") }}</p>
</VField>
<VField :label="t('wallet.withdrawMethod')">
<select
v-model="withdrawMethod"
class="border-border bg-surface text-text focus:border-primary focus:outline-primary/30 w-full rounded-md border px-3 py-2 text-sm focus:outline-2"
>
<option value="bank">{{ t("wallet.withdrawMethodBank") }}</option>
<option value="alipay">{{ t("wallet.withdrawMethodAlipay") }}</option>
<option value="wechat">{{ t("wallet.withdrawMethodWechat") }}</option>
</select>
</VField>
<VField :label="t('wallet.withdrawAccount')">
<VInput v-model="withdrawAccount" :placeholder="t('wallet.withdrawAccountHint')" />
</VField>
<VField :label="t('wallet.withdrawHolder')">
<VInput v-model="withdrawHolder" />
</VField>
<p v-if="withdrawError" role="alert" class="text-danger m-0 text-xs">
{{ withdrawError }}
</p>
<p v-if="withdrawNotice" role="status" class="text-success m-0 text-xs">
{{ withdrawNotice }}
</p>
<VBtn class="w-fit" variant="primary" type="submit" :disabled="withdrawing">
{{ t("wallet.withdrawSubmit") }}
</VBtn>
</form>
</section>
<section>
<h2 class="text-text mb-3 text-lg font-semibold">{{ t("wallet.historyTitle") }}</h2>
<UiEmptyState v-if="withdrawals.length === 0" :text="t('wallet.historyEmpty')" />
<VTable v-else>
<thead>
<tr>
<th>{{ t("wallet.historyCreatedAt") }}</th>
<th>{{ t("wallet.historyAmount") }}</th>
<th>{{ t("wallet.historyMethod") }}</th>
<th>{{ t("wallet.historyAccount") }}</th>
<th>{{ t("wallet.historyStatus") }}</th>
<th>{{ t("wallet.historyNote") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in withdrawals" :key="row.id">
<td class="text-muted text-xs whitespace-nowrap">
{{ row.created_at.slice(0, 10) }}
</td>
<td class="font-semibold">{{ money(row.amount_minor, row.currency) }}</td>
<td>{{ methodLabel(row.account_details.method) }}</td>
<td class="text-xs">{{ row.account_details.account }}</td>
<td>
<VBadge :tone="statusTone(row.status)">{{ t(STATUS_KEYS[row.status]) }}</VBadge>
</td>
<td class="text-muted text-xs">
{{ row.review_note ?? t("mall.notAvailable") }}
</td>
</tr>
</tbody>
</VTable>
</section>
</template>
</VCard>
</template>