mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Fix refund pending finalization gaps
This commit is contained in:
@@ -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 ====================
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user