fix(admin): 订单金额币种符号读取 currency 字段

This commit is contained in:
wucm667
2026-06-25 16:23:45 +08:00
parent 00d68ff6df
commit 55242ffac1
14 changed files with 506 additions and 42 deletions
@@ -2,6 +2,7 @@ package admin
import (
"strconv"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
@@ -115,24 +116,105 @@ func (h *PaymentHandler) RetryFulfillment(c *gin.Context) {
response.Success(c, gin.H{"message": "fulfillment retried"})
}
func sanitizeAdminPaymentOrdersForResponse(orders []*dbent.PaymentOrder) []*dbent.PaymentOrder {
if len(orders) == 0 {
return orders
}
out := make([]*dbent.PaymentOrder, 0, len(orders))
type AdminPaymentOrderResult struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
UserEmail string `json:"user_email,omitempty"`
UserName string `json:"user_name,omitempty"`
UserNotes *string `json:"user_notes,omitempty"`
Amount float64 `json:"amount"`
PayAmount float64 `json:"pay_amount"`
FeeRate float64 `json:"fee_rate"`
Currency string `json:"currency"`
RechargeCode string `json:"recharge_code,omitempty"`
OutTradeNo string `json:"out_trade_no"`
PaymentType string `json:"payment_type"`
PaymentTradeNo string `json:"payment_trade_no,omitempty"`
PayURL *string `json:"pay_url,omitempty"`
QRCode *string `json:"qr_code,omitempty"`
QRCodeImg *string `json:"qr_code_img,omitempty"`
OrderType string `json:"order_type"`
PlanID *int64 `json:"plan_id,omitempty"`
SubscriptionGroupID *int64 `json:"subscription_group_id,omitempty"`
SubscriptionDays *int `json:"subscription_days,omitempty"`
ProviderInstanceID *string `json:"provider_instance_id,omitempty"`
ProviderKey *string `json:"provider_key,omitempty"`
Status string `json:"status"`
RefundAmount float64 `json:"refund_amount"`
RefundReason *string `json:"refund_reason,omitempty"`
RefundAt *time.Time `json:"refund_at,omitempty"`
ForceRefund bool `json:"force_refund,omitempty"`
RefundRequestedAt *time.Time `json:"refund_requested_at,omitempty"`
RefundRequestReason *string `json:"refund_request_reason,omitempty"`
RefundRequestedBy *string `json:"refund_requested_by,omitempty"`
ExpiresAt time.Time `json:"expires_at"`
PaidAt *time.Time `json:"paid_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
FailedAt *time.Time `json:"failed_at,omitempty"`
FailedReason *string `json:"failed_reason,omitempty"`
ClientIP string `json:"client_ip,omitempty"`
SrcHost string `json:"src_host,omitempty"`
SrcURL *string `json:"src_url,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func sanitizeAdminPaymentOrdersForResponse(orders []*dbent.PaymentOrder) []*AdminPaymentOrderResult {
out := make([]*AdminPaymentOrderResult, 0, len(orders))
for _, order := range orders {
out = append(out, sanitizeAdminPaymentOrderForResponse(order))
if item := sanitizeAdminPaymentOrderForResponse(order); item != nil {
out = append(out, item)
}
}
return out
}
func sanitizeAdminPaymentOrderForResponse(order *dbent.PaymentOrder) *dbent.PaymentOrder {
func sanitizeAdminPaymentOrderForResponse(order *dbent.PaymentOrder) *AdminPaymentOrderResult {
if order == nil {
return nil
}
cloned := *order
cloned.ProviderSnapshot = nil
return &cloned
return &AdminPaymentOrderResult{
ID: order.ID,
UserID: order.UserID,
UserEmail: order.UserEmail,
UserName: order.UserName,
UserNotes: order.UserNotes,
Amount: order.Amount,
PayAmount: order.PayAmount,
FeeRate: order.FeeRate,
Currency: service.PaymentOrderCurrency(order),
RechargeCode: order.RechargeCode,
OutTradeNo: order.OutTradeNo,
PaymentType: order.PaymentType,
PaymentTradeNo: order.PaymentTradeNo,
PayURL: order.PayURL,
QRCode: order.QrCode,
QRCodeImg: order.QrCodeImg,
OrderType: order.OrderType,
PlanID: order.PlanID,
SubscriptionGroupID: order.SubscriptionGroupID,
SubscriptionDays: order.SubscriptionDays,
ProviderInstanceID: order.ProviderInstanceID,
ProviderKey: order.ProviderKey,
Status: order.Status,
RefundAmount: order.RefundAmount,
RefundReason: order.RefundReason,
RefundAt: order.RefundAt,
ForceRefund: order.ForceRefund,
RefundRequestedAt: order.RefundRequestedAt,
RefundRequestReason: order.RefundRequestReason,
RefundRequestedBy: order.RefundRequestedBy,
ExpiresAt: order.ExpiresAt,
PaidAt: order.PaidAt,
CompletedAt: order.CompletedAt,
FailedAt: order.FailedAt,
FailedReason: order.FailedReason,
ClientIP: order.ClientIP,
SrcHost: order.SrcHost,
SrcURL: order.SrcURL,
CreatedAt: order.CreatedAt,
UpdatedAt: order.UpdatedAt,
}
}
// AdminProcessRefundRequest is the request body for admin refund processing.
@@ -0,0 +1,48 @@
package admin
import (
"encoding/json"
"strings"
"testing"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
)
func TestSanitizeAdminPaymentOrderForResponseAddsCurrency(t *testing.T) {
now := time.Now()
order := &dbent.PaymentOrder{
ID: 1,
UserID: 2,
Amount: 100,
PayAmount: 108,
FeeRate: 8,
OutTradeNo: "sub2_202606250001",
PaymentType: "stripe",
OrderType: "subscription",
Status: "COMPLETED",
ExpiresAt: now,
CreatedAt: now,
UpdatedAt: now,
ProviderSnapshot: map[string]any{
"schema_version": 2,
"currency": "USD",
},
}
got := sanitizeAdminPaymentOrderForResponse(order)
if got == nil {
t.Fatal("expected sanitized order")
}
if got.Currency != "USD" {
t.Fatalf("expected currency USD, got %q", got.Currency)
}
body, err := json.Marshal(got)
if err != nil {
t.Fatalf("marshal sanitized order: %v", err)
}
if strings.Contains(string(body), "provider_snapshot") {
t.Fatalf("expected provider_snapshot to be omitted, got %s", string(body))
}
}
@@ -19,19 +19,19 @@
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.baseAmount') }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">¥{{ baseAmount.toFixed(2) }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ paymentAmountSymbol }}{{ baseAmount.toFixed(2) }}</p>
</div>
<div v-if="order.fee_rate > 0">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.fee') }} ({{ order.fee_rate }}%)</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">¥{{ feeAmount.toFixed(2) }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ paymentAmountSymbol }}{{ feeAmount.toFixed(2) }}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">¥{{ order.pay_amount.toFixed(2) }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ paymentAmountSymbol }}{{ order.pay_amount.toFixed(2) }}</p>
</div>
<div v-if="order.amount !== order.pay_amount">
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.creditedAmount') }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ order.order_type === 'balance' ? '$' : '¥' }}{{ order.amount.toFixed(2) }}</p>
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ creditedAmountSymbol }}{{ order.amount.toFixed(2) }}</p>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</p>
@@ -77,7 +77,7 @@
<div class="grid grid-cols-2 gap-2 text-sm">
<div>
<span class="text-red-600 dark:text-red-400">{{ t('payment.admin.refundAmount') }}:</span>
<span class="ml-1 font-medium text-red-700 dark:text-red-300">{{ order.order_type === 'balance' ? '$' : '¥' }}{{ order.refund_amount.toFixed(2) }}</span>
<span class="ml-1 font-medium text-red-700 dark:text-red-300">{{ creditedAmountSymbol }}{{ order.refund_amount.toFixed(2) }}</span>
</div>
<div v-if="order.refund_reason" class="col-span-2">
<span class="text-red-600 dark:text-red-400">{{ t('payment.admin.refundReason') }}:</span>
@@ -119,6 +119,7 @@ import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import type { PaymentOrder } from '@/types/payment'
import { statusBadgeClass, canRefund as canRefundStatus, formatOrderDateTime } from '@/components/payment/orderUtils'
import { currencySymbol } from '@/components/payment/currency'
const { t } = useI18n()
@@ -127,6 +128,10 @@ const props = defineProps<{
order: PaymentOrder | null
}>()
const creditedAmountSymbol = currencySymbol('USD')
const paymentAmountSymbol = computed(() => currencySymbol(props.order?.currency))
/** 充值金额 (base amount before fee) = pay_amount - fee = pay_amount / (1 + fee_rate/100) */
const baseAmount = computed(() => {
if (!props.order) return 0
@@ -53,12 +53,12 @@
<template #cell-pay_amount="{ value, row }">
<div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">¥{{ value.toFixed(2) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ paymentAmountSymbol(row) }}{{ value.toFixed(2) }}</span>
<span v-if="row.fee_rate > 0" class="ml-1 text-xs text-gray-400" :title="t('payment.orders.fee') + ': ' + row.fee_rate + '%'">
({{ row.fee_rate }}%)
</span>
<div v-if="row.amount !== row.pay_amount" class="text-xs text-gray-500">
{{ t('payment.orders.creditedAmount') }}: {{ row.order_type === 'balance' ? '$' : '¥' }}{{ row.amount.toFixed(2) }}
{{ t('payment.orders.creditedAmount') }}: {{ creditedAmountSymbol }}{{ row.amount.toFixed(2) }}
</div>
</div>
</template>
@@ -143,6 +143,7 @@ import Pagination from '@/components/common/Pagination.vue'
import Select from '@/components/common/Select.vue'
import Icon from '@/components/icons/Icon.vue'
import { statusBadgeClass, canRefund, formatOrderDateTime } from '@/components/payment/orderUtils'
import { currencySymbol } from '@/components/payment/currency'
const { t } = useI18n()
@@ -167,6 +168,11 @@ const emit = defineEmits<{
const searchQuery = ref('')
const filters = reactive({ status: '', payment_type: '', order_type: '' })
const creditedAmountSymbol = currencySymbol('USD')
function paymentAmountSymbol(order: PaymentOrder): string {
return currencySymbol(order.currency)
}
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function handleSearch() {
@@ -35,15 +35,15 @@
</div>
<div class="mt-1 flex justify-between text-sm">
<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) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ creditedAmountSymbol }}{{ order?.amount?.toFixed(2) }}</span>
</div>
<div class="mt-1 flex justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">¥{{ order?.pay_amount?.toFixed(2) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ paymentAmountSymbol }}{{ order?.pay_amount?.toFixed(2) }}</span>
</div>
<div v-if="actuallyRefunded > 0" class="mt-1 flex justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.alreadyRefunded') }}</span>
<span class="font-medium text-red-600 dark:text-red-400">{{ order?.order_type === 'balance' ? '$' : '¥' }}{{ actuallyRefunded.toFixed(2) }}</span>
<span class="font-medium text-red-600 dark:text-red-400">{{ creditedAmountSymbol }}{{ actuallyRefunded.toFixed(2) }}</span>
</div>
</div>
@@ -66,11 +66,11 @@
<div v-if="form.deduct_balance && userBalance != null" class="mt-3 grid grid-cols-2 gap-3">
<div class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-dark-700">
<div class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.userBalance') }}</div>
<div class="mt-1 font-semibold text-gray-900 dark:text-white">${{ userBalance.toFixed(2) }}</div>
<div class="mt-1 font-semibold text-gray-900 dark:text-white">{{ creditedAmountSymbol }}{{ userBalance.toFixed(2) }}</div>
</div>
<div class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-dark-700">
<div class="text-gray-500 dark:text-gray-400">{{ t('payment.admin.orderAmount') }}</div>
<div class="mt-1 font-semibold text-gray-900 dark:text-white">{{ order?.order_type === 'balance' ? '$' : '¥' }}{{ order?.amount?.toFixed(2) }}</div>
<div class="mt-1 font-semibold text-gray-900 dark:text-white">{{ creditedAmountSymbol }}{{ order?.amount?.toFixed(2) }}</div>
</div>
</div>
@@ -95,7 +95,7 @@
<div>
<label class="input-label">{{ t('payment.admin.refundAmount') }}</label>
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">{{ order?.order_type === 'balance' ? '$' : '¥' }}</span>
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">{{ creditedAmountSymbol }}</span>
<input
v-model.number="form.amount"
type="number"
@@ -107,7 +107,7 @@
/>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('payment.admin.maxRefundable') }}: {{ order?.order_type === 'balance' ? '$' : '¥' }}{{ maxRefundable.toFixed(2) }}
{{ t('payment.admin.maxRefundable') }}: {{ creditedAmountSymbol }}{{ maxRefundable.toFixed(2) }}
</p>
</div>
@@ -169,6 +169,7 @@ import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import type { PaymentOrder } from '@/types/payment'
import { formatOrderDateTime } from '@/components/payment/orderUtils'
import { currencySymbol } from '@/components/payment/currency'
const { t } = useI18n()
@@ -186,6 +187,10 @@ const emit = defineEmits<{
(e: 'cancel'): void
}>()
const creditedAmountSymbol = currencySymbol('USD')
const paymentAmountSymbol = computed(() => currencySymbol(props.order?.currency))
const form = reactive({
amount: 0,
reason: '',
@@ -0,0 +1,153 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import type { PaymentOrder } from '@/types/payment'
import AdminOrderDetail from '../AdminOrderDetail.vue'
import AdminOrderTable from '../AdminOrderTable.vue'
import AdminRefundDialog from '../AdminRefundDialog.vue'
import OrderTable from '@/components/payment/OrderTable.vue'
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
}),
}
})
const BaseDialogStub = {
props: ['show'],
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
}
const DataTableStub = {
props: ['data'],
template: `
<div>
<div v-for="row in data" :key="row.id">
<slot name="cell-pay_amount" :value="row.pay_amount" :row="row" />
</div>
</div>
`,
}
function orderFactory(overrides: Partial<PaymentOrder> = {}): PaymentOrder {
return {
id: 1,
user_id: 10,
amount: 100,
pay_amount: 108,
currency: 'USD',
fee_rate: 8,
payment_type: 'stripe',
out_trade_no: 'sub2_202606250001',
status: 'COMPLETED',
order_type: 'subscription',
created_at: '2026-06-25T10:00:00Z',
expires_at: '2026-06-25T10:30:00Z',
refund_amount: 25,
...overrides,
}
}
describe('admin order currency display', () => {
it('uses order currency for paid/base/fee amounts and USD for credited/refund amounts', () => {
const wrapper = mount(AdminOrderDetail, {
props: {
show: true,
order: orderFactory({ currency: 'CNY' }),
},
global: {
stubs: {
BaseDialog: BaseDialogStub,
},
},
})
const text = wrapper.text()
expect(text).toContain('¥100.00')
expect(text).toContain('¥8.00')
expect(text).toContain('¥108.00')
expect(text).toContain('$100.00')
expect(text).toContain('$25.00')
})
it('uses order currency for pay_amount and USD for refundable balance amounts', () => {
const wrapper = mount(AdminRefundDialog, {
props: {
show: true,
order: orderFactory({
currency: 'USD',
status: 'PARTIALLY_REFUNDED',
refund_amount: 20,
}),
userBalance: 200,
},
global: {
stubs: {
BaseDialog: BaseDialogStub,
},
},
})
const text = wrapper.text()
expect(text).toContain('$108.00')
expect(text).toContain('$100.00')
expect(text).toContain('$20.00')
expect(text).toContain('$80.00')
expect(text).toContain('$200.00')
})
it('renders payment currency consistently in the shared order table', () => {
const wrapper = mount(OrderTable, {
props: {
orders: [
orderFactory({ id: 1, currency: 'USD', amount: 100, pay_amount: 108 }),
orderFactory({ id: 2, currency: 'CNY', amount: 100, pay_amount: 108 }),
],
loading: false,
showUser: true,
},
global: {
stubs: {
DataTable: DataTableStub,
OrderStatusBadge: true,
},
},
})
const text = wrapper.text()
expect(text).toContain('$108.00')
expect(text).toContain('¥108.00')
expect(text).toContain('$100.00')
})
it('renders payment currency consistently in the admin order table', () => {
const wrapper = mount(AdminOrderTable, {
props: {
orders: [
orderFactory({ id: 1, currency: 'USD', amount: 100, pay_amount: 108 }),
orderFactory({ id: 2, currency: 'CNY', amount: 100, pay_amount: 108 }),
],
loading: false,
page: 1,
pageSize: 20,
total: 2,
},
global: {
stubs: {
DataTable: DataTableStub,
Icon: true,
Pagination: true,
Select: true,
},
},
})
const text = wrapper.text()
expect(text).toContain('$108.00')
expect(text).toContain('¥108.00')
expect(text).toContain('$100.00')
})
})
@@ -14,12 +14,12 @@
</template>
<template #cell-pay_amount="{ value, row }">
<div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">¥{{ value.toFixed(2) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ paymentAmountSymbol(row) }}{{ value.toFixed(2) }}</span>
<span v-if="row.fee_rate > 0" class="ml-1 text-xs text-gray-400" :title="t('payment.orders.fee') + ': ' + row.fee_rate + '%'">
({{ t('payment.orders.fee') }} {{ row.fee_rate }}%)
</span>
<div v-if="row.amount !== row.pay_amount" class="text-xs text-gray-500">
{{ t('payment.orders.creditedAmount') }}: {{ row.order_type === 'balance' ? '$' : '¥' }}{{ row.amount.toFixed(2) }}
{{ t('payment.orders.creditedAmount') }}: {{ creditedAmountSymbol }}{{ row.amount.toFixed(2) }}
</div>
</div>
</template>
@@ -45,6 +45,7 @@ import type { PaymentOrder } from '@/types/payment'
import type { Column } from '@/components/common/types'
import DataTable from '@/components/common/DataTable.vue'
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
import { currencySymbol } from '@/components/payment/currency'
const { t } = useI18n()
@@ -56,6 +57,12 @@ const props = defineProps<{
function formatDate(dateStr: string) { return new Date(dateStr).toLocaleString() }
const creditedAmountSymbol = currencySymbol('USD')
function paymentAmountSymbol(order: PaymentOrder): string {
return currencySymbol(order.currency)
}
const columns = computed((): Column[] => {
const cols: Column[] = [
{ key: 'id', label: t('payment.orders.orderId') },
@@ -45,11 +45,11 @@
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ paidOrder.order_type === 'balance' ? '$' : '¥' }}{{ paidOrder.amount.toFixed(2) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ creditedAmountSymbol }}{{ paidOrder.amount.toFixed(2) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">¥{{ paidOrder.pay_amount.toFixed(2) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ paymentAmountSymbol(paidOrder) }}{{ paidOrder.pay_amount.toFixed(2) }}</span>
</div>
</div>
</div>
@@ -81,6 +81,7 @@ import { paymentAPI } from '@/api/payment'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
import type { PaymentOrder } from '@/types/payment'
import { currencySymbol } from '@/components/payment/currency'
import QRCode from 'qrcode'
import alipayIcon from '@/assets/icons/alipay.svg'
import wxpayIcon from '@/assets/icons/wxpay.svg'
@@ -111,6 +112,7 @@ const expired = ref(false)
const cancelling = ref(false)
const success = ref(false)
const paidOrder = ref<PaymentOrder | null>(null)
const creditedAmountSymbol = currencySymbol('USD')
let pollTimer: ReturnType<typeof setInterval> | null = null
let countdownTimer: ReturnType<typeof setInterval> | null = null
@@ -137,6 +139,10 @@ const scanHint = computed(() => {
return ''
})
function paymentAmountSymbol(order: PaymentOrder): string {
return currencySymbol(order.currency)
}
const countdownDisplay = computed(() => {
const m = Math.floor(remainingSeconds.value / 60)
const s = remainingSeconds.value % 60
@@ -22,11 +22,11 @@
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ paidOrder.order_type === 'balance' ? '$' + paidOrder.amount.toFixed(2) : formatGatewayAmount(paidOrder.amount) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ creditedAmountSymbol }}{{ paidOrder.amount.toFixed(2) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ formatGatewayAmount(paidOrder.pay_amount) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ formatGatewayAmount(paidOrder.pay_amount, paidOrder.currency) }}</span>
</div>
</div>
</div>
@@ -129,7 +129,7 @@ import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
import { formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import { currencySymbol, formatPaymentAmount, normalizePaymentCurrency } from '@/components/payment/currency'
import type { PaymentOrder } from '@/types/payment'
import Icon from '@/components/icons/Icon.vue'
import QRCode from 'qrcode'
@@ -161,6 +161,7 @@ const remainingSeconds = ref(0)
const cancelling = ref(false)
const paidOrder = ref<PaymentOrder | null>(null)
const paymentCurrency = computed(() => normalizePaymentCurrency(props.currency))
const creditedAmountSymbol = currencySymbol('USD')
const localeCode = computed(() => {
const raw = i18n.locale as unknown
if (typeof raw === 'string') return raw
@@ -214,8 +215,8 @@ const countdownDisplay = computed(() => {
return m.toString().padStart(2, '0') + ':' + s.toString().padStart(2, '0')
})
function formatGatewayAmount(value: number): string {
return formatPaymentAmount(value, paymentCurrency.value, localeCode.value)
function formatGatewayAmount(value: number, currency?: string | null): string {
return formatPaymentAmount(value, currency || paymentCurrency.value, localeCode.value)
}
function isSuccessStatus(status: string | null | undefined): boolean {
@@ -23,11 +23,11 @@
</div>
<div v-if="amount > 0" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ orderType === 'balance' ? '$' : '¥' }}{{ amount.toFixed(2) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ creditedAmountSymbol }}{{ amount.toFixed(2) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">¥{{ payAmount.toFixed(2) }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ paymentAmountSymbol }}{{ payAmount.toFixed(2) }}</span>
</div>
</div>
</div>
@@ -40,7 +40,7 @@
<div class="card overflow-hidden">
<div class="bg-gradient-to-br from-[#635bff] to-[#4f46e5] px-6 py-5 text-center">
<p class="text-sm font-medium text-indigo-200">{{ t('payment.actualPay') }}</p>
<p class="mt-1 text-3xl font-bold text-white">¥{{ payAmount.toFixed(2) }}</p>
<p class="mt-1 text-3xl font-bold text-white">{{ paymentAmountSymbol }}{{ payAmount.toFixed(2) }}</p>
</div>
</div>
<!-- Stripe Payment Element -->
@@ -64,13 +64,14 @@
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { computed, ref, onMounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { extractI18nErrorMessage } from '@/utils/apiError'
import { paymentAPI } from '@/api/payment'
import { useAppStore } from '@/stores'
import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
import { currencySymbol } from '@/components/payment/currency'
import type { Stripe, StripeElements } from '@stripe/stripe-js'
import Icon from '@/components/icons/Icon.vue'
@@ -84,6 +85,7 @@ const props = defineProps<{
orderType?: 'balance' | 'subscription'
publishableKey: string
payAmount: number
currency?: string
}>()
const emit = defineEmits<{ success: []; done: []; back: []; redirect: [orderId: number, payUrl: string] }>()
@@ -101,6 +103,8 @@ const cancelling = ref(false)
const success = ref(false)
const ready = ref(false)
const selectedType = ref('')
const creditedAmountSymbol = currencySymbol('USD')
const paymentAmountSymbol = computed(() => currencySymbol(props.currency))
let stripeInstance: Stripe | null = null
let elementsInstance: StripeElements | null = null
@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import PaymentQRDialog from '../PaymentQRDialog.vue'
const pollOrderStatus = vi.hoisted(() => vi.fn())
const cancelOrder = vi.hoisted(() => vi.fn())
const verifyOrder = vi.hoisted(() => vi.fn())
const showError = vi.hoisted(() => vi.fn())
const toCanvas = vi.hoisted(() => vi.fn())
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
}),
}
})
vi.mock('@/stores/payment', () => ({
usePaymentStore: () => ({
pollOrderStatus,
}),
}))
vi.mock('@/stores', () => ({
useAppStore: () => ({
showError,
}),
}))
vi.mock('@/api/payment', () => ({
paymentAPI: {
cancelOrder,
verifyOrder,
},
}))
vi.mock('qrcode', () => ({
default: {
toCanvas,
},
}))
const paidOrder = {
id: 42,
user_id: 9,
amount: 100,
pay_amount: 108,
currency: 'CNY',
fee_rate: 8,
payment_type: 'alipay',
out_trade_no: 'sub2_202606250001',
status: 'COMPLETED',
order_type: 'subscription',
created_at: '2026-06-25T10:00:00Z',
expires_at: '2099-01-01T10:30:00Z',
refund_amount: 0,
}
describe('PaymentQRDialog currency display', () => {
beforeEach(() => {
vi.useFakeTimers()
pollOrderStatus.mockReset().mockResolvedValue(paidOrder)
cancelOrder.mockReset()
verifyOrder.mockReset()
showError.mockReset()
toCanvas.mockReset().mockResolvedValue(undefined)
})
afterEach(() => {
vi.useRealTimers()
})
it('uses order currency for pay_amount and USD for credited amount', async () => {
const wrapper = mount(PaymentQRDialog, {
props: {
show: false,
orderId: 42,
qrCode: '',
expiresAt: '2099-01-01T10:30:00Z',
paymentType: 'alipay',
},
global: {
stubs: {
BaseDialog: {
props: ['show'],
template: '<div v-if="show"><slot /><slot name="footer" /></div>',
},
Icon: true,
},
},
})
await wrapper.setProps({ show: true })
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
await flushPromises()
expect(pollOrderStatus).toHaveBeenCalledWith(42)
expect(wrapper.text()).toContain('$100.00')
expect(wrapper.text()).toContain('¥108.00')
})
})
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { formatPaymentAmount } from '../currency'
import { currencySymbol, formatPaymentAmount } from '../currency'
describe('formatPaymentAmount', () => {
it('uses the currency default fraction digits', () => {
@@ -8,3 +8,13 @@ describe('formatPaymentAmount', () => {
expect(formatPaymentAmount(100, 'HKD', 'en-US')).toContain('.00')
})
})
describe('currencySymbol', () => {
it('maps common payment currencies and falls back safely', () => {
expect(currencySymbol('USD')).toBe('$')
expect(currencySymbol('cny')).toBe('¥')
expect(currencySymbol('EUR')).toBe('€')
expect(currencySymbol('')).toBe('¥')
expect(currencySymbol('XYZ')).toBe('XYZ')
})
})
@@ -1,10 +1,36 @@
export const DEFAULT_PAYMENT_CURRENCY = 'CNY'
const PAYMENT_CURRENCY_SYMBOLS: Record<string, string> = {
USD: '$',
CNY: '¥',
RMB: '¥',
EUR: '€',
GBP: '£',
JPY: '¥',
HKD: 'HK$',
TWD: 'NT$',
KRW: '₩',
AUD: 'A$',
CAD: 'C$',
SGD: 'S$',
NZD: 'NZ$',
MOP: 'MOP$',
MYR: 'RM',
THB: '฿',
PHP: '₱',
INR: '₹',
}
export function normalizePaymentCurrency(currency?: string | null): string {
const normalized = String(currency || '').trim().toUpperCase()
return /^[A-Z]{3}$/.test(normalized) ? normalized : DEFAULT_PAYMENT_CURRENCY
}
export function currencySymbol(currency?: string | null): string {
const normalized = normalizePaymentCurrency(currency)
return PAYMENT_CURRENCY_SYMBOLS[normalized] || normalized
}
function paymentCurrencyFractionDigits(currency: string): number {
try {
return new Intl.NumberFormat(undefined, {
@@ -35,7 +35,7 @@
{{ t('payment.admin.retry') }}
</button>
<template v-if="row.status === 'REFUND_REQUESTED'">
<span v-if="row.refund_amount" class="rounded-full bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">{{ row.order_type === 'balance' ? '$' : '¥' }}{{ row.refund_amount.toFixed(2) }}</span>
<span v-if="row.refund_amount" class="rounded-full bg-purple-100 px-1.5 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">{{ creditedAmountSymbol }}{{ row.refund_amount.toFixed(2) }}</span>
<button @click="openRefundDialog(row)" class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-purple-600 hover:bg-purple-50 dark:text-purple-400 dark:hover:bg-purple-900/20">
<Icon name="check" size="sm" />
{{ t('payment.admin.approveRefund') }}
@@ -62,14 +62,14 @@
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderId') }}</p><p class="font-mono text-sm font-medium text-gray-900 dark:text-white">#{{ selectedOrder.id }}</p></div>
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.orderNo') }}</p><p class="text-sm font-medium text-gray-900 dark:text-white">{{ selectedOrder.out_trade_no }}</p></div>
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.status') }}</p><OrderStatusBadge :status="selectedOrder.status" /></div>
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</p><p class="text-sm font-medium text-gray-900 dark:text-white">{{ selectedOrder.order_type === 'balance' ? '$' : '¥' }}{{ selectedOrder.amount.toFixed(2) }}</p></div>
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</p><p class="text-sm font-medium text-gray-900 dark:text-white">¥{{ selectedOrder.pay_amount.toFixed(2) }}</p></div>
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</p><p class="text-sm font-medium text-gray-900 dark:text-white">{{ creditedAmountSymbol }}{{ selectedOrder.amount.toFixed(2) }}</p></div>
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</p><p class="text-sm font-medium text-gray-900 dark:text-white">{{ paymentAmountSymbol(selectedOrder) }}{{ selectedOrder.pay_amount.toFixed(2) }}</p></div>
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ t('payment.methods.' + selectedOrder.payment_type, selectedOrder.payment_type) }}</p></div>
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.feeRate') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ selectedOrder.fee_rate }}%</p></div>
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.orders.createdAt') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(selectedOrder.created_at) }}</p></div>
<div><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.expiresAt') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(selectedOrder.expires_at) }}</p></div>
<div v-if="selectedOrder.paid_at"><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.paidAt') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ formatDateTime(selectedOrder.paid_at) }}</p></div>
<div v-if="selectedOrder.refund_amount"><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundAmount') }}</p><p class="text-sm font-medium text-red-600 dark:text-red-400">{{ selectedOrder.order_type === 'balance' ? '$' : '¥' }}{{ selectedOrder.refund_amount.toFixed(2) }}</p></div>
<div v-if="selectedOrder.refund_amount"><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundAmount') }}</p><p class="text-sm font-medium text-red-600 dark:text-red-400">{{ creditedAmountSymbol }}{{ selectedOrder.refund_amount.toFixed(2) }}</p></div>
<div v-if="selectedOrder.refund_reason" class="col-span-2"><p class="text-xs text-gray-500 dark:text-gray-400">{{ t('payment.admin.refundReason') }}</p><p class="text-sm text-gray-700 dark:text-gray-300">{{ selectedOrder.refund_reason }}</p></div>
<!-- Refund request info -->
<div v-if="selectedOrder.refund_requested_at" class="col-span-2 border-t border-gray-200 pt-3 dark:border-dark-600">
@@ -127,6 +127,7 @@ import Icon from '@/components/icons/Icon.vue'
import AdminRefundDialog from '@/components/admin/payment/AdminRefundDialog.vue'
import OrderStatusBadge from '@/components/payment/OrderStatusBadge.vue'
import OrderTable from '@/components/payment/OrderTable.vue'
import { currencySymbol } from '@/components/payment/currency'
interface AuditLog {
id: number
@@ -149,6 +150,11 @@ const showDetailDialog = ref(false)
const showRefundDialog = ref(false)
const refundSubmitting = ref(false)
const orderAuditLogs = ref<AuditLog[]>([])
const creditedAmountSymbol = currencySymbol('USD')
function paymentAmountSymbol(order: PaymentOrder | null | undefined): string {
return currencySymbol(order?.currency)
}
let debounceTimer: ReturnType<typeof setTimeout> | null = null
function debounceLoadOrders() {