From cc858c3cc90f76f2415c78f8a25108379ec36ba6 Mon Sep 17 00:00:00 2001 From: erio Date: Thu, 9 Apr 2026 21:47:39 +0800 Subject: [PATCH] feat(admin): enhance refund workflow to match sub2apipay Admin order list: - Add REFUND_REQUESTED "approve refund" button with amount badge - Add REFUND_FAILED "retry refund" button - Add missing status filters (REFUND_REQUESTED, REFUND_FAILED) - Order detail dialog: show refund request info + audit logs Admin refund dialog: - Show refund request info card (violet) when user requested - Display user balance with insufficient balance warning - Add "no deduction" info when deduct_balance unchecked - Add force refund checkbox (shown when requireForce=true) - Add warning display prop - Pre-fill reason from user's refund request - Default deduct_balance to true chore: bump version to 0.1.108.141 --- backend/cmd/server/VERSION | 2 +- frontend/src/api/admin/payment.ts | 2 +- .../admin/payment/AdminOrderDetail.vue | 2 +- .../admin/payment/AdminOrderTable.vue | 4 +- .../admin/payment/AdminRefundDialog.vue | 113 ++++++++++++++++-- frontend/src/i18n/locales/en.ts | 14 +++ frontend/src/i18n/locales/zh.ts | 14 +++ frontend/src/types/payment.ts | 3 + .../views/admin/orders/AdminOrdersView.vue | 82 ++++++++++++- 9 files changed, 215 insertions(+), 21 deletions(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index e534f2aacd..9a65da79f8 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.108.140 +0.1.108.141 diff --git a/frontend/src/api/admin/payment.ts b/frontend/src/api/admin/payment.ts index a539e6d448..946c91b07e 100644 --- a/frontend/src/api/admin/payment.ts +++ b/frontend/src/api/admin/payment.ts @@ -102,7 +102,7 @@ export const adminPaymentAPI = { }, /** Process a refund */ - refundOrder(id: number, data: { amount: number; reason: string; deduct_balance?: boolean }) { + refundOrder(id: number, data: { amount: number; reason: string; deduct_balance?: boolean; force?: boolean }) { return apiClient.post(`/admin/payment/orders/${id}/refund`, data) }, diff --git a/frontend/src/components/admin/payment/AdminOrderDetail.vue b/frontend/src/components/admin/payment/AdminOrderDetail.vue index 5c2e5ae7f4..b10bc76b55 100644 --- a/frontend/src/components/admin/payment/AdminOrderDetail.vue +++ b/frontend/src/components/admin/payment/AdminOrderDetail.vue @@ -139,7 +139,7 @@ function statusBadgeClass(status: string): string { } function canRefund(order: PaymentOrder): boolean { - return ['COMPLETED', 'PARTIALLY_REFUNDED'].includes(order.status) + return ['COMPLETED', 'PARTIALLY_REFUNDED', 'REFUND_REQUESTED', 'REFUND_FAILED'].includes(order.status) } function formatDateTime(dateStr: string): string { diff --git a/frontend/src/components/admin/payment/AdminOrderTable.vue b/frontend/src/components/admin/payment/AdminOrderTable.vue index 14b1f93787..61ffbfa818 100644 --- a/frontend/src/components/admin/payment/AdminOrderTable.vue +++ b/frontend/src/components/admin/payment/AdminOrderTable.vue @@ -199,6 +199,8 @@ const statusFilterOptions = computed(() => [ { value: 'CANCELLED', label: t('payment.status.cancelled') }, { value: 'FAILED', label: t('payment.status.failed') }, { value: 'REFUNDED', label: t('payment.status.refunded') }, + { value: 'REFUND_REQUESTED', label: t('payment.status.refund_requested') }, + { value: 'REFUND_FAILED', label: t('payment.status.refund_failed') }, ]) const paymentTypeFilterOptions = computed(() => [ @@ -225,7 +227,7 @@ function statusBadgeClass(status: string): string { } function canRefund(order: PaymentOrder): boolean { - return ['COMPLETED', 'PARTIALLY_REFUNDED'].includes(order.status) + return ['COMPLETED', 'PARTIALLY_REFUNDED', 'REFUND_REQUESTED', 'REFUND_FAILED'].includes(order.status) } function formatDateTime(dateStr: string): string { diff --git a/frontend/src/components/admin/payment/AdminRefundDialog.vue b/frontend/src/components/admin/payment/AdminRefundDialog.vue index 0f78375cad..05e72befcf 100644 --- a/frontend/src/components/admin/payment/AdminRefundDialog.vue +++ b/frontend/src/components/admin/payment/AdminRefundDialog.vue @@ -6,6 +6,27 @@ @close="emit('cancel')" >
+ +
+
+ + + + {{ t('payment.admin.refundRequestInfo') }} +
+
+ {{ t('payment.admin.refundRequestedAt') }} + {{ formatDateTime(order.refund_requested_at) }} +
+
+ {{ t('payment.admin.refundRequestReason') }}: + {{ order.refund_request_reason }} +
+
+
@@ -22,6 +43,50 @@
+ +
+
+ + + {{ t('payment.admin.deductBalanceHint') }} +
+ + +
+
+
{{ t('payment.admin.userBalance') }}
+
${{ userBalance.toFixed(2) }}
+
+
+
{{ t('payment.admin.orderAmount') }}
+
${{ order?.pay_amount?.toFixed(2) }}
+
+
+ + +
+ {{ t('payment.admin.insufficientBalance') }} +
+ + +
+ {{ t('payment.admin.noDeduction') }} +
+
+
@@ -54,16 +119,24 @@ >
- -
+ +
+ {{ warning }} +
+ + +
-
@@ -76,7 +149,7 @@ - + + @@ -80,6 +96,38 @@

{{ t('payment.admin.paidAt') }}

{{ formatDateTime(selectedOrder.paid_at) }}

{{ t('payment.admin.refundAmount') }}

${{ selectedOrder.refund_amount.toFixed(2) }}

{{ t('payment.admin.refundReason') }}

{{ selectedOrder.refund_reason }}

+ +
+

{{ t('payment.admin.refundRequestInfo') }}

+
+
+

{{ t('payment.admin.refundRequestedAt') }}

+

{{ formatDateTime(selectedOrder.refund_requested_at) }}

+
+
+

{{ t('payment.admin.refundRequestedBy') }}

+

#{{ selectedOrder.refund_requested_by }}

+
+
+

{{ t('payment.admin.refundRequestReason') }}

+

{{ selectedOrder.refund_request_reason }}

+
+
+
+
+ +
+

{{ t('payment.admin.auditLogs') }}

+
+
+
+ {{ log.action }} + {{ formatDateTime(log.created_at) }} +
+
{{ log.detail }}
+
{{ t('payment.admin.operator') }}: {{ log.operator }}
+
+
@@ -104,6 +152,14 @@ import Select from '@/components/common/Select.vue' import Icon from '@/components/icons/Icon.vue' import AdminRefundDialog from '@/components/admin/payment/AdminRefundDialog.vue' +interface AuditLog { + id: number + action: string + detail: string | null + operator: string | null + created_at: string +} + const { t } = useI18n() const appStore = useAppStore() @@ -116,6 +172,7 @@ const selectedOrder = ref(null) const showDetailDialog = ref(false) const showRefundDialog = ref(false) const refundSubmitting = ref(false) +const orderAuditLogs = ref([]) let debounceTimer: ReturnType | null = null function debounceLoadOrders() { @@ -150,6 +207,8 @@ const statusFilterOptions = computed(() => [ { value: 'CANCELLED', label: t('payment.status.cancelled') }, { value: 'FAILED', label: t('payment.status.failed') }, { value: 'REFUNDED', label: t('payment.status.refunded') }, + { value: 'REFUND_REQUESTED', label: t('payment.status.refund_requested') }, + { value: 'REFUND_FAILED', label: t('payment.status.refund_failed') }, ]) const paymentTypeFilterOptions = computed(() => [ @@ -181,8 +240,21 @@ function statusBadgeClass(status: string): string { return m[status] || 'badge-secondary' } -function canRefund(order: PaymentOrder): boolean { return ['COMPLETED', 'PARTIALLY_REFUNDED'].includes(order.status) } -function showOrderDetail(order: PaymentOrder) { selectedOrder.value = order; showDetailDialog.value = true } +function canRefund(order: PaymentOrder): boolean { + return ['COMPLETED', 'PARTIALLY_REFUNDED', 'REFUND_REQUESTED', 'REFUND_FAILED'].includes(order.status) +} + +async function showOrderDetail(order: PaymentOrder) { + selectedOrder.value = order + orderAuditLogs.value = [] + showDetailDialog.value = true + try { + const res = await adminPaymentAPI.getOrder(order.id) + const data = res.data as Record + if (data.order) selectedOrder.value = data.order as PaymentOrder + orderAuditLogs.value = (data.auditLogs || data.audit_logs || []) as AuditLog[] + } catch (_err: unknown) { /* keep cached order data */ } +} async function handleCancelOrder(order: PaymentOrder) { try { await adminPaymentAPI.cancelOrder(order.id); appStore.showSuccess(t('payment.admin.orderCancelled')); loadOrders() } @@ -196,11 +268,11 @@ async function handleRetryOrder(order: PaymentOrder) { function openRefundDialog(order: PaymentOrder) { selectedOrder.value = order; showRefundDialog.value = true } -async function handleRefund(data: { amount: number; reason: string; deduct_balance: boolean }) { +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 }) + 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() } catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) } finally { refundSubmitting.value = false }