fix(payment): restore recharge crediting and balance refresh

This commit is contained in:
elky
2026-09-11 13:04:54 +08:00
parent b748b5bfd2
commit 60b89cc840
5 changed files with 557 additions and 34 deletions
+9 -12
View File
@@ -132,6 +132,13 @@ export interface PaymentOrder {
expires_at: string | null
}
export interface WalletRechargeOrdersResponse extends WalletBalanceResponse {
items: PaymentOrder[]
total: number
limit: number
offset: number
}
export interface RefundRequest {
id: string
refund_no: string
@@ -235,18 +242,8 @@ export const walletApi = {
return response.data
},
async listRechargeOrders(params?: { limit?: number; offset?: number }): Promise<{
items: PaymentOrder[]
total: number
limit: number
offset: number
}> {
const response = await apiClient.get<{
items: PaymentOrder[]
total: number
limit: number
offset: number
}>('/api/wallet/recharge', { params })
async listRechargeOrders(params?: { limit?: number; offset?: number }): Promise<WalletRechargeOrdersResponse> {
const response = await apiClient.get<WalletRechargeOrdersResponse>('/api/wallet/recharge', { params })
return response.data
},
+97 -10
View File
@@ -745,7 +745,7 @@ import {
walletTransactionReasonLabel,
} from '@/utils/walletDisplay'
const { success, error: showError } = useToast()
const { success, info, error: showError } = useToast()
const ENABLE_WALLET_ACTION_FORMS = true
@@ -787,6 +787,10 @@ const loadedTabs = new Set<string>()
const tabLoadPromises = new Map<string, Promise<void>>()
let refundEligibilityLoaded = false
let todayCostPollTimer: ReturnType<typeof setInterval> | null = null
let orderPollTimer: ReturnType<typeof setTimeout> | null = null
let orderLoadVersion = 0
let unmounted = false
const pendingOrders = new Map<string, PaymentOrder>()
const rechargeForm = reactive({
amount_usd: 10,
@@ -922,6 +926,8 @@ onMounted(async () => {
loadTransactions(),
loadRechargeOptions(),
])
if (unmounted) return
await loadOrders()
syncTodayCostPolling()
} finally {
loadingInitial.value = false
@@ -929,7 +935,10 @@ onMounted(async () => {
})
onBeforeUnmount(() => {
unmounted = true
orderLoadVersion += 1
stopTodayCostPolling()
stopOrderPolling()
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
@@ -943,7 +952,8 @@ watch(refundableOrders, () => {
})
async function refreshWallet() {
await Promise.all([loadBalance(), loadOrders(), loadTransactions()])
await loadOrders()
await Promise.all([loadBalance(), loadTransactions()])
}
async function loadBalance() {
@@ -991,7 +1001,7 @@ async function loadTodayCost() {
}
function syncTodayCostPolling() {
if (activeTab.value === 'transactions' && !document.hidden) {
if (!unmounted && activeTab.value === 'transactions' && !document.hidden) {
startTodayCostPolling()
} else {
stopTodayCostPolling()
@@ -1013,22 +1023,96 @@ function stopTodayCostPolling() {
function handleVisibilityChange() {
syncTodayCostPolling()
if (document.hidden) {
stopOrderPolling()
} else if (!loadingInitial.value) {
void reloadOrders(true)
}
}
async function loadOrders() {
loadingOrders.value = true
function isAwaitingCredit(order: PaymentOrder): boolean {
return order.status === 'paid'
|| (order.status === 'pending'
&& (!order.expires_at || new Date(order.expires_at).getTime() > Date.now()))
}
function stopOrderPolling() {
if (orderPollTimer === null) return
clearTimeout(orderPollTimer)
orderPollTimer = null
}
function scheduleOrderPolling() {
stopOrderPolling()
if (unmounted || document.hidden || ![...pendingOrders.values()].some(isAwaitingCredit)) return
orderPollTimer = setTimeout(() => {
orderPollTimer = null
void reloadOrders(true)
}, 5_000)
}
function loadOrders() {
return reloadOrders(false)
}
async function reloadOrders(silent: boolean) {
if (unmounted) return
stopOrderPolling()
const version = ++orderLoadVersion
loadingOrders.value = !silent
try {
const offset = (orderPage.value - 1) * orderPageSize.value
const resp = await walletApi.listRechargeOrders({ limit: orderPageSize.value, offset })
if (unmounted || version !== orderLoadVersion) return
const orders = [...resp.items]
const visibleIds = new Set(orders.map(order => order.id))
const missingOrders = [...pendingOrders.values()].filter(order =>
isAwaitingCredit(order) && !visibleIds.has(order.id)
)
const details = await Promise.allSettled(missingOrders.map(order => walletApi.getRechargeOrder(order.id)))
if (unmounted || version !== orderLoadVersion) return
for (const detail of details) {
if (detail.status === 'fulfilled') orders.push(detail.value.order)
else log.error('加载待到账订单失败:', detail.reason)
}
const newlyCredited = orders.filter(order =>
order.status === 'credited' && pendingOrders.has(order.id)
)
for (const order of orders) {
if (isAwaitingCredit(order)) pendingOrders.set(order.id, order)
else if (order.status !== 'credited') pendingOrders.delete(order.id)
if (latestRecharge.value?.order.id === order.id) latestRecharge.value.order = order
}
// Order lists include the wallet snapshot, but omit the daily package quota.
const { items: _items, total: _total, limit: _limit, offset: _offset, ...balance } = resp
const currentPackageBalance = packageBalance.value
const currentWalletBalance = Number(balance.wallet?.balance ?? balance.balance ?? 0)
walletBalance.value = {
...walletBalance.value,
...balance,
wallet_balance: Math.max(0, currentWalletBalance),
total_available_balance: balance.unlimited ? null : Math.max(0, currentWalletBalance + currentPackageBalance),
}
rechargeOrders.value = resp.items
orderTotal.value = resp.total
loadedTabs.add('orders')
syncRefundOrderSelection()
if (newlyCredited.length > 0) {
// Read after the credited status so a pre-credit list snapshot cannot win.
await Promise.all([loadBalance(), loadTransactions()])
for (const order of newlyCredited) pendingOrders.delete(order.id)
if (!unmounted && version === orderLoadVersion) success('充值已到账,余额已更新')
}
} catch (error) {
if (unmounted || version !== orderLoadVersion) return
log.error('加载充值订单失败:', error)
showError(parseApiError(error, '加载充值订单失败'))
if (!silent) showError(parseApiError(error, '加载充值订单失败'))
} finally {
loadingOrders.value = false
if (!unmounted && version === orderLoadVersion) {
loadingOrders.value = false
scheduleOrderPolling()
}
}
}
@@ -1164,8 +1248,11 @@ async function submitRecharge() {
payment_channel: option.payment_channel,
idempotency_key: rechargeForm.idempotency_key,
})
if (isAwaitingCredit(latestRecharge.value.order)) {
pendingOrders.set(latestRecharge.value.order.id, latestRecharge.value.order)
}
success('充值订单创建成功')
await Promise.all([loadOrders(), loadBalance()])
await loadOrders()
activeTab.value = 'orders'
submitPaymentInstructions(latestRecharge.value.payment_instructions)
rechargeForm.idempotency_key = ''
@@ -1234,8 +1321,8 @@ function isSafariBrowser(): boolean {
}
async function handleStripePaymentSuccess() {
success('支付已完成,正在刷新钱包余额')
await Promise.all([loadBalance(), loadOrders(), loadTransactions(), loadTodayCost()])
info('支付已提交,正在等待充值到账')
await loadOrders()
activeTab.value = 'orders'
}
@@ -0,0 +1,225 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick, type App } from 'vue'
import type { PaymentOrder, WalletBalanceResponse } from '@/api/wallet'
import WalletCenter from '../WalletCenter.vue'
const walletApiMock = vi.hoisted(() => ({
getBalance: vi.fn(),
getFlow: vi.fn(),
getTodayCost: vi.fn(),
listRechargeOptions: vi.fn(),
listRechargeOrders: vi.fn(),
getRechargeOrder: vi.fn(),
createRechargeOrder: vi.fn(),
}))
const toastMock = vi.hoisted(() => ({ success: vi.fn(), info: vi.fn(), error: vi.fn() }))
vi.mock('@/api/wallet', () => ({ walletApi: walletApiMock }))
vi.mock('@/composables/useToast', () => ({ useToast: () => toastMock }))
vi.mock('@/utils/logger', () => ({ log: { error: vi.fn() } }))
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
const passthrough = defineComponent({ setup: (_, { slots }) => () => h('div', slots.default?.()) })
const button = defineComponent({ setup: (_, { slots }) => () => h('button', slots.default?.()) })
return {
...Object.fromEntries([
'Badge', 'Card', 'Input', 'Label', 'Select', 'SelectContent', 'SelectItem',
'SelectTrigger', 'SelectValue', 'Table', 'TableBody', 'TableCell', 'TableHead',
'TableHeader', 'TableRow', 'Tabs', 'TabsContent', 'TabsList', 'TabsTrigger', 'Textarea',
].map(name => [name, passthrough])),
Button: button,
RefreshButton: defineComponent({ setup: () => () => h('button', { 'data-refresh': true }, '刷新') }),
Pagination: defineComponent({
props: { current: Number },
emits: ['update:current'],
setup: (_, { emit }) => () => h('button', {
'data-next-page': true,
onClick: () => emit('update:current', 2),
}, '下一页'),
}),
}
})
vi.mock('@/components/common', async () => {
const { defineComponent, h } = await import('vue')
const empty = defineComponent({ setup: () => () => h('div') })
return {
EmptyState: empty,
LoadingState: empty,
StripePaymentDialog: defineComponent({
emits: ['success'],
setup: (_, { emit }) => () => h('button', {
'data-stripe-success': true,
onClick: () => emit('success', { intentId: 'pi-1', status: 'processing' }),
}, 'Stripe 提交'),
}),
}
})
const mountedApps: Array<{ app: App; root: HTMLElement }> = []
let hidden = false
function walletBalance(amount: number): WalletBalanceResponse {
return {
wallet: {
id: 'wallet-1', balance: amount, recharge_balance: amount, gift_balance: 0,
refundable_balance: amount, currency: 'USD', status: 'active', total_recharged: amount,
total_consumed: 0, total_refunded: 0, total_adjusted: 0, updated_at: '2026-09-11T00:00:00Z',
},
balance: amount, unlimited: false, limit_mode: 'finite', currency: 'USD',
wallet_balance: amount, package_balance: 3, total_available_balance: amount + 3,
daily_quota: { has_active: true, total_usd: 5, used_usd: 2, remaining_usd: 3, allow_wallet_overage: true },
}
}
function paymentOrder(status = 'pending', overrides: Partial<PaymentOrder> = {}): PaymentOrder {
return {
id: 'order-1', order_no: 'RECHARGE-1', wallet_id: 'wallet-1', user_id: 'user-1',
amount_usd: 10, pay_amount: 10, pay_currency: 'USD', exchange_rate: 1,
refunded_amount_usd: 0, refundable_amount_usd: status === 'credited' ? 10 : 0,
payment_method: 'epay', gateway_order_id: 'gateway-1', gateway_response: null,
status, created_at: '2026-09-11T00:00:00Z', paid_at: null,
credited_at: status === 'credited' ? '2026-09-11T00:01:00Z' : null, expires_at: null,
...overrides,
}
}
function orderResponse(items: PaymentOrder[], amount = 2, offset = 0) {
const { wallet_balance: _wallet, package_balance: _package, total_available_balance: _total, daily_quota: _quota, ...balance } = walletBalance(amount)
return { ...balance, items, total: items.length, limit: 20, offset }
}
async function flushPromises() {
for (let i = 0; i < 10; i += 1) await Promise.resolve()
await nextTick()
}
async function mountWallet() {
const root = document.createElement('div')
document.body.append(root)
const app = createApp(WalletCenter)
app.mount(root)
mountedApps.push({ app, root })
await flushPromises()
return { app, root }
}
function setHidden(value: boolean) {
hidden = value
document.dispatchEvent(new Event('visibilitychange'))
}
beforeEach(() => {
vi.useFakeTimers()
vi.resetAllMocks()
hidden = false
vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden)
walletApiMock.getBalance.mockResolvedValue(walletBalance(2))
walletApiMock.getFlow.mockResolvedValue({ items: [], total: 0, today_entry: null })
walletApiMock.getTodayCost.mockResolvedValue(null)
walletApiMock.listRechargeOptions.mockResolvedValue({ items: [] })
walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder()]))
})
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('WalletCenter recharge synchronization', () => {
it('waits for server credit after Stripe submission and refreshes the credited balance and flow', async () => {
const { root } = await mountWallet()
root.querySelector<HTMLButtonElement>('[data-stripe-success]')!.click()
await flushPromises()
expect(toastMock.info).toHaveBeenCalledWith('支付已提交,正在等待充值到账')
expect(toastMock.success).not.toHaveBeenCalled()
walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder('paid')]))
await vi.advanceTimersByTimeAsync(5_000)
expect(toastMock.success).not.toHaveBeenCalled()
// The order query can observe the credit after its wallet snapshot was read.
walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder('credited')], 2))
walletApiMock.getBalance.mockResolvedValue(walletBalance(12))
await vi.advanceTimersByTimeAsync(5_000)
expect(root.textContent).toContain('$12.00')
expect(root.textContent).toContain('$15.00')
expect(walletApiMock.getFlow).toHaveBeenCalledTimes(2)
expect(toastMock.success).toHaveBeenCalledWith('充值已到账,余额已更新')
const calls = walletApiMock.listRechargeOrders.mock.calls.length
await vi.advanceTimersByTimeAsync(15_000)
expect(walletApiMock.listRechargeOrders).toHaveBeenCalledTimes(calls)
})
it('updates the wallet and preserves package quota when the orders refresh button is clicked', async () => {
walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder('credited')], 2))
const { root } = await mountWallet()
walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder('credited')], 12))
root.querySelectorAll<HTMLButtonElement>('[data-refresh]')[4]!.click()
await flushPromises()
expect(root.textContent).toContain('$12.00')
expect(root.textContent).toContain('$15.00')
expect(root.textContent).toContain('已用 $2.00 / 每日 $5.00')
expect(walletApiMock.getBalance).toHaveBeenCalledTimes(1)
})
it('pauses polling while hidden and checks credit as soon as the page becomes visible', async () => {
const { root } = await mountWallet()
setHidden(true)
await vi.advanceTimersByTimeAsync(20_000)
expect(walletApiMock.listRechargeOrders).toHaveBeenCalledTimes(1)
walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder('credited')], 12))
walletApiMock.getBalance.mockResolvedValue(walletBalance(12))
setHidden(false)
await flushPromises()
expect(walletApiMock.listRechargeOrders).toHaveBeenCalledTimes(2)
expect(root.textContent).toContain('$12.00')
expect(walletApiMock.getFlow).toHaveBeenCalledTimes(2)
})
it('keeps tracking a pending recharge after the user changes the order page', async () => {
const { root } = await mountWallet()
walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([], 2, 20))
walletApiMock.getRechargeOrder.mockResolvedValue({ order: paymentOrder('credited') })
walletApiMock.getBalance.mockResolvedValue(walletBalance(12))
root.querySelectorAll<HTMLButtonElement>('[data-next-page]')[1]!.click()
await flushPromises()
expect(walletApiMock.getRechargeOrder).toHaveBeenCalledWith('order-1')
expect(root.textContent).toContain('$12.00')
expect(toastMock.success).toHaveBeenCalledTimes(1)
})
it('retries a temporary polling failure without showing an error toast', async () => {
const { root } = await mountWallet()
walletApiMock.listRechargeOrders.mockRejectedValueOnce(new Error('network unavailable'))
await vi.advanceTimersByTimeAsync(5_000)
expect(toastMock.error).not.toHaveBeenCalled()
walletApiMock.listRechargeOrders.mockResolvedValue(orderResponse([paymentOrder('credited')], 12))
walletApiMock.getBalance.mockResolvedValue(walletBalance(12))
await vi.advanceTimersByTimeAsync(5_000)
expect(root.textContent).toContain('$12.00')
expect(toastMock.success).toHaveBeenCalledTimes(1)
})
it('does not restart polling when an in-flight response completes after unmount', async () => {
const { app } = await mountWallet()
let resolve!: (value: ReturnType<typeof orderResponse>) => void
walletApiMock.listRechargeOrders.mockReturnValue(new Promise(complete => { resolve = complete }))
await vi.advanceTimersByTimeAsync(5_000)
app.unmount()
mountedApps.splice(0).forEach(({ root }) => root.remove())
resolve(orderResponse([paymentOrder()]))
await flushPromises()
expect(vi.getTimerCount()).toBe(0)
})
})