Files
vmall/apps/mall/pages/user/wallet.vue
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

371 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>