feat: 支付手续费功能

- 支持提供商级别和渠道级别手续费率配置(FEE_RATE_PROVIDER_* / FEE_RATE_*)
- 用户多付手续费,到账金额不变(充值 ¥100 + 1.6% = 实付 ¥101.60)
- 前端显示手续费明细和实付金额
- 退款时按实付金额退款,余额扣减到账金额
This commit is contained in:
erio
2026-03-03 22:00:44 +08:00
parent 1a44e94bb5
commit 5be0616e78
14 changed files with 197 additions and 13 deletions

View File

@@ -8,6 +8,8 @@ export interface MethodLimitInfo {
remaining: number | null;
/** 单笔限额0 = 使用全局 maxAmount */
singleMax?: number;
/** 手续费率百分比0 = 无手续费 */
feeRate?: number;
}
interface PaymentFormProps {
@@ -75,6 +77,13 @@ export default function PaymentForm({
const isMethodAvailable = !methodLimits || (methodLimits[paymentType]?.available !== false);
const methodSingleMax = methodLimits?.[paymentType]?.singleMax;
const effectiveMax = (methodSingleMax !== undefined && methodSingleMax > 0) ? methodSingleMax : maxAmount;
const feeRate = methodLimits?.[paymentType]?.feeRate ?? 0;
const feeAmount = feeRate > 0 && selectedAmount > 0
? Math.ceil(selectedAmount * feeRate / 100 * 100) / 100
: 0;
const payAmount = feeRate > 0 && selectedAmount > 0
? Math.round((selectedAmount + feeAmount) * 100) / 100
: selectedAmount;
const isValid = selectedAmount >= minAmount && selectedAmount <= effectiveMax && hasValidCentPrecision(selectedAmount) && isMethodAvailable;
const handleSubmit = async (e: React.FormEvent) => {
@@ -277,6 +286,32 @@ export default function PaymentForm({
})()}
</div>
{/* Fee Detail */}
{feeRate > 0 && selectedAmount > 0 && (
<div
className={[
'rounded-xl border px-4 py-3 text-sm',
dark ? 'border-slate-700 bg-slate-800/60 text-slate-300' : 'border-slate-200 bg-slate-50 text-slate-600',
].join(' ')}
>
<div className="flex items-center justify-between">
<span></span>
<span>¥{selectedAmount.toFixed(2)}</span>
</div>
<div className="flex items-center justify-between mt-1">
<span>{feeRate}%</span>
<span>¥{feeAmount.toFixed(2)}</span>
</div>
<div className={[
'flex items-center justify-between mt-1.5 pt-1.5 border-t font-medium',
dark ? 'border-slate-700 text-slate-100' : 'border-slate-200 text-slate-900',
].join(' ')}>
<span></span>
<span>¥{payAmount.toFixed(2)}</span>
</div>
</div>
)}
{/* Submit */}
<button
type="submit"
@@ -291,7 +326,7 @@ export default function PaymentForm({
: 'cursor-not-allowed bg-gray-300'
}`}
>
{loading ? '处理中...' : `立即充值 ¥${selectedAmount || 0}`}
{loading ? '处理中...' : `立即充值 ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`}
</button>
</form>
);

View File

@@ -11,6 +11,7 @@ interface PaymentQRCodeProps {
checkoutUrl?: string | null;
paymentType?: 'alipay' | 'wxpay' | 'stripe';
amount: number;
payAmount?: number;
expiresAt: string;
onStatusChange: (status: string) => void;
onBack: () => void;
@@ -42,11 +43,14 @@ export default function PaymentQRCode({
checkoutUrl,
paymentType,
amount,
payAmount: payAmountProp,
expiresAt,
onStatusChange,
onBack,
dark = false,
}: PaymentQRCodeProps) {
const displayAmount = payAmountProp ?? amount;
const hasFeeDiff = payAmountProp !== undefined && payAmountProp !== amount;
const [timeLeft, setTimeLeft] = useState('');
const [expired, setExpired] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState('');
@@ -196,7 +200,12 @@ export default function PaymentQRCode({
return (
<div className="flex flex-col items-center space-y-4">
<div className="text-center">
<div className="text-4xl font-bold text-blue-600">{'\u00A5'}{amount.toFixed(2)}</div>
<div className="text-4xl font-bold text-blue-600">{'\u00A5'}{displayAmount.toFixed(2)}</div>
{hasFeeDiff && (
<div className={['mt-1 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
¥{amount.toFixed(2)}
</div>
)}
<div className={`mt-1 text-sm ${expired ? 'text-red-500' : dark ? 'text-slate-400' : 'text-gray-500'}`}>
{expired ? TEXT_EXPIRED : `${TEXT_REMAINING}: ${timeLeft}`}
</div>