feat: 支付手续费功能
- 支持提供商级别和渠道级别手续费率配置(FEE_RATE_PROVIDER_* / FEE_RATE_*) - 用户多付手续费,到账金额不变(充值 ¥100 + 1.6% = 实付 ¥101.60) - 前端显示手续费明细和实付金额 - 退款时按实付金额退款,余额扣减到账金额
This commit is contained in:
38
src/lib/order/fee.ts
Normal file
38
src/lib/order/fee.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
|
||||
/**
|
||||
* 获取指定支付渠道的手续费率(百分比)。
|
||||
* 优先级:FEE_RATE_{TYPE} > FEE_RATE_PROVIDER_{KEY} > 0
|
||||
*/
|
||||
export function getMethodFeeRate(paymentType: string): number {
|
||||
// 渠道级别:FEE_RATE_ALIPAY / FEE_RATE_WXPAY / FEE_RATE_STRIPE
|
||||
const methodRaw = process.env[`FEE_RATE_${paymentType.toUpperCase()}`];
|
||||
if (methodRaw !== undefined && methodRaw !== '') {
|
||||
const num = Number(methodRaw);
|
||||
if (Number.isFinite(num) && num >= 0) return num;
|
||||
}
|
||||
|
||||
// 提供商级别:FEE_RATE_PROVIDER_EASYPAY / FEE_RATE_PROVIDER_STRIPE
|
||||
initPaymentProviders();
|
||||
const providerKey = paymentRegistry.getProviderKey(paymentType);
|
||||
if (providerKey) {
|
||||
const providerRaw = process.env[`FEE_RATE_PROVIDER_${providerKey.toUpperCase()}`];
|
||||
if (providerRaw !== undefined && providerRaw !== '') {
|
||||
const num = Number(providerRaw);
|
||||
if (Number.isFinite(num) && num >= 0) return num;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据到账金额和手续费率计算实付金额。
|
||||
* feeAmount = ceil(rechargeAmount * feeRate / 100 * 100) / 100 (进一制到分)
|
||||
* payAmount = rechargeAmount + feeAmount
|
||||
*/
|
||||
export function calculatePayAmount(rechargeAmount: number, feeRate: number): number {
|
||||
if (feeRate <= 0) return rechargeAmount;
|
||||
const feeAmount = Math.ceil(rechargeAmount * feeRate / 100 * 100) / 100;
|
||||
return Math.round((rechargeAmount + feeAmount) * 100) / 100;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
import { getMethodFeeRate } from './fee';
|
||||
|
||||
/**
|
||||
* 获取指定支付渠道的每日全平台限额(0 = 不限制)。
|
||||
@@ -55,6 +56,8 @@ export interface MethodLimitStatus {
|
||||
available: boolean;
|
||||
/** 单笔限额,0 = 使用全局配置 MAX_RECHARGE_AMOUNT */
|
||||
singleMax: number;
|
||||
/** 手续费率百分比,0 = 无手续费 */
|
||||
feeRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,6 +88,7 @@ export async function queryMethodLimits(
|
||||
for (const type of paymentTypes) {
|
||||
const dailyLimit = getMethodDailyLimit(type);
|
||||
const singleMax = getMethodSingleLimit(type);
|
||||
const feeRate = getMethodFeeRate(type);
|
||||
const used = usageMap[type] ?? 0;
|
||||
const remaining = dailyLimit > 0 ? Math.max(0, dailyLimit - used) : null;
|
||||
result[type] = {
|
||||
@@ -93,6 +97,7 @@ export async function queryMethodLimits(
|
||||
remaining,
|
||||
available: dailyLimit === 0 || used < dailyLimit,
|
||||
singleMax,
|
||||
feeRate,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { prisma } from '@/lib/db';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { generateRechargeCode } from './code-gen';
|
||||
import { getMethodDailyLimit } from './limits';
|
||||
import { getMethodFeeRate, calculatePayAmount } from './fee';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
import type { PaymentType, PaymentNotification } from '@/lib/payment';
|
||||
import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client';
|
||||
@@ -22,6 +23,8 @@ export interface CreateOrderInput {
|
||||
export interface CreateOrderResult {
|
||||
orderId: string;
|
||||
amount: number;
|
||||
payAmount: number;
|
||||
feeRate: number;
|
||||
status: string;
|
||||
paymentType: PaymentType;
|
||||
userName: string;
|
||||
@@ -96,6 +99,9 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
}
|
||||
}
|
||||
|
||||
const feeRate = getMethodFeeRate(input.paymentType);
|
||||
const payAmount = calculatePayAmount(input.amount, feeRate);
|
||||
|
||||
const expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000);
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
@@ -104,6 +110,8 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
userName: user.username,
|
||||
userNotes: user.notes || null,
|
||||
amount: new Prisma.Decimal(input.amount.toFixed(2)),
|
||||
payAmount: new Prisma.Decimal(payAmount.toFixed(2)),
|
||||
feeRate: feeRate > 0 ? new Prisma.Decimal(feeRate.toFixed(2)) : null,
|
||||
rechargeCode: '',
|
||||
status: 'PENDING',
|
||||
paymentType: input.paymentType,
|
||||
@@ -125,9 +133,9 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
const provider = paymentRegistry.getProvider(input.paymentType);
|
||||
const paymentResult = await provider.createPayment({
|
||||
orderId: order.id,
|
||||
amount: input.amount,
|
||||
amount: payAmount,
|
||||
paymentType: input.paymentType,
|
||||
subject: `${env.PRODUCT_NAME} ${input.amount.toFixed(2)} CNY`,
|
||||
subject: `${env.PRODUCT_NAME} ${payAmount.toFixed(2)} CNY`,
|
||||
notifyUrl: env.EASY_PAY_NOTIFY_URL || '',
|
||||
returnUrl: env.EASY_PAY_RETURN_URL || '',
|
||||
clientIp: input.clientIp,
|
||||
@@ -154,6 +162,8 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
return {
|
||||
orderId: order.id,
|
||||
amount: input.amount,
|
||||
payAmount,
|
||||
feeRate,
|
||||
status: 'PENDING',
|
||||
paymentType: input.paymentType,
|
||||
userName: user.username,
|
||||
@@ -313,10 +323,11 @@ export async function confirmPayment(input: {
|
||||
console.error(`${input.providerName} notify: non-positive amount:`, input.paidAmount);
|
||||
return false;
|
||||
}
|
||||
if (!paidAmount.equals(order.amount)) {
|
||||
const expectedAmount = order.payAmount ?? order.amount;
|
||||
if (!paidAmount.equals(expectedAmount)) {
|
||||
console.warn(
|
||||
`${input.providerName} notify: amount changed, use paid amount`,
|
||||
order.amount.toString(),
|
||||
expectedAmount.toString(),
|
||||
paidAmount.toString(),
|
||||
);
|
||||
}
|
||||
@@ -551,15 +562,16 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
throw new OrderError('INVALID_STATUS', 'Only completed orders can be refunded', 400);
|
||||
}
|
||||
|
||||
const amount = Number(order.amount);
|
||||
const rechargeAmount = Number(order.amount);
|
||||
const refundAmount = Number(order.payAmount ?? order.amount);
|
||||
|
||||
if (!input.force) {
|
||||
try {
|
||||
const user = await getUser(order.userId);
|
||||
if (user.balance < amount) {
|
||||
if (user.balance < rechargeAmount) {
|
||||
return {
|
||||
success: false,
|
||||
warning: `User balance ${user.balance} is lower than refund ${amount}`,
|
||||
warning: `User balance ${user.balance} is lower than refund ${rechargeAmount}`,
|
||||
requireForce: true,
|
||||
};
|
||||
}
|
||||
@@ -587,18 +599,18 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
await provider.refund({
|
||||
tradeNo: order.paymentTradeNo,
|
||||
orderId: order.id,
|
||||
amount,
|
||||
amount: refundAmount,
|
||||
reason: input.reason,
|
||||
});
|
||||
}
|
||||
|
||||
await subtractBalance(order.userId, amount, `sub2apipay refund order:${order.id}`, `sub2apipay:refund:${order.id}`);
|
||||
await subtractBalance(order.userId, rechargeAmount, `sub2apipay refund order:${order.id}`, `sub2apipay:refund:${order.id}`);
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: input.orderId },
|
||||
data: {
|
||||
status: 'REFUNDED',
|
||||
refundAmount: new Prisma.Decimal(amount.toFixed(2)),
|
||||
refundAmount: new Prisma.Decimal(refundAmount.toFixed(2)),
|
||||
refundReason: input.reason || null,
|
||||
refundAt: new Date(),
|
||||
forceRefund: input.force || false,
|
||||
@@ -609,7 +621,7 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
data: {
|
||||
orderId: input.orderId,
|
||||
action: 'REFUND_SUCCESS',
|
||||
detail: JSON.stringify({ amount, reason: input.reason, force: input.force }),
|
||||
detail: JSON.stringify({ rechargeAmount, refundAmount, reason: input.reason, force: input.force }),
|
||||
operator: 'admin',
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user