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.
371 lines
14 KiB
Vue
371 lines
14 KiB
Vue
<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>
|