feat(payment): balance recharge multiplier and refund amount separation
- Add balance_recharge_multiplier system setting (e.g. 1.2 = charge 100 get 120) - Separate order_amount (credited balance) from pay_amount (actual payment) - Refund calculates gateway amount proportionally from pay_amount - Frontend shows both amounts in order details, payment status, refund dialog - Admin settings UI for configuring recharge multiplier
This commit is contained in:
@@ -2371,10 +2371,16 @@
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.preview') }}</label><div class="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600 dark:border-dark-600 dark:bg-dark-800 dark:text-gray-300">{{ (form.payment_product_name_prefix || 'Sub2API') + ' 100 ' + (form.payment_product_name_suffix || 'CNY') }}</div></div>
|
||||
</div>
|
||||
<!-- Row 2: Balance toggle + amounts -->
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.minAmount') }}</label><input :value="form.payment_min_amount || ''" @input="form.payment_min_amount = parseFloat(($event.target as HTMLInputElement).value) || 0" type="number" step="0.01" min="0" class="input" :placeholder="t('admin.settings.payment.noLimit')" /></div>
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.maxAmount') }}</label><input :value="form.payment_max_amount || ''" @input="form.payment_max_amount = parseFloat(($event.target as HTMLInputElement).value) || 0" type="number" step="0.01" min="0" class="input" :placeholder="t('admin.settings.payment.noLimit')" /></div>
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.dailyLimit') }}</label><input :value="form.payment_daily_limit || ''" @input="form.payment_daily_limit = parseFloat(($event.target as HTMLInputElement).value) || 0" type="number" step="0.01" min="0" class="input" :placeholder="t('admin.settings.payment.noLimit')" /></div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.settings.payment.balanceRechargeMultiplier') }}</label>
|
||||
<input :value="form.payment_balance_recharge_multiplier || ''" @input="form.payment_balance_recharge_multiplier = parseFloat(($event.target as HTMLInputElement).value) || 1" type="number" step="0.01" min="0.01" class="input" />
|
||||
<p class="mt-0.5 text-xs text-gray-400">{{ t('admin.settings.payment.balanceRechargeMultiplierHint') }}</p>
|
||||
<p class="mt-1 text-xs font-medium text-primary-600 dark:text-primary-400">{{ t('admin.settings.payment.balanceRechargePreview', { usd: (Number(form.payment_balance_recharge_multiplier) || 1).toFixed(2) }) }}</p>
|
||||
</div>
|
||||
<div><label class="input-label">{{ t('admin.settings.payment.orderTimeout') }} <span class="text-red-500">*</span></label><input v-model.number="form.payment_order_timeout_minutes" type="number" min="1" class="input" required /><p class="mt-0.5 text-xs text-gray-400">{{ t('admin.settings.payment.orderTimeoutHint') }}</p></div>
|
||||
</div>
|
||||
<!-- Row 3: Pending orders + load balance + cancel rate limit (all in one row) -->
|
||||
@@ -2968,7 +2974,7 @@ const form = reactive<SettingsForm>({
|
||||
home_content: '',
|
||||
backend_mode_enabled: false,
|
||||
hide_ccs_import_button: false,
|
||||
payment_enabled: false, payment_min_amount: 1, payment_max_amount: 10000, payment_daily_limit: 50000, payment_max_pending_orders: 3, payment_order_timeout_minutes: 30, payment_balance_disabled: false, payment_enabled_types: [], payment_help_image_url: '', payment_help_text: '', payment_product_name_prefix: '', payment_product_name_suffix: '', payment_load_balance_strategy: 'round-robin', payment_cancel_rate_limit_enabled: false, payment_cancel_rate_limit_max: 10, payment_cancel_rate_limit_window: 1, payment_cancel_rate_limit_unit: 'day', payment_cancel_rate_limit_window_mode: 'rolling',
|
||||
payment_enabled: false, payment_min_amount: 1, payment_max_amount: 10000, payment_daily_limit: 50000, payment_max_pending_orders: 3, payment_order_timeout_minutes: 30, payment_balance_disabled: false, payment_balance_recharge_multiplier: 1, payment_enabled_types: [], payment_help_image_url: '', payment_help_text: '', payment_product_name_prefix: '', payment_product_name_suffix: '', payment_load_balance_strategy: 'round-robin', payment_cancel_rate_limit_enabled: false, payment_cancel_rate_limit_max: 10, payment_cancel_rate_limit_window: 1, payment_cancel_rate_limit_unit: 'day', payment_cancel_rate_limit_window_mode: 'rolling',
|
||||
table_default_page_size: tablePageSizeDefault,
|
||||
table_page_size_options: [10, 20, 50, 100],
|
||||
custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>,
|
||||
@@ -3627,6 +3633,7 @@ async function saveSettings() {
|
||||
payment_max_pending_orders: Number(form.payment_max_pending_orders) || 0,
|
||||
payment_order_timeout_minutes: Number(form.payment_order_timeout_minutes) || 0,
|
||||
payment_balance_disabled: form.payment_balance_disabled,
|
||||
payment_balance_recharge_multiplier: Number(form.payment_balance_recharge_multiplier) || 1,
|
||||
payment_enabled_types: form.payment_enabled_types,
|
||||
payment_load_balance_strategy: form.payment_load_balance_strategy,
|
||||
payment_product_name_prefix: form.payment_product_name_prefix,
|
||||
|
||||
@@ -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.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">{{ row.order_type === 'balance' ? '$' : '¥' }}{{ 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.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">{{ 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.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 * 100).toFixed(1) }}%</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.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">{{ selectedOrder.order_type === 'balance' ? '$' : '¥' }}{{ 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">
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
</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">{{ order.order_type === 'balance' ? '$' : '¥' }}{{ order.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">¥{{ order.pay_amount.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
@@ -58,7 +62,7 @@
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ returnInfo.outTradeNo }}</span>
|
||||
</div>
|
||||
<div v-if="returnInfo.money" class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.amount') }}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.payAmount') }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">¥{{ returnInfo.money }}</span>
|
||||
</div>
|
||||
<div v-if="returnInfo.type" class="flex justify-between">
|
||||
|
||||
@@ -28,7 +28,9 @@
|
||||
<template v-else-if="paymentPhase === 'stripe'">
|
||||
<StripePaymentInline
|
||||
:order-id="paymentState.orderId"
|
||||
:amount="paymentState.amount"
|
||||
:client-secret="paymentState.clientSecret"
|
||||
:order-type="paymentState.orderType || undefined"
|
||||
:publishable-key="checkout.stripe_publishable_key"
|
||||
:pay-amount="paymentState.payAmount"
|
||||
@success="onPaymentSuccess"
|
||||
@@ -67,13 +69,17 @@
|
||||
@select="selectedMethod = $event"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="feeRate > 0 && validAmount > 0" class="card p-6">
|
||||
<div v-if="validAmount > 0" class="card p-6">
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.amountLabel') }}</span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.paymentAmount') }}</span>
|
||||
<span class="text-gray-900 dark:text-white">¥{{ validAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.creditedBalance') }}</span>
|
||||
<span class="text-gray-900 dark:text-white">${{ creditedAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
<div v-if="feeRate > 0" class="flex justify-between">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
|
||||
<span class="text-gray-900 dark:text-white">¥{{ feeAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
@@ -81,6 +87,9 @@
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span>
|
||||
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">¥{{ totalAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
<p class="border-t border-gray-200 pt-2 text-xs text-gray-500 dark:border-dark-600 dark:text-gray-400">
|
||||
{{ t('payment.rechargeRatePreview', { usd: balanceRechargeMultiplier.toFixed(2) }) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button :class="['btn w-full py-3 text-base font-medium', paymentButtonClass]" :disabled="!canSubmit || submitting" @click="handleSubmitRecharge">
|
||||
@@ -88,7 +97,7 @@
|
||||
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
|
||||
{{ t('common.processing') }}
|
||||
</span>
|
||||
<span v-else>{{ t('payment.createOrder') }} ¥{{ (feeRate > 0 && validAmount > 0 ? totalAmount : validAmount).toFixed(2) }}</span>
|
||||
<span v-else>{{ t('payment.createOrder') }} ¥{{ totalAmount.toFixed(2) }}</span>
|
||||
</button>
|
||||
<div v-if="errorMessage" class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20">
|
||||
<p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p>
|
||||
@@ -264,7 +273,7 @@ import { useAppStore } from '@/stores'
|
||||
import { paymentAPI } from '@/api/payment'
|
||||
import { extractApiErrorMessage } from '@/utils/apiError'
|
||||
import { isMobileDevice } from '@/utils/device'
|
||||
import type { SubscriptionPlan, CheckoutInfoResponse } from '@/types/payment'
|
||||
import type { SubscriptionPlan, CheckoutInfoResponse, OrderType } from '@/types/payment'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import AmountInput from '@/components/payment/AmountInput.vue'
|
||||
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
|
||||
@@ -302,11 +311,21 @@ const previewImage = ref('')
|
||||
|
||||
// Payment phase: 'select' → 'paying' (QR/redirect) or 'stripe' (inline Stripe)
|
||||
const paymentPhase = ref<'select' | 'paying' | 'stripe'>('select')
|
||||
const paymentState = ref({ orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' })
|
||||
const paymentState = ref<{
|
||||
orderId: number
|
||||
amount: number
|
||||
qrCode: string
|
||||
expiresAt: string
|
||||
paymentType: string
|
||||
payUrl: string
|
||||
clientSecret: string
|
||||
payAmount: number
|
||||
orderType: OrderType | ''
|
||||
}>({ orderId: 0, amount: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' })
|
||||
|
||||
function resetPayment() {
|
||||
paymentPhase.value = 'select'
|
||||
paymentState.value = { orderId: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' }
|
||||
paymentState.value = { orderId: 0, amount: 0, qrCode: '', expiresAt: '', paymentType: '', payUrl: '', clientSecret: '', payAmount: 0, orderType: '' }
|
||||
}
|
||||
|
||||
function onPaymentDone() {
|
||||
@@ -342,7 +361,7 @@ function onStripeRedirect(orderId: number, payUrl: string) {
|
||||
// All checkout data from single API call
|
||||
const checkout = ref<CheckoutInfoResponse>({
|
||||
methods: {}, global_min: 0, global_max: 0,
|
||||
plans: [], balance_disabled: false, help_text: '', help_image_url: '', stripe_publishable_key: '',
|
||||
plans: [], balance_disabled: false, balance_recharge_multiplier: 1, help_text: '', help_image_url: '', stripe_publishable_key: '',
|
||||
})
|
||||
|
||||
const tabs = computed(() => {
|
||||
@@ -354,6 +373,11 @@ const tabs = computed(() => {
|
||||
|
||||
const enabledMethods = computed(() => Object.keys(checkout.value.methods))
|
||||
const validAmount = computed(() => amount.value ?? 0)
|
||||
const balanceRechargeMultiplier = computed(() => {
|
||||
const multiplier = checkout.value.balance_recharge_multiplier
|
||||
return multiplier > 0 ? multiplier : 1
|
||||
})
|
||||
const creditedAmount = computed(() => Math.round((validAmount.value * balanceRechargeMultiplier.value) * 100) / 100)
|
||||
|
||||
// Adaptive grid: center single card, 2-col for 2 plans, 3-col for 3+
|
||||
const planGridClass = computed(() => {
|
||||
@@ -518,7 +542,7 @@ async function confirmSubscribe() {
|
||||
await createOrder(selectedPlan.value.price, 'subscription', selectedPlan.value.id)
|
||||
}
|
||||
|
||||
async function createOrder(orderAmount: number, orderType: string, planId?: number) {
|
||||
async function createOrder(orderAmount: number, orderType: OrderType, planId?: number) {
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
@@ -537,7 +561,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
|
||||
if (result.client_secret) {
|
||||
// Stripe: show Payment Element inline (user picks method → confirms → redirect if needed)
|
||||
paymentState.value = {
|
||||
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '',
|
||||
orderId: result.order_id, amount: result.amount, qrCode: '', expiresAt: result.expires_at || '',
|
||||
paymentType: selectedMethod.value, payUrl: '',
|
||||
clientSecret: result.client_secret, payAmount: result.pay_amount,
|
||||
orderType,
|
||||
@@ -546,7 +570,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
|
||||
} else if (isMobileDevice() && result.pay_url) {
|
||||
// Mobile + pay_url: redirect directly instead of QR/popup (mobile browsers block popups)
|
||||
paymentState.value = {
|
||||
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '',
|
||||
orderId: result.order_id, amount: result.amount, qrCode: '', expiresAt: result.expires_at || '',
|
||||
paymentType: selectedMethod.value, payUrl: result.pay_url,
|
||||
clientSecret: '', payAmount: 0,
|
||||
orderType,
|
||||
@@ -557,7 +581,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
|
||||
} else if (result.qr_code) {
|
||||
// QR mode: show QR code inline
|
||||
paymentState.value = {
|
||||
orderId: result.order_id, qrCode: result.qr_code,
|
||||
orderId: result.order_id, amount: result.amount, qrCode: result.qr_code,
|
||||
expiresAt: result.expires_at || '', paymentType: selectedMethod.value, payUrl: '',
|
||||
clientSecret: '', payAmount: 0,
|
||||
orderType,
|
||||
@@ -567,7 +591,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
|
||||
// Redirect/popup mode: open payment URL, show waiting state inline
|
||||
openWindow(result.pay_url)
|
||||
paymentState.value = {
|
||||
orderId: result.order_id, qrCode: '', expiresAt: result.expires_at || '',
|
||||
orderId: result.order_id, amount: result.amount, qrCode: '', expiresAt: result.expires_at || '',
|
||||
paymentType: selectedMethod.value, payUrl: result.pay_url,
|
||||
clientSecret: '', payAmount: 0,
|
||||
orderType,
|
||||
|
||||
Reference in New Issue
Block a user