Commit f1297a36 authored by erio's avatar erio
Browse files

feat: add per-provider allow_user_refund control and align wildcard matching

allow_user_refund:
- Add allow_user_refund field to PaymentProviderInstance ent schema
- Migration 103: ALTER TABLE payment_provider_instances ADD COLUMN
- Cascade logic: disabling refund_enabled auto-disables allow_user_refund
- User refund validation: check provider instance allows user refund
- Admin refund validation: check provider instance allows admin refund
- Subscription refund: deduct days on refund, rollback on failure
- New endpoint: GET /payment/orders/refund-eligible-providers
- Frontend: ToggleSwitch in ProviderCard/Dialog, cascade in SettingsView

Wildcard matching:
- Change findPricingForModel from "longest prefix wins" to "config order
  priority (first match wins)", aligning with channel service behavior
parent e8ee400a
...@@ -75,5 +75,10 @@ export const paymentAPI = { ...@@ -75,5 +75,10 @@ export const paymentAPI = {
/** Request a refund for a completed order */ /** Request a refund for a completed order */
requestRefund(id: number, data: { reason: string }) { requestRefund(id: number, data: { reason: string }) {
return apiClient.post(`/payment/orders/${id}/refund-request`, data) return apiClient.post(`/payment/orders/${id}/refund-request`, data)
},
/** Get provider instance IDs that allow user refund */
getRefundEligibleProviders() {
return apiClient.get<{ provider_instance_ids: string[] }>('/payment/orders/refund-eligible-providers')
} }
} }
...@@ -32,7 +32,8 @@ ...@@ -32,7 +32,8 @@
<!-- Toggles + Payment mode + Supported types (single row) --> <!-- Toggles + Payment mode + Supported types (single row) -->
<div class="flex flex-wrap items-center gap-x-5 gap-y-2"> <div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<ToggleSwitch :label="t('common.enabled')" :checked="form.enabled" @toggle="form.enabled = !form.enabled" /> <ToggleSwitch :label="t('common.enabled')" :checked="form.enabled" @toggle="form.enabled = !form.enabled" />
<ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="form.refund_enabled" @toggle="form.refund_enabled = !form.refund_enabled" /> <ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="form.refund_enabled" @toggle="form.refund_enabled = !form.refund_enabled; if (!form.refund_enabled) form.allow_user_refund = false" />
<ToggleSwitch v-if="form.refund_enabled" :label="t('admin.settings.payment.allowUserRefund')" :checked="form.allow_user_refund" @toggle="form.allow_user_refund = !form.allow_user_refund" />
<div v-if="form.provider_key === 'easypay'" class="flex items-center gap-2"> <div v-if="form.provider_key === 'easypay'" class="flex items-center gap-2">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.paymentMode') }}</span> <span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('admin.settings.payment.paymentMode') }}</span>
<div class="flex gap-1.5"> <div class="flex gap-1.5">
...@@ -243,6 +244,7 @@ const emit = defineEmits<{ ...@@ -243,6 +244,7 @@ const emit = defineEmits<{
enabled: boolean enabled: boolean
payment_mode: string payment_mode: string
refund_enabled: boolean refund_enabled: boolean
allow_user_refund: boolean
config: Record<string, string> config: Record<string, string>
limits: string limits: string
}] }]
...@@ -258,6 +260,7 @@ const form = reactive({ ...@@ -258,6 +260,7 @@ const form = reactive({
enabled: true, enabled: true,
payment_mode: PAYMENT_MODE_QRCODE, payment_mode: PAYMENT_MODE_QRCODE,
refund_enabled: false, refund_enabled: false,
allow_user_refund: false,
}) })
const config = reactive<Record<string, string>>({}) const config = reactive<Record<string, string>>({})
const limits = reactive<Record<string, Record<string, number>>>({}) const limits = reactive<Record<string, Record<string, number>>>({})
...@@ -433,6 +436,7 @@ function handleSave() { ...@@ -433,6 +436,7 @@ function handleSave() {
enabled: form.enabled, enabled: form.enabled,
payment_mode: form.provider_key === 'easypay' ? form.payment_mode : '', payment_mode: form.provider_key === 'easypay' ? form.payment_mode : '',
refund_enabled: form.refund_enabled, refund_enabled: form.refund_enabled,
allow_user_refund: form.refund_enabled ? form.allow_user_refund : false,
config: filteredConfig, config: filteredConfig,
limits: serializeLimits(), limits: serializeLimits(),
}) })
...@@ -452,6 +456,7 @@ function reset(defaultKey: string) { ...@@ -452,6 +456,7 @@ function reset(defaultKey: string) {
form.enabled = true form.enabled = true
form.payment_mode = defaultKey === 'easypay' ? PAYMENT_MODE_QRCODE : '' form.payment_mode = defaultKey === 'easypay' ? PAYMENT_MODE_QRCODE : ''
form.refund_enabled = false form.refund_enabled = false
form.allow_user_refund = false
clearConfig() clearConfig()
applyDefaults() applyDefaults()
} }
...@@ -463,6 +468,7 @@ function loadProvider(provider: ProviderInstance) { ...@@ -463,6 +468,7 @@ function loadProvider(provider: ProviderInstance) {
form.enabled = provider.enabled form.enabled = provider.enabled
form.payment_mode = provider.payment_mode || (provider.provider_key === 'easypay' ? PAYMENT_MODE_QRCODE : '') form.payment_mode = provider.payment_mode || (provider.provider_key === 'easypay' ? PAYMENT_MODE_QRCODE : '')
form.refund_enabled = provider.refund_enabled form.refund_enabled = provider.refund_enabled
form.allow_user_refund = provider.allow_user_refund
clearConfig() clearConfig()
// Pre-fill config from API response (non-sensitive in cleartext, sensitive masked as ••••••••) // Pre-fill config from API response (non-sensitive in cleartext, sensitive masked as ••••••••)
if (provider.config) { if (provider.config) {
......
...@@ -115,7 +115,7 @@ const emit = defineEmits<{ ...@@ -115,7 +115,7 @@ const emit = defineEmits<{
create: [] create: []
edit: [provider: ProviderInstance] edit: [provider: ProviderInstance]
delete: [provider: ProviderInstance] delete: [provider: ProviderInstance]
toggleField: [provider: ProviderInstance, field: 'enabled' | 'refund_enabled'] toggleField: [provider: ProviderInstance, field: 'enabled' | 'refund_enabled' | 'allow_user_refund']
toggleType: [provider: ProviderInstance, type: string] toggleType: [provider: ProviderInstance, type: string]
reorder: [providers: { id: number; sort_order: number }[]] reorder: [providers: { id: number; sort_order: number }[]]
}>() }>()
......
...@@ -46,6 +46,7 @@ ...@@ -46,6 +46,7 @@
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<ToggleSwitch :label="t('common.enabled')" :checked="provider.enabled" @toggle="emit('toggleField', 'enabled')" /> <ToggleSwitch :label="t('common.enabled')" :checked="provider.enabled" @toggle="emit('toggleField', 'enabled')" />
<ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="provider.refund_enabled" @toggle="emit('toggleField', 'refund_enabled')" /> <ToggleSwitch :label="t('admin.settings.payment.refundEnabled')" :checked="provider.refund_enabled" @toggle="emit('toggleField', 'refund_enabled')" />
<ToggleSwitch v-if="provider.refund_enabled" :label="t('admin.settings.payment.allowUserRefund')" :checked="provider.allow_user_refund" @toggle="emit('toggleField', 'allow_user_refund')" />
<div class="flex items-center gap-2 border-l border-gray-200 pl-3 dark:border-dark-600"> <div class="flex items-center gap-2 border-l border-gray-200 pl-3 dark:border-dark-600">
<button type="button" @click="emit('edit')" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400"> <button type="button" @click="emit('edit')" class="flex flex-col items-center gap-0.5 rounded-lg p-1.5 text-gray-500 transition-colors hover:bg-blue-50 hover:text-blue-600 dark:hover:bg-blue-900/20 dark:hover:text-blue-400">
<Icon name="edit" size="sm" /> <Icon name="edit" size="sm" />
...@@ -84,7 +85,7 @@ const props = defineProps<{ ...@@ -84,7 +85,7 @@ const props = defineProps<{
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
toggleField: [field: 'enabled' | 'refund_enabled'] toggleField: [field: 'enabled' | 'refund_enabled' | 'allow_user_refund']
toggleType: [type: string] toggleType: [type: string]
edit: [] edit: []
delete: [] delete: []
......
...@@ -4646,6 +4646,7 @@ export default { ...@@ -4646,6 +4646,7 @@ export default {
supportedTypes: 'Supported Payment Types', supportedTypes: 'Supported Payment Types',
supportedTypesHint: 'Comma-separated, e.g. alipay,wxpay', supportedTypesHint: 'Comma-separated, e.g. alipay,wxpay',
refundEnabled: 'Allow Refund', refundEnabled: 'Allow Refund',
allowUserRefund: 'Allow User Refund',
}, },
balanceNotify: { balanceNotify: {
title: 'Balance Low Notification', title: 'Balance Low Notification',
......
...@@ -4810,6 +4810,7 @@ export default { ...@@ -4810,6 +4810,7 @@ export default {
supportedTypes: '支持的支付方式', supportedTypes: '支持的支付方式',
supportedTypesHint: '逗号分隔,如 alipay,wxpay', supportedTypesHint: '逗号分隔,如 alipay,wxpay',
refundEnabled: '允许退款', refundEnabled: '允许退款',
allowUserRefund: '允许用户退款',
}, },
balanceNotify: { balanceNotify: {
title: '余额不足提醒', title: '余额不足提醒',
......
...@@ -89,6 +89,7 @@ export interface PaymentOrder { ...@@ -89,6 +89,7 @@ export interface PaymentOrder {
refund_requested_by?: number refund_requested_by?: number
refund_request_reason?: string refund_request_reason?: string
plan_id?: number plan_id?: number
provider_instance_id?: string
} }
// ==================== Plans & Channels ==================== // ==================== Plans & Channels ====================
...@@ -138,6 +139,7 @@ export interface ProviderInstance { ...@@ -138,6 +139,7 @@ export interface ProviderInstance {
enabled: boolean enabled: boolean
payment_mode: string payment_mode: string
refund_enabled: boolean refund_enabled: boolean
allow_user_refund: boolean
limits: string limits: string
sort_order: number sort_order: number
} }
......
...@@ -4111,12 +4111,25 @@ async function handleSaveProvider(payload: Partial<ProviderInstance>) { ...@@ -4111,12 +4111,25 @@ async function handleSaveProvider(payload: Partial<ProviderInstance>) {
} }
} }
async function handleToggleField(provider: ProviderInstance, field: 'enabled' | 'refund_enabled') { async function handleToggleField(provider: ProviderInstance, field: 'enabled' | 'refund_enabled' | 'allow_user_refund') {
const newValue = field === 'enabled' ? !provider.enabled : !provider.refund_enabled let newValue: boolean
if (field === 'enabled') newValue = !provider.enabled
else if (field === 'refund_enabled') newValue = !provider.refund_enabled
else newValue = !provider.allow_user_refund
try { try {
await adminAPI.payment.updateProvider(provider.id, { [field]: newValue }) const payload: Record<string, boolean> = { [field]: newValue }
// Cascade: turning off refund_enabled also disables allow_user_refund
if (field === 'refund_enabled' && !newValue) {
payload.allow_user_refund = false
}
await adminAPI.payment.updateProvider(provider.id, payload)
if (field === 'enabled') provider.enabled = newValue if (field === 'enabled') provider.enabled = newValue
else provider.refund_enabled = newValue else if (field === 'refund_enabled') {
provider.refund_enabled = newValue
if (!newValue) provider.allow_user_refund = false
} else {
provider.allow_user_refund = newValue
}
} catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value)) } } catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'), paymentErrorMap.value)) }
} }
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment