Unverified Commit 9bf079b7 authored by Wesley Liddick's avatar Wesley Liddick Committed by GitHub
Browse files

Merge pull request #1655 from touwaeriol/feat/payment-fee-multiplier

feat(payment): balance recharge multiplier and fee rate
parents 7c671b53 c2108421
<template> <template>
<label class="flex items-center gap-1.5 cursor-pointer"> <label class="flex flex-col items-center gap-0.5 cursor-pointer">
<span class="text-xs text-gray-500 dark:text-gray-400">{{ label }}</span> <span class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{{ label }}</span>
<button <button
type="button" type="button"
role="switch" role="switch"
......
...@@ -4566,6 +4566,12 @@ export default { ...@@ -4566,6 +4566,12 @@ export default {
minAmount: 'Minimum Amount', minAmount: 'Minimum Amount',
maxAmount: 'Maximum Amount', maxAmount: 'Maximum Amount',
dailyLimit: 'Daily Limit', dailyLimit: 'Daily Limit',
balanceRechargeMultiplier: 'Balance Recharge Multiplier',
balanceRechargeMultiplierHint: 'How many USD balance the user receives for each 1 CNY paid',
balanceRechargePreview: 'Preview: 1 CNY = {usd} USD',
rechargeFeeRate: 'Recharge Fee Rate',
rechargeFeeRateHint: 'Percentage of service fee charged on top of recharge amount, 0 means no fee',
rechargeFeePreview: 'Preview: Recharge 100, fee {fee}',
orderTimeout: 'Order Timeout', orderTimeout: 'Order Timeout',
orderTimeoutHint: 'In minutes, minimum 1', orderTimeoutHint: 'In minutes, minimum 1',
maxPendingOrders: 'Max Pending Orders', maxPendingOrders: 'Max Pending Orders',
...@@ -5324,6 +5330,8 @@ export default { ...@@ -5324,6 +5330,8 @@ export default {
payment: { payment: {
title: 'Recharge / Subscription', title: 'Recharge / Subscription',
amountLabel: 'Amount', amountLabel: 'Amount',
paymentAmount: 'Payment Amount',
creditedBalance: 'Credited Balance',
quickAmounts: 'Quick Amounts', quickAmounts: 'Quick Amounts',
customAmount: 'Custom Amount', customAmount: 'Custom Amount',
enterAmount: 'Enter amount', enterAmount: 'Enter amount',
...@@ -5379,6 +5387,10 @@ export default { ...@@ -5379,6 +5387,10 @@ export default {
orderNo: 'Order No.', orderNo: 'Order No.',
amount: 'Amount', amount: 'Amount',
payAmount: 'Paid', payAmount: 'Paid',
creditedAmount: 'Credited Amount',
fee: 'Fee',
baseAmount: 'Base Amount',
includedInPayAmount: 'included in paid amount',
status: 'Status', status: 'Status',
paymentMethod: 'Payment Method', paymentMethod: 'Payment Method',
createdAt: 'Created', createdAt: 'Created',
...@@ -5408,6 +5420,7 @@ export default { ...@@ -5408,6 +5420,7 @@ export default {
amountTooLow: 'Minimum amount is {min}', amountTooLow: 'Minimum amount is {min}',
amountTooHigh: 'Maximum amount is {max}', amountTooHigh: 'Maximum amount is {max}',
amountNoMethod: 'No payment method available for this amount', amountNoMethod: 'No payment method available for this amount',
rechargeRatePreview: 'Current rate: 1 CNY = {usd} USD',
refundReason: 'Refund Reason', refundReason: 'Refund Reason',
refundReasonPlaceholder: 'Please describe your refund reason', refundReasonPlaceholder: 'Please describe your refund reason',
stripeLoadFailed: 'Failed to load payment component. Please refresh and try again.', stripeLoadFailed: 'Failed to load payment component. Please refresh and try again.',
......
...@@ -4726,10 +4726,16 @@ export default { ...@@ -4726,10 +4726,16 @@ export default {
enabledHint: '启用或禁用支付系统', enabledHint: '启用或禁用支付系统',
enabledPaymentTypes: '启用的服务商', enabledPaymentTypes: '启用的服务商',
enabledPaymentTypesHint: '禁用服务商将同时禁用对应的实例。', enabledPaymentTypesHint: '禁用服务商将同时禁用对应的实例。',
findProvider: '正在寻找合适的 EasyPay 服务商?', findProvider: '正在寻找合适的易支付服务商?',
minAmount: '最低金额', minAmount: '最低金额',
maxAmount: '最高金额', maxAmount: '最高金额',
dailyLimit: '每日限额', dailyLimit: '每日限额',
balanceRechargeMultiplier: '余额充值倍率',
balanceRechargeMultiplierHint: '用户每支付 1 CNY 可获得多少 USD 余额',
balanceRechargePreview: '预览:1 CNY = {usd} USD',
rechargeFeeRate: '充值手续费率',
rechargeFeeRateHint: '用户充值时额外收取的手续费百分比,0 表示不收取手续费',
rechargeFeePreview: '预览:充值 100 元,手续费 {fee} 元',
orderTimeout: '订单超时时间', orderTimeout: '订单超时时间',
orderTimeoutHint: '单位:分钟,至少 1 分钟', orderTimeoutHint: '单位:分钟,至少 1 分钟',
maxPendingOrders: '最大待支付订单数', maxPendingOrders: '最大待支付订单数',
...@@ -5512,6 +5518,8 @@ export default { ...@@ -5512,6 +5518,8 @@ export default {
payment: { payment: {
title: '充值/订阅', title: '充值/订阅',
amountLabel: '充值金额', amountLabel: '充值金额',
paymentAmount: '支付金额',
creditedBalance: '到账余额',
quickAmounts: '快捷金额', quickAmounts: '快捷金额',
customAmount: '自定义金额', customAmount: '自定义金额',
enterAmount: '输入金额', enterAmount: '输入金额',
...@@ -5567,6 +5575,10 @@ export default { ...@@ -5567,6 +5575,10 @@ export default {
orderNo: '订单编号', orderNo: '订单编号',
amount: '金额', amount: '金额',
payAmount: '实付', payAmount: '实付',
creditedAmount: '到账金额',
fee: '手续费',
baseAmount: '充值金额',
includedInPayAmount: '已含在实付金额中',
status: '状态', status: '状态',
paymentMethod: '支付方式', paymentMethod: '支付方式',
createdAt: '创建时间', createdAt: '创建时间',
...@@ -5596,6 +5608,7 @@ export default { ...@@ -5596,6 +5608,7 @@ export default {
amountTooLow: '最低金额为 {min}', amountTooLow: '最低金额为 {min}',
amountTooHigh: '最高金额为 {max}', amountTooHigh: '最高金额为 {max}',
amountNoMethod: '该金额没有可用的支付方式', amountNoMethod: '该金额没有可用的支付方式',
rechargeRatePreview: '当前倍率:1 CNY = {usd} USD',
refundReason: '退款原因', refundReason: '退款原因',
refundReasonPlaceholder: '请描述您的退款原因', refundReasonPlaceholder: '请描述您的退款原因',
stripeLoadFailed: '支付组件加载失败,请刷新页面重试', stripeLoadFailed: '支付组件加载失败,请刷新页面重试',
......
...@@ -32,6 +32,7 @@ export interface PaymentConfig { ...@@ -32,6 +32,7 @@ export interface PaymentConfig {
max_pending_orders: number max_pending_orders: number
order_timeout_minutes: number order_timeout_minutes: number
balance_disabled: boolean balance_disabled: boolean
balance_recharge_multiplier: number
enabled_payment_types: PaymentType[] enabled_payment_types: PaymentType[]
help_image_url: string help_image_url: string
help_text: string help_text: string
...@@ -62,6 +63,8 @@ export interface CheckoutInfoResponse { ...@@ -62,6 +63,8 @@ export interface CheckoutInfoResponse {
global_max: number global_max: number
plans: SubscriptionPlan[] plans: SubscriptionPlan[]
balance_disabled: boolean balance_disabled: boolean
balance_recharge_multiplier: number
recharge_fee_rate: number
help_text: string help_text: string
help_image_url: string help_image_url: string
stripe_publishable_key: string stripe_publishable_key: string
...@@ -155,10 +158,12 @@ export interface CreateOrderRequest { ...@@ -155,10 +158,12 @@ export interface CreateOrderRequest {
export interface CreateOrderResult { export interface CreateOrderResult {
order_id: number order_id: number
amount: number
pay_url?: string pay_url?: string
qr_code?: string qr_code?: string
client_secret?: string client_secret?: string
pay_amount: number pay_amount: number
fee_rate: number
expires_at: string expires_at: string
payment_mode?: string payment_mode?: string
} }
......
...@@ -2371,10 +2371,25 @@ ...@@ -2371,10 +2371,25 @@
<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><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> </div>
<!-- Row 2: Balance toggle + amounts --> <!-- 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.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.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.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.rechargeFeeRate') }}</label>
<div class="relative">
<input :value="form.payment_recharge_fee_rate ?? ''" @input="form.payment_recharge_fee_rate = Math.min(100, Math.max(0, Math.round(parseFloat(($event.target as HTMLInputElement).value || '0') * 100) / 100))" type="number" step="0.01" min="0" max="100" class="input pr-8" />
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400">%</span>
</div>
<p class="mt-0.5 text-xs text-gray-400">{{ t('admin.settings.payment.rechargeFeeRateHint') }}</p>
<p v-if="(Number(form.payment_recharge_fee_rate) || 0) > 0" class="mt-1 text-xs font-medium text-primary-600 dark:text-primary-400">{{ t('admin.settings.payment.rechargeFeePreview', { fee: (Number(form.payment_recharge_fee_rate) || 0).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><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> </div>
<!-- Row 3: Pending orders + load balance + cancel rate limit (all in one row) --> <!-- Row 3: Pending orders + load balance + cancel rate limit (all in one row) -->
...@@ -2968,7 +2983,7 @@ const form = reactive<SettingsForm>({ ...@@ -2968,7 +2983,7 @@ const form = reactive<SettingsForm>({
home_content: '', home_content: '',
backend_mode_enabled: false, backend_mode_enabled: false,
hide_ccs_import_button: 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_recharge_fee_rate: 0, 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_default_page_size: tablePageSizeDefault,
table_page_size_options: [10, 20, 50, 100], 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}>, custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>,
...@@ -3627,6 +3642,8 @@ async function saveSettings() { ...@@ -3627,6 +3642,8 @@ async function saveSettings() {
payment_max_pending_orders: Number(form.payment_max_pending_orders) || 0, payment_max_pending_orders: Number(form.payment_max_pending_orders) || 0,
payment_order_timeout_minutes: Number(form.payment_order_timeout_minutes) || 0, payment_order_timeout_minutes: Number(form.payment_order_timeout_minutes) || 0,
payment_balance_disabled: form.payment_balance_disabled, payment_balance_disabled: form.payment_balance_disabled,
payment_balance_recharge_multiplier: Number(form.payment_balance_recharge_multiplier) || 1,
payment_recharge_fee_rate: Number(form.payment_recharge_fee_rate) || 0,
payment_enabled_types: form.payment_enabled_types, payment_enabled_types: form.payment_enabled_types,
payment_load_balance_strategy: form.payment_load_balance_strategy, payment_load_balance_strategy: form.payment_load_balance_strategy,
payment_product_name_prefix: form.payment_product_name_prefix, payment_product_name_prefix: form.payment_product_name_prefix,
......
...@@ -35,7 +35,7 @@ ...@@ -35,7 +35,7 @@
{{ t('payment.admin.retry') }} {{ t('payment.admin.retry') }}
</button> </button>
<template v-if="row.status === 'REFUND_REQUESTED'"> <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"> <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" /> <Icon name="check" size="sm" />
{{ t('payment.admin.approveRefund') }} {{ t('payment.admin.approveRefund') }}
...@@ -62,14 +62,14 @@ ...@@ -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.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.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.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.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.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.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.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.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><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.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> <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 --> <!-- 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"> <div v-if="selectedOrder.refund_requested_at" class="col-span-2 border-t border-gray-200 pt-3 dark:border-dark-600">
......
...@@ -37,8 +37,20 @@ ...@@ -37,8 +37,20 @@
<span class="font-medium text-gray-900 dark:text-white">{{ order.out_trade_no }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ order.out_trade_no }}</span>
</div> </div>
<div class="flex justify-between"> <div 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.baseAmount') }}</span>
<span class="font-medium text-gray-900 dark:text-white">&#165;{{ order.pay_amount.toFixed(2) }}</span> <span class="font-medium text-gray-900 dark:text-white">&#165;{{ baseAmount.toFixed(2) }}</span>
</div>
<div v-if="order.fee_rate > 0" class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.fee') }} ({{ order.fee_rate }}%)</span>
<span class="font-medium text-gray-900 dark:text-white">&#165;{{ feeAmount.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-bold text-primary-600 dark:text-primary-400">&#165;{{ order.pay_amount.toFixed(2) }}</span>
</div>
<div v-if="order.amount !== order.pay_amount" class="flex justify-between">
<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>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</span> <span class="text-gray-500 dark:text-gray-400">{{ t('payment.orders.paymentMethod') }}</span>
...@@ -58,7 +70,7 @@ ...@@ -58,7 +70,7 @@
<span class="font-medium text-gray-900 dark:text-white">{{ returnInfo.outTradeNo }}</span> <span class="font-medium text-gray-900 dark:text-white">{{ returnInfo.outTradeNo }}</span>
</div> </div>
<div v-if="returnInfo.money" class="flex justify-between"> <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">&#165;{{ returnInfo.money }}</span> <span class="font-medium text-gray-900 dark:text-white">&#165;{{ returnInfo.money }}</span>
</div> </div>
<div v-if="returnInfo.type" class="flex justify-between"> <div v-if="returnInfo.type" class="flex justify-between">
...@@ -104,6 +116,18 @@ const returnInfo = ref<ReturnInfo | null>(null) ...@@ -104,6 +116,18 @@ const returnInfo = ref<ReturnInfo | null>(null)
const SUCCESS_STATUSES = new Set(['COMPLETED', 'PAID', 'RECHARGING']) const SUCCESS_STATUSES = new Set(['COMPLETED', 'PAID', 'RECHARGING'])
/** 充值金额 = pay_amount / (1 + fee_rate/100),fee_rate=0 时等于 pay_amount */
const baseAmount = computed(() => {
if (!order.value || order.value.fee_rate <= 0) return order.value?.pay_amount ?? 0
return Math.round((order.value.pay_amount / (1 + order.value.fee_rate / 100)) * 100) / 100
})
/** 手续费 = pay_amount - baseAmount */
const feeAmount = computed(() => {
if (!order.value || order.value.fee_rate <= 0) return 0
return Math.round((order.value.pay_amount - baseAmount.value) * 100) / 100
})
const isSuccess = computed(() => { const isSuccess = computed(() => {
// Always prioritize actual order status from backend // Always prioritize actual order status from backend
if (order.value) { if (order.value) {
......
...@@ -28,7 +28,9 @@ ...@@ -28,7 +28,9 @@
<template v-else-if="paymentPhase === 'stripe'"> <template v-else-if="paymentPhase === 'stripe'">
<StripePaymentInline <StripePaymentInline
:order-id="paymentState.orderId" :order-id="paymentState.orderId"
:amount="paymentState.amount"
:client-secret="paymentState.clientSecret" :client-secret="paymentState.clientSecret"
:order-type="paymentState.orderType || undefined"
:publishable-key="checkout.stripe_publishable_key" :publishable-key="checkout.stripe_publishable_key"
:pay-amount="paymentState.payAmount" :pay-amount="paymentState.payAmount"
@success="onPaymentSuccess" @success="onPaymentSuccess"
...@@ -67,20 +69,27 @@ ...@@ -67,20 +69,27 @@
@select="selectedMethod = $event" @select="selectedMethod = $event"
/> />
</div> </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="space-y-2 text-sm">
<div class="flex justify-between"> <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> <span class="text-gray-900 dark:text-white">¥{{ validAmount.toFixed(2) }}</span>
</div> </div>
<div class="flex justify-between"> <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-500 dark:text-gray-400">{{ t('payment.fee') }} ({{ feeRate }}%)</span>
<span class="text-gray-900 dark:text-white">¥{{ feeAmount.toFixed(2) }}</span> <span class="text-gray-900 dark:text-white">¥{{ feeAmount.toFixed(2) }}</span>
</div> </div>
<div class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600"> <div v-if="feeRate > 0" class="flex justify-between border-t border-gray-200 pt-2 dark:border-dark-600">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('payment.actualPay') }}</span> <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> <span class="text-lg font-bold text-primary-600 dark:text-primary-400">¥{{ totalAmount.toFixed(2) }}</span>
</div> </div>
<div v-if="balanceRechargeMultiplier !== 1" class="flex justify-between" :class="{ 'border-t border-gray-200 pt-2 dark:border-dark-600': feeRate <= 0 }">
<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>
<p v-if="balanceRechargeMultiplier !== 1" 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>
</div> </div>
<button :class="['btn w-full py-3 text-base font-medium', paymentButtonClass]" :disabled="!canSubmit || submitting" @click="handleSubmitRecharge"> <button :class="['btn w-full py-3 text-base font-medium', paymentButtonClass]" :disabled="!canSubmit || submitting" @click="handleSubmitRecharge">
...@@ -88,7 +97,7 @@ ...@@ -88,7 +97,7 @@
<span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span> <span class="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></span>
{{ t('common.processing') }} {{ t('common.processing') }}
</span> </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> </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"> <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> <p class="text-sm text-red-700 dark:text-red-400">{{ errorMessage }}</p>
...@@ -264,7 +273,7 @@ import { useAppStore } from '@/stores' ...@@ -264,7 +273,7 @@ import { useAppStore } from '@/stores'
import { paymentAPI } from '@/api/payment' import { paymentAPI } from '@/api/payment'
import { extractApiErrorMessage } from '@/utils/apiError' import { extractApiErrorMessage } from '@/utils/apiError'
import { isMobileDevice } from '@/utils/device' 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 AppLayout from '@/components/layout/AppLayout.vue'
import AmountInput from '@/components/payment/AmountInput.vue' import AmountInput from '@/components/payment/AmountInput.vue'
import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue' import PaymentMethodSelector from '@/components/payment/PaymentMethodSelector.vue'
...@@ -302,11 +311,21 @@ const previewImage = ref('') ...@@ -302,11 +311,21 @@ const previewImage = ref('')
// Payment phase: 'select' → 'paying' (QR/redirect) or 'stripe' (inline Stripe) // Payment phase: 'select' → 'paying' (QR/redirect) or 'stripe' (inline Stripe)
const paymentPhase = ref<'select' | 'paying' | 'stripe'>('select') 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() { function resetPayment() {
paymentPhase.value = 'select' 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() { function onPaymentDone() {
...@@ -342,7 +361,7 @@ function onStripeRedirect(orderId: number, payUrl: string) { ...@@ -342,7 +361,7 @@ function onStripeRedirect(orderId: number, payUrl: string) {
// All checkout data from single API call // All checkout data from single API call
const checkout = ref<CheckoutInfoResponse>({ const checkout = ref<CheckoutInfoResponse>({
methods: {}, global_min: 0, global_max: 0, 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, recharge_fee_rate: 0, help_text: '', help_image_url: '', stripe_publishable_key: '',
}) })
const tabs = computed(() => { const tabs = computed(() => {
...@@ -354,6 +373,11 @@ const tabs = computed(() => { ...@@ -354,6 +373,11 @@ const tabs = computed(() => {
const enabledMethods = computed(() => Object.keys(checkout.value.methods)) const enabledMethods = computed(() => Object.keys(checkout.value.methods))
const validAmount = computed(() => amount.value ?? 0) 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+ // Adaptive grid: center single card, 2-col for 2 plans, 3-col for 3+
const planGridClass = computed(() => { const planGridClass = computed(() => {
...@@ -390,7 +414,7 @@ const methodOptions = computed<PaymentMethodOption[]>(() => ...@@ -390,7 +414,7 @@ const methodOptions = computed<PaymentMethodOption[]>(() =>
}) })
) )
const feeRate = computed(() => selectedLimit.value?.fee_rate ?? 0) const feeRate = computed(() => checkout.value?.recharge_fee_rate ?? 0)
const feeAmount = computed(() => const feeAmount = computed(() =>
feeRate.value > 0 && validAmount.value > 0 feeRate.value > 0 && validAmount.value > 0
? Math.ceil(((validAmount.value * feeRate.value) / 100) * 100) / 100 ? Math.ceil(((validAmount.value * feeRate.value) / 100) * 100) / 100
...@@ -518,7 +542,7 @@ async function confirmSubscribe() { ...@@ -518,7 +542,7 @@ async function confirmSubscribe() {
await createOrder(selectedPlan.value.price, 'subscription', selectedPlan.value.id) 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 submitting.value = true
errorMessage.value = '' errorMessage.value = ''
try { try {
...@@ -537,7 +561,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb ...@@ -537,7 +561,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
if (result.client_secret) { if (result.client_secret) {
// Stripe: show Payment Element inline (user picks method → confirms → redirect if needed) // Stripe: show Payment Element inline (user picks method → confirms → redirect if needed)
paymentState.value = { 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: '', paymentType: selectedMethod.value, payUrl: '',
clientSecret: result.client_secret, payAmount: result.pay_amount, clientSecret: result.client_secret, payAmount: result.pay_amount,
orderType, orderType,
...@@ -546,7 +570,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb ...@@ -546,7 +570,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
} else if (isMobileDevice() && result.pay_url) { } else if (isMobileDevice() && result.pay_url) {
// Mobile + pay_url: redirect directly instead of QR/popup (mobile browsers block popups) // Mobile + pay_url: redirect directly instead of QR/popup (mobile browsers block popups)
paymentState.value = { 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, paymentType: selectedMethod.value, payUrl: result.pay_url,
clientSecret: '', payAmount: 0, clientSecret: '', payAmount: 0,
orderType, orderType,
...@@ -557,7 +581,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb ...@@ -557,7 +581,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
} else if (result.qr_code) { } else if (result.qr_code) {
// QR mode: show QR code inline // QR mode: show QR code inline
paymentState.value = { 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: '', expiresAt: result.expires_at || '', paymentType: selectedMethod.value, payUrl: '',
clientSecret: '', payAmount: 0, clientSecret: '', payAmount: 0,
orderType, orderType,
...@@ -567,7 +591,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb ...@@ -567,7 +591,7 @@ async function createOrder(orderAmount: number, orderType: string, planId?: numb
// Redirect/popup mode: open payment URL, show waiting state inline // Redirect/popup mode: open payment URL, show waiting state inline
openWindow(result.pay_url) openWindow(result.pay_url)
paymentState.value = { 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, paymentType: selectedMethod.value, payUrl: result.pay_url,
clientSecret: '', payAmount: 0, clientSecret: '', payAmount: 0,
orderType, orderType,
......
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