Fix refund pending finalization gaps

This commit is contained in:
wucm667
2026-06-30 10:19:50 +08:00
parent 7316d83027
commit 93a3bf3077
17 changed files with 591 additions and 39 deletions
+14 -1
View File
@@ -49,6 +49,14 @@ export interface UpdatePaymentConfigRequest {
help_text?: string
}
export interface RefundResult {
success: boolean
warning?: string
require_force?: boolean
balance_deducted?: number
subscription_days_deducted?: number
}
export const adminPaymentAPI = {
// ==================== Config ====================
@@ -105,7 +113,12 @@ export const adminPaymentAPI = {
/** Process a refund */
refundOrder(id: number, data: { amount: number; reason: string; deduct_balance?: boolean; force?: boolean }) {
return apiClient.post(`/admin/payment/orders/${id}/refund`, data)
return apiClient.post<RefundResult>(`/admin/payment/orders/${id}/refund`, data)
},
/** Query and finalize a pending refund */
queryRefund(id: number) {
return apiClient.post<RefundResult>(`/admin/payment/orders/${id}/refund/query`)
},
// ==================== Channels ====================
+10 -2
View File
@@ -16,6 +16,14 @@ import type {
} from '@/types/payment'
import type { BasePaginationResponse } from '@/types'
export interface PublicOrderVerifyResult {
out_trade_no: string
status: string
paid: boolean
created_at: string
expires_at: string
}
export const paymentAPI = {
/** Get payment configuration (enabled types, limits, etc.) */
getConfig() {
@@ -69,12 +77,12 @@ export const paymentAPI = {
/** Legacy-compatible public order lookup by out_trade_no */
verifyOrderPublic(outTradeNo: string) {
return apiClient.post<PaymentOrder>('/payment/public/orders/verify', { out_trade_no: outTradeNo })
return apiClient.post<PublicOrderVerifyResult>('/payment/public/orders/verify', { out_trade_no: outTradeNo })
},
/** Resolve an order from a signed resume token without auth */
resolveOrderPublicByResumeToken(resumeToken: string) {
return apiClient.post<PaymentOrder>('/payment/public/orders/resolve', { resume_token: resumeToken })
return apiClient.post<PublicOrderVerifyResult>('/payment/public/orders/resolve', { resume_token: resumeToken })
},
/** Request a refund for a completed order */
+2
View File
@@ -7201,6 +7201,8 @@ export default {
refundReasonPlaceholder: 'Please enter refund reason',
confirmRefund: 'Confirm Refund',
refundSuccess: 'Refund successful',
refundPending: 'Refund pending gateway confirmation',
queryRefundStatus: 'Query refund status',
refundInfo: 'Refund Info',
refundEnabled: 'Refund Enabled',
allowUserRefund: 'Allow User Refund',
+2
View File
@@ -7381,6 +7381,8 @@ export default {
refundReasonPlaceholder: '请输入退款原因',
confirmRefund: '确认退款',
refundSuccess: '退款成功',
refundPending: '退款处理中,待网关确认',
queryRefundStatus: '查询退款状态',
refundInfo: '退款信息',
refundEnabled: '允许退款',
alreadyRefunded: '已退款',
@@ -45,6 +45,10 @@
<Icon name="refresh" size="sm" />
{{ t('payment.admin.retryRefund') }}
</button>
<button v-else-if="row.status === 'REFUND_PENDING'" :disabled="refundQueryingIds.has(row.id)" @click="handleQueryRefund(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-orange-600 hover:bg-orange-50 disabled:opacity-60 dark:text-orange-400 dark:hover:bg-orange-900/20">
<Icon name="refresh" size="sm" :class="refundQueryingIds.has(row.id) ? 'animate-spin' : ''" />
{{ t('payment.admin.queryRefundStatus') }}
</button>
<button v-else-if="row.status === 'COMPLETED' || row.status === 'PARTIALLY_REFUNDED'" @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20">
<Icon name="dollar" size="sm" />
{{ t('payment.admin.refund') }}
@@ -149,6 +153,7 @@ const selectedOrder = ref<PaymentOrder | null>(null)
const showDetailDialog = ref(false)
const showRefundDialog = ref(false)
const refundSubmitting = ref(false)
const refundQueryingIds = ref(new Set<number>())
const orderAuditLogs = ref<AuditLog[]>([])
const creditedAmountSymbol = currencySymbol('USD')
@@ -232,16 +237,53 @@ async function handleRetryOrder(order: PaymentOrder) {
function openRefundDialog(order: PaymentOrder) { selectedOrder.value = order; showRefundDialog.value = true }
function isRefundPendingWarning(warning: string | undefined): boolean {
return /pending|处理中|待/.test(String(warning || '').toLowerCase())
}
async function handleRefund(data: { amount: number; reason: string; deduct_balance: boolean; force: boolean }) {
if (!selectedOrder.value) return
refundSubmitting.value = true
try {
await adminPaymentAPI.refundOrder(selectedOrder.value.id, { amount: data.amount, reason: data.reason, deduct_balance: data.deduct_balance, force: data.force })
appStore.showSuccess(t('payment.admin.refundSuccess')); showRefundDialog.value = false; loadOrders()
const res = await adminPaymentAPI.refundOrder(selectedOrder.value.id, { amount: data.amount, reason: data.reason, deduct_balance: data.deduct_balance, force: data.force })
if (res.data.success) {
appStore.showSuccess(t('payment.admin.refundSuccess'))
showRefundDialog.value = false
loadOrders()
return
}
if (isRefundPendingWarning(res.data.warning)) {
appStore.showSuccess(t('payment.admin.refundPending'))
showRefundDialog.value = false
loadOrders()
return
}
appStore.showError(res.data.warning || t('common.error'))
} catch (err: unknown) { appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error'))) }
finally { refundSubmitting.value = false }
}
async function handleQueryRefund(order: PaymentOrder) {
refundQueryingIds.value = new Set(refundQueryingIds.value).add(order.id)
try {
const res = await adminPaymentAPI.queryRefund(order.id)
if (res.data.success) {
appStore.showSuccess(t('payment.admin.refundSuccess'))
} else if (isRefundPendingWarning(res.data.warning)) {
appStore.showSuccess(t('payment.admin.refundPending'))
} else {
appStore.showError(res.data.warning || t('common.error'))
}
loadOrders()
} catch (err: unknown) {
appStore.showError(extractI18nErrorMessage(err, t, 'payment.errors', t('common.error')))
} finally {
const next = new Set(refundQueryingIds.value)
next.delete(order.id)
refundQueryingIds.value = next
}
}
function formatDateTime(dateStr: string): string { return formatOrderDateTime(dateStr) }
onMounted(() => loadOrders())
+39 -20
View File
@@ -35,7 +35,7 @@
<!-- Order Info -->
<div v-if="order" class="rounded-xl bg-white p-5 shadow-sm dark:bg-dark-800">
<div class="space-y-3 text-sm">
<div class="flex justify-between">
<div v-if="hasOrderId(order)" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</span>
<span class="font-medium text-gray-900 dark:text-white">#{{ order.id }}</span>
</div>
@@ -43,29 +43,29 @@
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderNo') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ order.out_trade_no }}</span>
</div>
<div class="flex justify-between">
<div v-if="hasAmountFields(order)" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.baseAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ formatGatewayAmount(baseAmount) }}</span>
</div>
<div v-if="order.fee_rate > 0" class="flex justify-between">
<div v-if="hasAmountFields(order) && order.fee_rate > 0" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.fee') }} ({{ order.fee_rate }}%)</span>
<span class="font-medium text-gray-900 dark:text-white">{{ formatGatewayAmount(feeAmount) }}</span>
</div>
<div class="flex justify-between">
<div v-if="hasAmountFields(order)" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</span>
<span class="font-bold text-primary-600 dark:text-primary-400">{{ formatGatewayAmount(order.pay_amount) }}</span>
</div>
<div v-if="order.amount !== order.pay_amount" class="flex justify-between">
<div v-if="hasAmountFields(order) && order.amount !== order.pay_amount" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.creditedAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ order.order_type === 'balance' ? '$' + order.amount.toFixed(2) : formatGatewayAmount(order.amount) }}</span>
</div>
<div class="flex justify-between">
<div v-if="hasPaymentType(order)" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ t(paymentMethodI18nKey(order.payment_type), normalizedOrderPaymentType(order.payment_type)) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.status') }}</span>
<OrderStatusBadge :status="order.status" />
<OrderStatusBadge :status="displayOrderStatus(order.status)" />
</div>
</div>
</div>
@@ -108,7 +108,8 @@ import {
} from '@/components/payment/paymentFlow'
import { usePaymentStore } from '@/stores/payment'
import { paymentAPI } from '@/api/payment'
import type { PaymentOrder } from '@/types/payment'
import type { PublicOrderVerifyResult } from '@/api/payment'
import type { OrderStatus, PaymentOrder } from '@/types/payment'
import { formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import { normalizePaymentMethodForDisplay, paymentMethodI18nKey } from './paymentUx'
@@ -118,7 +119,9 @@ const route = useRoute()
const router = useRouter()
const paymentStore = usePaymentStore()
const order = ref<PaymentOrder | null>(null)
type ResolvedOrder = PaymentOrder | PublicOrderVerifyResult
const order = ref<ResolvedOrder | null>(null)
const loading = ref(true)
const currency = ref('CNY')
@@ -140,7 +143,7 @@ const refreshAttempts = ref(0)
/** 充值金额 = pay_amount / (1 + fee_rate/100),fee_rate=0 时等于 pay_amount */
const baseAmount = computed(() => {
if (!order.value) return 0
if (!hasAmountFields(order.value)) return 0
const feeRate = Number(order.value.fee_rate) || 0
if (feeRate <= 0) return order.value.pay_amount ?? 0
return Math.round((order.value.pay_amount / (1 + feeRate / 100)) * 100) / 100
@@ -148,7 +151,7 @@ const baseAmount = computed(() => {
/** 手续费 = pay_amount - baseAmount */
const feeAmount = computed(() => {
if (!order.value) return 0
if (!hasAmountFields(order.value)) return 0
const feeRate = Number(order.value.fee_rate) || 0
if (feeRate <= 0) return 0
return Math.round((order.value.pay_amount - baseAmount.value) * 100) / 100
@@ -182,24 +185,40 @@ const statusTitle = computed(() => {
})
function normalizedOrderPaymentType(paymentType: string): string {
return normalizePaymentMethodForDisplay(paymentType) || paymentType
return normalizePaymentMethodForDisplay(paymentType || '') || paymentType || ''
}
function formatGatewayAmount(value: number): string {
return formatPaymentAmount(value, currency.value, localeCode.value)
}
function setResolvedOrder(nextOrder: PaymentOrder | null): void {
function setResolvedOrder(nextOrder: ResolvedOrder | null): void {
order.value = nextOrder
if (nextOrder?.currency) {
if (nextOrder && 'currency' in nextOrder && nextOrder.currency) {
currency.value = normalizePaymentCurrency(nextOrder.currency)
}
}
function hasOrderId(nextOrder: ResolvedOrder | null): nextOrder is PaymentOrder {
return !!nextOrder && 'id' in nextOrder && typeof nextOrder.id === 'number'
}
function hasAmountFields(nextOrder: ResolvedOrder | null): nextOrder is PaymentOrder {
return !!nextOrder && 'pay_amount' in nextOrder && typeof nextOrder.pay_amount === 'number' && 'amount' in nextOrder && typeof nextOrder.amount === 'number'
}
function hasPaymentType(nextOrder: ResolvedOrder | null): nextOrder is PaymentOrder {
return !!nextOrder && 'payment_type' in nextOrder && typeof nextOrder.payment_type === 'string' && nextOrder.payment_type.trim() !== ''
}
function normalizeOrderStatus(status: string | null | undefined): string {
return String(status || '').trim().toUpperCase()
}
function displayOrderStatus(status: string): OrderStatus {
return normalizeOrderStatus(status) as OrderStatus
}
function isSuccessStatus(status: string | null | undefined): boolean {
return SUCCESS_STATUSES.has(normalizeOrderStatus(status))
}
@@ -256,7 +275,7 @@ function restoreRecoverySnapshot(context: {
return restored
}
async function resolveOrderFromResumeToken(resumeToken: string): Promise<PaymentOrder | null> {
async function resolveOrderFromResumeToken(resumeToken: string): Promise<ResolvedOrder | null> {
try {
const result = await paymentAPI.resolveOrderPublicByResumeToken(resumeToken)
return result.data
@@ -265,7 +284,7 @@ async function resolveOrderFromResumeToken(resumeToken: string): Promise<Payment
}
}
async function resolveOrderFromOutTradeNo(outTradeNo: string): Promise<PaymentOrder | null> {
async function resolveOrderFromOutTradeNo(outTradeNo: string): Promise<ResolvedOrder | null> {
try {
const result = await paymentAPI.verifyOrder(outTradeNo)
return result.data
@@ -298,7 +317,7 @@ function clearRecoverySnapshotForTerminalStatus(status: string | null | undefine
}
}
function scheduleStatusRefresh(refreshOrder: (() => Promise<PaymentOrder | null>) | null): void {
function scheduleStatusRefresh(refreshOrder: (() => Promise<ResolvedOrder | null>) | null): void {
clearStatusRefreshTimer()
if (!refreshOrder || !isPending.value || refreshAttempts.value >= STATUS_REFRESH_MAX_ATTEMPTS) {
return
@@ -345,7 +364,7 @@ onMounted(async () => {
if (resolvedOrder) {
setResolvedOrder(resolvedOrder)
if (!orderId) {
orderId = resolvedOrder.id
orderId = hasOrderId(resolvedOrder) ? resolvedOrder.id : 0
}
} else if (routeOrderId > 0) {
resumeTokenLookupFailed = true
@@ -373,7 +392,7 @@ onMounted(async () => {
if (legacyOrder) {
setResolvedOrder(legacyOrder)
if (!orderId) {
orderId = legacyOrder.id
orderId = hasOrderId(legacyOrder) ? legacyOrder.id : 0
}
}
}
@@ -387,7 +406,7 @@ onMounted(async () => {
}
}
const refreshOrder = async (): Promise<PaymentOrder | null> => {
const refreshOrder = async (): Promise<ResolvedOrder | null> => {
if (resumeToken) {
const resolvedOrder = await resolveOrderFromResumeToken(resumeToken)
if (resolvedOrder) {
@@ -353,6 +353,37 @@ describe('PaymentResultView', () => {
expect(wrapper.text()).toContain('payment.result.success')
})
it('renders the minimal public out_trade_no verification result without payment_type', async () => {
routeState.query = {
out_trade_no: 'legacy-minimal',
trade_status: 'TRADE_SUCCESS',
}
verifyOrder.mockRejectedValue(new Error('auth required'))
verifyOrderPublic.mockResolvedValue({
data: {
out_trade_no: 'legacy-minimal',
status: 'PAID',
paid: true,
created_at: '2026-04-20T12:00:00Z',
expires_at: '2026-04-20T12:30:00Z',
},
})
const wrapper = mount(PaymentResultView, {
global: {
stubs: {
OrderStatusBadge: true,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('payment.result.success')
expect(wrapper.text()).toContain('legacy-minimal')
expect(wrapper.text()).not.toContain('payment.orders.paymentMethod')
})
it('prefers authenticated order verification before falling back to public lookup', async () => {
routeState.query = {
out_trade_no: 'auth-verify-123',