fix(payment): refresh balance after fulfillment

This commit is contained in:
ranxi2001
2026-08-24 22:30:24 +08:00
parent 3af5443b22
commit eb594eefc9
2 changed files with 57 additions and 3 deletions
@@ -107,6 +107,7 @@ import {
readPaymentRecoverySnapshot,
} from '@/components/payment/paymentFlow'
import { usePaymentStore } from '@/stores/payment'
import { useAuthStore } from '@/stores/auth'
import { paymentAPI } from '@/api/payment'
import type { PublicOrderVerifyResult } from '@/api/payment'
import type { OrderStatus, PaymentOrder } from '@/types/payment'
@@ -118,6 +119,7 @@ const { t } = i18n
const route = useRoute()
const router = useRouter()
const paymentStore = usePaymentStore()
const authStore = useAuthStore()
type ResolvedOrder = PaymentOrder | PublicOrderVerifyResult
@@ -139,6 +141,7 @@ const STATUS_REFRESH_INTERVAL_MS = 2000
const STATUS_REFRESH_MAX_ATTEMPTS = 15
let statusRefreshTimer: ReturnType<typeof setTimeout> | null = null
let userBalanceRefreshStarted = false
const refreshAttempts = ref(0)
/** 充值金额 = pay_amount / (1 + fee_rate/100),fee_rate=0 时等于 pay_amount */
@@ -197,6 +200,21 @@ function setResolvedOrder(nextOrder: ResolvedOrder | null): void {
if (nextOrder && 'currency' in nextOrder && nextOrder.currency) {
currency.value = normalizePaymentCurrency(nextOrder.currency)
}
refreshUserBalanceForSuccessfulOrder(nextOrder)
}
function refreshUserBalanceForSuccessfulOrder(nextOrder: ResolvedOrder | null): void {
if (!nextOrder || userBalanceRefreshStarted || normalizeOrderStatus(nextOrder.status) !== 'COMPLETED') {
return
}
if ('order_type' in nextOrder && nextOrder.order_type !== 'balance') {
return
}
userBalanceRefreshStarted = true
void authStore.refreshUser().catch(() => {
// The order result remains authoritative even if refreshing profile data fails.
})
}
function hasOrderId(nextOrder: ResolvedOrder | null): nextOrder is PaymentOrder {
@@ -10,6 +10,7 @@ const pollOrderStatus = vi.hoisted(() => vi.fn())
const verifyOrder = vi.hoisted(() => vi.fn())
const verifyOrderPublic = vi.hoisted(() => vi.fn())
const resolveOrderPublicByResumeToken = vi.hoisted(() => vi.fn())
const refreshUser = vi.hoisted(() => vi.fn())
vi.mock('vue-router', async () => {
const actual = await vi.importActual<typeof import('vue-router')>('vue-router')
@@ -36,6 +37,12 @@ vi.mock('@/stores/payment', () => ({
}),
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({
refreshUser,
}),
}))
vi.mock('@/api/payment', () => ({
paymentAPI: {
verifyOrder,
@@ -91,6 +98,8 @@ describe('PaymentResultView', () => {
verifyOrder.mockReset()
verifyOrderPublic.mockReset()
resolveOrderPublicByResumeToken.mockReset()
refreshUser.mockReset()
refreshUser.mockResolvedValue({})
window.localStorage.clear()
})
@@ -171,7 +180,7 @@ describe('PaymentResultView', () => {
}))
resolveOrderPublicByResumeToken.mockResolvedValue({
data: {
...orderFactory('PAID'),
...orderFactory('COMPLETED'),
amount: 100,
pay_amount: 103,
fee_rate: 3,
@@ -190,13 +199,14 @@ describe('PaymentResultView', () => {
expect(pollOrderStatus).not.toHaveBeenCalled()
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledWith('resume-authoritative')
expect(refreshUser).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('payment.result.success')
expect(wrapper.text()).toContain('103.00')
expect(wrapper.text()).toContain('100.00')
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
})
it('refreshes a pending resume-token result until the order becomes paid', async () => {
it('waits for completed fulfillment before refreshing the user balance', async () => {
vi.useFakeTimers()
routeState.query = {
resume_token: 'resume-77',
@@ -210,7 +220,7 @@ describe('PaymentResultView', () => {
data: orderFactory('PENDING'),
})
.mockResolvedValueOnce({
data: orderFactory('PAID'),
data: orderFactory('COMPLETED'),
})
const wrapper = mount(PaymentResultView, {
@@ -224,6 +234,7 @@ describe('PaymentResultView', () => {
await flushPromises()
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(1)
expect(refreshUser).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('payment.result.processing')
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).not.toBeNull()
@@ -231,11 +242,36 @@ describe('PaymentResultView', () => {
await flushPromises()
expect(resolveOrderPublicByResumeToken).toHaveBeenCalledTimes(2)
expect(refreshUser).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('payment.result.success')
expect(wrapper.text()).not.toContain('payment.result.failed')
expect(window.localStorage.getItem(PAYMENT_RECOVERY_STORAGE_KEY)).toBeNull()
})
it('keeps the successful result when refreshing the user balance fails', async () => {
routeState.query = {
resume_token: 'resume-refresh-failure',
}
resolveOrderPublicByResumeToken.mockResolvedValue({
data: orderFactory('COMPLETED'),
})
refreshUser.mockRejectedValueOnce(new Error('profile refresh failed'))
const wrapper = mount(PaymentResultView, {
global: {
stubs: {
OrderStatusBadge: true,
},
},
})
await flushPromises()
expect(refreshUser).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('payment.result.success')
expect(wrapper.text()).not.toContain('payment.result.failed')
})
it('falls back to order_id polling when resume-token recovery fails', async () => {
routeState.query = {
resume_token: 'resume-fail',