diff --git a/backend/internal/handler/admin/payment_handler.go b/backend/internal/handler/admin/payment_handler.go index 84359cd93b..d8e211e68b 100644 --- a/backend/internal/handler/admin/payment_handler.go +++ b/backend/internal/handler/admin/payment_handler.go @@ -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. diff --git a/backend/internal/handler/admin/payment_handler_test.go b/backend/internal/handler/admin/payment_handler_test.go new file mode 100644 index 0000000000..42974b907c --- /dev/null +++ b/backend/internal/handler/admin/payment_handler_test.go @@ -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)) + } +} diff --git a/frontend/src/components/admin/payment/AdminOrderDetail.vue b/frontend/src/components/admin/payment/AdminOrderDetail.vue index e278a5c6d5..28bd614ac8 100644 --- a/frontend/src/components/admin/payment/AdminOrderDetail.vue +++ b/frontend/src/components/admin/payment/AdminOrderDetail.vue @@ -19,19 +19,19 @@

{{ t('payment.orders.baseAmount') }}

-

¥{{ baseAmount.toFixed(2) }}

+

{{ paymentAmountSymbol }}{{ baseAmount.toFixed(2) }}

{{ t('payment.orders.fee') }} ({{ order.fee_rate }}%)

-

¥{{ feeAmount.toFixed(2) }}

+

{{ paymentAmountSymbol }}{{ feeAmount.toFixed(2) }}

{{ t('payment.orders.payAmount') }}

-

¥{{ order.pay_amount.toFixed(2) }}

+

{{ paymentAmountSymbol }}{{ order.pay_amount.toFixed(2) }}

{{ t('payment.orders.creditedAmount') }}

-

{{ order.order_type === 'balance' ? '$' : '¥' }}{{ order.amount.toFixed(2) }}

+

{{ creditedAmountSymbol }}{{ order.amount.toFixed(2) }}

{{ t('payment.orders.paymentMethod') }}

@@ -77,7 +77,7 @@
{{ t('payment.admin.refundAmount') }}: - {{ order.order_type === 'balance' ? '$' : '¥' }}{{ order.refund_amount.toFixed(2) }} + {{ creditedAmountSymbol }}{{ order.refund_amount.toFixed(2) }}
{{ t('payment.admin.refundReason') }}: @@ -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 diff --git a/frontend/src/components/admin/payment/AdminOrderTable.vue b/frontend/src/components/admin/payment/AdminOrderTable.vue index 677103bb32..dda0ec27b0 100644 --- a/frontend/src/components/admin/payment/AdminOrderTable.vue +++ b/frontend/src/components/admin/payment/AdminOrderTable.vue @@ -53,12 +53,12 @@ @@ -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 | null = null function handleSearch() { diff --git a/frontend/src/components/admin/payment/AdminRefundDialog.vue b/frontend/src/components/admin/payment/AdminRefundDialog.vue index c29ec919e2..f5f5412d54 100644 --- a/frontend/src/components/admin/payment/AdminRefundDialog.vue +++ b/frontend/src/components/admin/payment/AdminRefundDialog.vue @@ -35,15 +35,15 @@
{{ t('payment.orders.creditedAmount') }} - {{ order?.order_type === 'balance' ? '$' : '¥' }}{{ order?.amount?.toFixed(2) }} + {{ creditedAmountSymbol }}{{ order?.amount?.toFixed(2) }}
{{ t('payment.orders.payAmount') }} - ¥{{ order?.pay_amount?.toFixed(2) }} + {{ paymentAmountSymbol }}{{ order?.pay_amount?.toFixed(2) }}
{{ t('payment.admin.alreadyRefunded') }} - {{ order?.order_type === 'balance' ? '$' : '¥' }}{{ actuallyRefunded.toFixed(2) }} + {{ creditedAmountSymbol }}{{ actuallyRefunded.toFixed(2) }}
@@ -66,11 +66,11 @@
{{ t('payment.admin.userBalance') }}
-
${{ userBalance.toFixed(2) }}
+
{{ creditedAmountSymbol }}{{ userBalance.toFixed(2) }}
{{ t('payment.admin.orderAmount') }}
-
{{ order?.order_type === 'balance' ? '$' : '¥' }}{{ order?.amount?.toFixed(2) }}
+
{{ creditedAmountSymbol }}{{ order?.amount?.toFixed(2) }}
@@ -95,7 +95,7 @@
- {{ order?.order_type === 'balance' ? '$' : '¥' }} + {{ creditedAmountSymbol }}

- {{ t('payment.admin.maxRefundable') }}: {{ order?.order_type === 'balance' ? '$' : '¥' }}{{ maxRefundable.toFixed(2) }} + {{ t('payment.admin.maxRefundable') }}: {{ creditedAmountSymbol }}{{ maxRefundable.toFixed(2) }}

@@ -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: '', diff --git a/frontend/src/components/admin/payment/__tests__/orderCurrencyDisplay.spec.ts b/frontend/src/components/admin/payment/__tests__/orderCurrencyDisplay.spec.ts new file mode 100644 index 0000000000..eb1821ace0 --- /dev/null +++ b/frontend/src/components/admin/payment/__tests__/orderCurrencyDisplay.spec.ts @@ -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('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key, + }), + } +}) + +const BaseDialogStub = { + props: ['show'], + template: '
', +} + +const DataTableStub = { + props: ['data'], + template: ` +
+
+ +
+
+ `, +} + +function orderFactory(overrides: Partial = {}): 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') + }) +}) diff --git a/frontend/src/components/payment/OrderTable.vue b/frontend/src/components/payment/OrderTable.vue index 899901655d..1ef31e6cb4 100644 --- a/frontend/src/components/payment/OrderTable.vue +++ b/frontend/src/components/payment/OrderTable.vue @@ -14,12 +14,12 @@ @@ -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') }, diff --git a/frontend/src/components/payment/PaymentQRDialog.vue b/frontend/src/components/payment/PaymentQRDialog.vue index 34cc52039c..f6278e93e0 100644 --- a/frontend/src/components/payment/PaymentQRDialog.vue +++ b/frontend/src/components/payment/PaymentQRDialog.vue @@ -45,11 +45,11 @@
{{ t('payment.orders.amount') }} - {{ paidOrder.order_type === 'balance' ? '$' : '¥' }}{{ paidOrder.amount.toFixed(2) }} + {{ creditedAmountSymbol }}{{ paidOrder.amount.toFixed(2) }}
{{ t('payment.orders.payAmount') }} - ¥{{ paidOrder.pay_amount.toFixed(2) }} + {{ paymentAmountSymbol(paidOrder) }}{{ paidOrder.pay_amount.toFixed(2) }}
@@ -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(null) +const creditedAmountSymbol = currencySymbol('USD') let pollTimer: ReturnType | null = null let countdownTimer: ReturnType | 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 diff --git a/frontend/src/components/payment/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue index 2a1349af24..d77db58a2f 100644 --- a/frontend/src/components/payment/PaymentStatusPanel.vue +++ b/frontend/src/components/payment/PaymentStatusPanel.vue @@ -22,11 +22,11 @@
{{ t('payment.orders.amount') }} - {{ paidOrder.order_type === 'balance' ? '$' + paidOrder.amount.toFixed(2) : formatGatewayAmount(paidOrder.amount) }} + {{ creditedAmountSymbol }}{{ paidOrder.amount.toFixed(2) }}
{{ t('payment.orders.payAmount') }} - {{ formatGatewayAmount(paidOrder.pay_amount) }} + {{ formatGatewayAmount(paidOrder.pay_amount, paidOrder.currency) }}
@@ -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(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 { diff --git a/frontend/src/components/payment/StripePaymentInline.vue b/frontend/src/components/payment/StripePaymentInline.vue index bdb0dd6b50..65f5bb18c7 100644 --- a/frontend/src/components/payment/StripePaymentInline.vue +++ b/frontend/src/components/payment/StripePaymentInline.vue @@ -23,11 +23,11 @@
{{ t('payment.orders.amount') }} - {{ orderType === 'balance' ? '$' : '¥' }}{{ amount.toFixed(2) }} + {{ creditedAmountSymbol }}{{ amount.toFixed(2) }}
{{ t('payment.orders.payAmount') }} - ¥{{ payAmount.toFixed(2) }} + {{ paymentAmountSymbol }}{{ payAmount.toFixed(2) }}
@@ -40,7 +40,7 @@

{{ t('payment.actualPay') }}

-

¥{{ payAmount.toFixed(2) }}

+

{{ paymentAmountSymbol }}{{ payAmount.toFixed(2) }}

@@ -64,13 +64,14 @@