@@ -270,7 +313,7 @@ import Select from '@/components/common/Select.vue'
import type { SelectOption } from '@/components/common/Select.vue'
import ToggleSwitch from './ToggleSwitch.vue'
import type { ProviderInstance } from '@/types/payment'
-import type { TypeOption } from './providerConfig'
+import type { EasyPayCustomMethod, TypeOption } from './providerConfig'
import {
PROVIDER_CONFIG_FIELDS,
PROVIDER_SUPPORTED_TYPES,
@@ -282,6 +325,8 @@ import {
STRIPE_SDK_API_VERSION,
getAvailableTypes,
extractBaseUrl,
+ parseEasyPayCustomMethods,
+ serializeEasyPayCustomMethods,
} from './providerConfig'
/** Default payment_mode per provider key — "" means "no preference, use
@@ -365,6 +410,7 @@ const notifyBaseUrl = ref('')
const returnBaseUrl = ref('')
const limitsExpanded = ref(false)
const visibleFields = reactive
>({})
+const easyPayCustomMethods = reactive([])
// --- Computed ---
const defaultBaseUrl = typeof window !== 'undefined' ? window.location.origin : ''
@@ -404,6 +450,16 @@ const paymentModeOptions = computed(() => {
const availableTypes = computed(() => {
const base = getAvailableTypes(form.provider_key, props.allPaymentTypes, props.redirectLabel)
+ if (form.provider_key === 'easypay') {
+ for (const method of normalizedEasyPayCustomMethods()) {
+ if (!base.some(opt => opt.value === method.type)) {
+ base.push({
+ value: method.type,
+ label: method.displayName || method.type,
+ })
+ }
+ }
+ }
// Resolve i18n labels for types not in allPaymentTypes (e.g. card, link inside stripe)
return base.map(opt =>
opt.label === opt.value
@@ -510,6 +566,28 @@ function toggleType(type: string) {
}
}
+function normalizedEasyPayCustomMethods(): EasyPayCustomMethod[] {
+ return easyPayCustomMethods
+ .map(method => ({
+ type: normalizeEasyPayCustomMethodCode(method.type),
+ upstreamType: normalizeEasyPayCustomMethodCode(method.upstreamType),
+ displayName: method.displayName.trim(),
+ }))
+ .filter(method => method.type || method.upstreamType || method.displayName)
+}
+
+function normalizeEasyPayCustomMethodCode(value: string): string {
+ return value.trim().toLowerCase()
+}
+
+function addEasyPayCustomMethod() {
+ easyPayCustomMethods.push({ type: '', upstreamType: '', displayName: '' })
+}
+
+function removeEasyPayCustomMethod(index: number) {
+ easyPayCustomMethods.splice(index, 1)
+}
+
function onKeyChange() {
form.supported_types = [...(PROVIDER_SUPPORTED_TYPES[form.provider_key] || [])]
form.payment_mode = defaultPaymentMode(form.provider_key)
@@ -524,6 +602,7 @@ function clearConfig() {
notifyBaseUrl.value = ''
returnBaseUrl.value = ''
limitsExpanded.value = false
+ easyPayCustomMethods.splice(0, easyPayCustomMethods.length)
}
function applyDefaults() {
@@ -581,6 +660,14 @@ function handleSave() {
emitValidationError(t('admin.settings.payment.validationNameRequired'))
return
}
+ if (form.provider_key === 'easypay') {
+ const validationError = validateEasyPayCustomMethods()
+ if (validationError) {
+ emitValidationError(validationError)
+ return
+ }
+ syncEasyPayCustomMethods()
+ }
// Validate required config fields — all non-optional fields must be filled.
// In edit mode, sensitive fields may be left blank to preserve the stored
// value (backend merges blanks by preserving the existing secret).
@@ -610,6 +697,9 @@ function handleSave() {
}
filteredConfig[k] = v
}
+ if (form.provider_key === 'easypay') {
+ filteredConfig.customMethods = serializeEasyPayCustomMethods(normalizedEasyPayCustomMethods())
+ }
// Inject computed callback URLs (each URL = independent base + fixed path)
// If base URL is empty, auto-fill with current domain
@@ -636,6 +726,56 @@ function handleSave() {
})
}
+function syncEasyPayCustomMethods(): string[] {
+ if (form.provider_key !== 'easypay') return []
+ const baseTypes = new Set(PROVIDER_SUPPORTED_TYPES.easypay || [])
+ const customTypes: string[] = []
+ const seen = new Set()
+ for (const method of normalizedEasyPayCustomMethods()) {
+ if (!method.type || !method.upstreamType) continue
+ if (seen.has(method.type)) continue
+ seen.add(method.type)
+ customTypes.push(method.type)
+ }
+ form.supported_types = form.supported_types
+ .map(type => normalizeEasyPayCustomMethodCode(type))
+ .filter(type => baseTypes.has(type) || customTypes.includes(type))
+ for (const customType of customTypes) {
+ if (!form.supported_types.includes(customType)) {
+ form.supported_types.push(customType)
+ }
+ }
+ return customTypes
+}
+
+function validateEasyPayCustomMethods(): string | null {
+ const seen = new Set()
+ for (const method of normalizedEasyPayCustomMethods()) {
+ const hasAnyValue = Boolean(method.type || method.upstreamType || method.displayName)
+ if (!hasAnyValue) continue
+ if (!method.type || !method.upstreamType) {
+ return t('admin.settings.payment.validationEasyPayCustomMethodRequired')
+ }
+ if (!/^[a-z0-9_-]+$/.test(method.type)) {
+ return t('admin.settings.payment.validationEasyPayCustomMethodTypeInvalid')
+ }
+ if (!/^[a-z0-9_-]+$/.test(method.upstreamType)) {
+ return t('admin.settings.payment.validationEasyPayCustomMethodUpstreamTypeInvalid')
+ }
+ if ((PROVIDER_SUPPORTED_TYPES.easypay || []).includes(method.type)) {
+ return t('admin.settings.payment.validationEasyPayCustomMethodReserved')
+ }
+ if (method.type.startsWith('alipay') || method.type.startsWith('wxpay')) {
+ return t('admin.settings.payment.validationEasyPayCustomMethodPrefixReserved')
+ }
+ if (seen.has(method.type)) {
+ return t('admin.settings.payment.validationEasyPayCustomMethodDuplicate')
+ }
+ seen.add(method.type)
+ }
+ return null
+}
+
function emitValidationError(msg: string) {
// Use a custom event or inject appStore — for now use window alert fallback
// The parent handles this via the save event validation
@@ -677,6 +817,10 @@ function loadProvider(provider: ProviderInstance) {
for (const [k, v] of Object.entries(provider.config)) {
// Skip notifyUrl/returnUrl — they are derived from callbackBaseUrl
if (k === 'notifyUrl' || k === 'returnUrl') continue
+ if (k === 'customMethods' && provider.provider_key === 'easypay') {
+ easyPayCustomMethods.push(...parseEasyPayCustomMethods(v))
+ continue
+ }
config[k] = v
}
// Extract base URLs from existing callback URLs
diff --git a/frontend/src/components/payment/PaymentQRDialog.vue b/frontend/src/components/payment/PaymentQRDialog.vue
index f6278e93e0..7dff831a6a 100644
--- a/frontend/src/components/payment/PaymentQRDialog.vue
+++ b/frontend/src/components/payment/PaymentQRDialog.vue
@@ -79,7 +79,7 @@ import { usePaymentStore } from '@/stores/payment'
import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment'
import { extractI18nErrorMessage } from '@/utils/apiError'
-import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
+import { getPaymentPopupFeatures, isBuiltInAlipayMethod, isBuiltInWxpayMethod } from '@/components/payment/providerConfig'
import type { PaymentOrder } from '@/types/payment'
import { currencySymbol } from '@/components/payment/currency'
import QRCode from 'qrcode'
@@ -122,8 +122,8 @@ let lastVerifyAt = 0
const VERIFY_RETRY_INTERVAL_MS = 15000
const VERIFY_RETRY_MAX_ATTEMPTS = 6
-const isAlipay = computed(() => props.paymentType.includes('alipay'))
-const isWxpay = computed(() => props.paymentType.includes('wxpay'))
+const isAlipay = computed(() => isBuiltInAlipayMethod(props.paymentType))
+const isWxpay = computed(() => isBuiltInWxpayMethod(props.paymentType))
const dialogTitle = computed(() => {
if (success.value) return t('payment.result.success')
diff --git a/frontend/src/components/payment/PaymentStatusPanel.vue b/frontend/src/components/payment/PaymentStatusPanel.vue
index d77db58a2f..c7232fd640 100644
--- a/frontend/src/components/payment/PaymentStatusPanel.vue
+++ b/frontend/src/components/payment/PaymentStatusPanel.vue
@@ -79,7 +79,7 @@
-
+
@@ -128,13 +128,14 @@ import { usePaymentStore } from '@/stores/payment'
import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment'
import { extractI18nErrorMessage } from '@/utils/apiError'
-import { getPaymentPopupFeatures } from '@/components/payment/providerConfig'
+import { getPaymentPopupFeatures, isBuiltInAlipayMethod, isBuiltInWxpayMethod } from '@/components/payment/providerConfig'
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'
import alipayIcon from '@/assets/icons/alipay.svg'
import wxpayIcon from '@/assets/icons/wxpay.svg'
+import paymentIcon from '@/assets/icons/payment.svg'
const props = defineProps<{
orderId: number
@@ -182,8 +183,8 @@ let lastVerifyAt = 0
const VERIFY_RETRY_INTERVAL_MS = 15000
const VERIFY_RETRY_MAX_ATTEMPTS = 6
-const isAlipay = computed(() => props.paymentType.includes('alipay'))
-const isWxpay = computed(() => props.paymentType.includes('wxpay'))
+const isAlipay = computed(() => isBuiltInAlipayMethod(props.paymentType))
+const isWxpay = computed(() => isBuiltInWxpayMethod(props.paymentType))
const qrBorderClass = computed(() => {
if (isAlipay.value) return 'border-[#00AEEF] bg-blue-50 dark:border-[#00AEEF]/70 dark:bg-blue-950/20'
@@ -197,6 +198,12 @@ const qrLogoBgClass = computed(() => {
return 'bg-gray-400'
})
+const qrLogoIcon = computed(() => {
+ if (isAlipay.value) return alipayIcon
+ if (isWxpay.value) return wxpayIcon
+ return paymentIcon
+})
+
const scanTitle = computed(() => {
if (isAlipay.value) return t('payment.qr.scanAlipay')
if (isWxpay.value) return t('payment.qr.scanWxpay')
diff --git a/frontend/src/components/payment/__tests__/PaymentMethodSelector.spec.ts b/frontend/src/components/payment/__tests__/PaymentMethodSelector.spec.ts
new file mode 100644
index 0000000000..e481325fe7
--- /dev/null
+++ b/frontend/src/components/payment/__tests__/PaymentMethodSelector.spec.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
+
+vi.mock('vue-i18n', () => ({
+ useI18n: () => ({
+ t: (key: string, fallback?: string) => fallback ?? key,
+ }),
+}))
+
+describe('PaymentMethodSelector', () => {
+ it('shows the configured display name for custom EasyPay methods', () => {
+ const wrapper = mount(PaymentMethodSelector, {
+ props: {
+ selected: 'ldc',
+ methods: [{ type: 'ldc', display_name: 'LDC Pay', fee_rate: 0, available: true }],
+ },
+ })
+
+ expect(wrapper.text()).toContain('LDC Pay')
+ expect(wrapper.text()).not.toContain('ldc')
+ expect(wrapper.text()).not.toContain('payment.methods.ldc')
+ })
+
+ it('uses the generic selected style for custom methods that contain built-in names', () => {
+ const wrapper = mount(PaymentMethodSelector, {
+ props: {
+ selected: 'card_alipay',
+ methods: [{ type: 'card_alipay', display_name: 'Card Pay', fee_rate: 0, available: true }],
+ },
+ })
+
+ const button = wrapper.get('button')
+ expect(button.classes()).toContain('border-primary-500')
+ expect(button.classes()).not.toContain('border-[#02A9F1]')
+ })
+})
diff --git a/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts b/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts
index 099152d8a3..a84ff4cbda 100644
--- a/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts
+++ b/frontend/src/components/payment/__tests__/PaymentProviderDialog.spec.ts
@@ -7,6 +7,12 @@ import type { ProviderInstance } from '@/types/payment'
const messages: Record