feat: 支付手续费功能
- 支持提供商级别和渠道级别手续费率配置(FEE_RATE_PROVIDER_* / FEE_RATE_*) - 用户多付手续费,到账金额不变(充值 ¥100 + 1.6% = 实付 ¥101.60) - 前端显示手续费明细和实付金额 - 退款时按实付金额退款,余额扣减到账金额
This commit is contained in:
65
.env.example
Normal file
65
.env.example
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# 数据库
|
||||||
|
DATABASE_URL="postgresql://sub2apipay:password@localhost:5432/sub2apipay"
|
||||||
|
|
||||||
|
# Sub2API
|
||||||
|
SUB2API_BASE_URL="https://your-sub2api-domain.com"
|
||||||
|
SUB2API_ADMIN_API_KEY="your-admin-api-key"
|
||||||
|
|
||||||
|
# ── 支付服务商(逗号分隔,决定加载哪些服务商) ───────────────────────────────
|
||||||
|
# 可选值: easypay, stripe
|
||||||
|
# 示例(仅易支付): PAYMENT_PROVIDERS=easypay
|
||||||
|
# 示例(仅 Stripe): PAYMENT_PROVIDERS=stripe
|
||||||
|
# 示例(两者都用): PAYMENT_PROVIDERS=easypay,stripe
|
||||||
|
PAYMENT_PROVIDERS=easypay
|
||||||
|
|
||||||
|
# ── 易支付配置(PAYMENT_PROVIDERS 含 easypay 时必填) ────────────────────────
|
||||||
|
EASY_PAY_PID="your-pid"
|
||||||
|
EASY_PAY_PKEY="your-pkey"
|
||||||
|
EASY_PAY_API_BASE="https://zpayz.cn"
|
||||||
|
EASY_PAY_NOTIFY_URL="https://pay.example.com/api/easy-pay/notify"
|
||||||
|
EASY_PAY_RETURN_URL="https://pay.example.com/pay/result"
|
||||||
|
# 渠道 ID(部分易支付平台需要,可选)
|
||||||
|
#EASY_PAY_CID_ALIPAY=""
|
||||||
|
#EASY_PAY_CID_WXPAY=""
|
||||||
|
|
||||||
|
# ── Stripe 配置(PAYMENT_PROVIDERS 含 stripe 时必填) ────────────────────────
|
||||||
|
#STRIPE_SECRET_KEY="sk_live_..."
|
||||||
|
#STRIPE_PUBLISHABLE_KEY="pk_live_..."
|
||||||
|
#STRIPE_WEBHOOK_SECRET="whsec_..."
|
||||||
|
|
||||||
|
# ── 启用的支付渠道(在已配置服务商支持的渠道中选择) ─────────────────────────
|
||||||
|
# 易支付支持: alipay, wxpay
|
||||||
|
# Stripe 支持: stripe
|
||||||
|
ENABLED_PAYMENT_TYPES="alipay,wxpay"
|
||||||
|
|
||||||
|
# ── 订单配置 ──────────────────────────────────────────────────────────────────
|
||||||
|
ORDER_TIMEOUT_MINUTES="5"
|
||||||
|
MIN_RECHARGE_AMOUNT="1"
|
||||||
|
MAX_RECHARGE_AMOUNT="10000"
|
||||||
|
# 每用户每日累计充值上限,0 = 不限制
|
||||||
|
MAX_DAILY_RECHARGE_AMOUNT="0"
|
||||||
|
# 各渠道全平台每日总限额,0 = 不限制(未设置则使用各服务商默认值)
|
||||||
|
#MAX_DAILY_AMOUNT_ALIPAY="0"
|
||||||
|
#MAX_DAILY_AMOUNT_WXPAY="0"
|
||||||
|
#MAX_DAILY_AMOUNT_STRIPE="0"
|
||||||
|
PRODUCT_NAME="Sub2API 余额充值"
|
||||||
|
|
||||||
|
# ── 手续费(百分比,可选) ─────────────────────────────────────────────────────
|
||||||
|
# 提供商级别(应用于该提供商下所有渠道)
|
||||||
|
#FEE_RATE_PROVIDER_EASYPAY=1.6
|
||||||
|
#FEE_RATE_PROVIDER_STRIPE=5.9
|
||||||
|
# 渠道级别(覆盖提供商级别)
|
||||||
|
#FEE_RATE_ALIPAY=
|
||||||
|
#FEE_RATE_WXPAY=
|
||||||
|
#FEE_RATE_STRIPE=
|
||||||
|
|
||||||
|
# ── 管理员 ────────────────────────────────────────────────────────────────────
|
||||||
|
ADMIN_TOKEN="your-admin-token"
|
||||||
|
|
||||||
|
# ── 应用 ──────────────────────────────────────────────────────────────────────
|
||||||
|
NEXT_PUBLIC_APP_URL="https://pay.example.com"
|
||||||
|
# iframe 允许嵌入的域名(逗号分隔)
|
||||||
|
IFRAME_ALLOW_ORIGINS="https://example.com"
|
||||||
|
# 充值页面底部帮助内容(可选)
|
||||||
|
#PAY_HELP_IMAGE_URL="https://example.com/qrcode.png"
|
||||||
|
#PAY_HELP_TEXT="如需帮助请联系客服微信:xxxxx"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "orders" ADD COLUMN "pay_amount" DECIMAL(10,2),
|
||||||
|
ADD COLUMN "fee_rate" DECIMAL(5,2);
|
||||||
@@ -13,6 +13,8 @@ model Order {
|
|||||||
userName String? @map("user_name")
|
userName String? @map("user_name")
|
||||||
userNotes String? @map("user_notes")
|
userNotes String? @map("user_notes")
|
||||||
amount Decimal @db.Decimal(10, 2)
|
amount Decimal @db.Decimal(10, 2)
|
||||||
|
payAmount Decimal? @db.Decimal(10, 2) @map("pay_amount")
|
||||||
|
feeRate Decimal? @db.Decimal(5, 2) @map("fee_rate")
|
||||||
rechargeCode String @unique @map("recharge_code")
|
rechargeCode String @unique @map("recharge_code")
|
||||||
status OrderStatus @default(PENDING)
|
status OrderStatus @default(PENDING)
|
||||||
paymentType String @map("payment_type")
|
paymentType String @map("payment_type")
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import type {
|
|||||||
|
|
||||||
class MockProvider implements PaymentProvider {
|
class MockProvider implements PaymentProvider {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
|
readonly providerKey: string;
|
||||||
readonly supportedTypes: PaymentType[];
|
readonly supportedTypes: PaymentType[];
|
||||||
|
|
||||||
constructor(name: string, types: PaymentType[]) {
|
constructor(name: string, types: PaymentType[]) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
|
this.providerKey = name;
|
||||||
this.supportedTypes = types;
|
this.supportedTypes = types;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { MethodLimitInfo } from '@/components/PaymentForm';
|
|||||||
interface OrderResult {
|
interface OrderResult {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
payAmount?: number;
|
||||||
status: string;
|
status: string;
|
||||||
paymentType: 'alipay' | 'wxpay' | 'stripe';
|
paymentType: 'alipay' | 'wxpay' | 'stripe';
|
||||||
payUrl?: string | null;
|
payUrl?: string | null;
|
||||||
@@ -255,6 +256,7 @@ function PayContent() {
|
|||||||
setOrderResult({
|
setOrderResult({
|
||||||
orderId: data.orderId,
|
orderId: data.orderId,
|
||||||
amount: data.amount,
|
amount: data.amount,
|
||||||
|
payAmount: data.payAmount,
|
||||||
status: data.status,
|
status: data.status,
|
||||||
paymentType: data.paymentType || paymentType,
|
paymentType: data.paymentType || paymentType,
|
||||||
payUrl: data.payUrl,
|
payUrl: data.payUrl,
|
||||||
@@ -410,6 +412,7 @@ function PayContent() {
|
|||||||
userName={userInfo?.username}
|
userName={userInfo?.username}
|
||||||
userBalance={userInfo?.balance}
|
userBalance={userInfo?.balance}
|
||||||
enabledPaymentTypes={config.enabledPaymentTypes}
|
enabledPaymentTypes={config.enabledPaymentTypes}
|
||||||
|
methodLimits={config.methodLimits}
|
||||||
minAmount={config.minAmount}
|
minAmount={config.minAmount}
|
||||||
maxAmount={config.maxAmount}
|
maxAmount={config.maxAmount}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
@@ -465,6 +468,7 @@ function PayContent() {
|
|||||||
checkoutUrl={orderResult.checkoutUrl}
|
checkoutUrl={orderResult.checkoutUrl}
|
||||||
paymentType={orderResult.paymentType}
|
paymentType={orderResult.paymentType}
|
||||||
amount={orderResult.amount}
|
amount={orderResult.amount}
|
||||||
|
payAmount={orderResult.payAmount}
|
||||||
expiresAt={orderResult.expiresAt}
|
expiresAt={orderResult.expiresAt}
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export interface MethodLimitInfo {
|
|||||||
remaining: number | null;
|
remaining: number | null;
|
||||||
/** 单笔限额,0 = 使用全局 maxAmount */
|
/** 单笔限额,0 = 使用全局 maxAmount */
|
||||||
singleMax?: number;
|
singleMax?: number;
|
||||||
|
/** 手续费率百分比,0 = 无手续费 */
|
||||||
|
feeRate?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PaymentFormProps {
|
interface PaymentFormProps {
|
||||||
@@ -75,6 +77,13 @@ export default function PaymentForm({
|
|||||||
const isMethodAvailable = !methodLimits || (methodLimits[paymentType]?.available !== false);
|
const isMethodAvailable = !methodLimits || (methodLimits[paymentType]?.available !== false);
|
||||||
const methodSingleMax = methodLimits?.[paymentType]?.singleMax;
|
const methodSingleMax = methodLimits?.[paymentType]?.singleMax;
|
||||||
const effectiveMax = (methodSingleMax !== undefined && methodSingleMax > 0) ? methodSingleMax : maxAmount;
|
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 isValid = selectedAmount >= minAmount && selectedAmount <= effectiveMax && hasValidCentPrecision(selectedAmount) && isMethodAvailable;
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@@ -277,6 +286,32 @@ export default function PaymentForm({
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</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 */}
|
{/* Submit */}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -291,7 +326,7 @@ export default function PaymentForm({
|
|||||||
: 'cursor-not-allowed bg-gray-300'
|
: 'cursor-not-allowed bg-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{loading ? '处理中...' : `立即充值 ¥${selectedAmount || 0}`}
|
{loading ? '处理中...' : `立即充值 ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface PaymentQRCodeProps {
|
|||||||
checkoutUrl?: string | null;
|
checkoutUrl?: string | null;
|
||||||
paymentType?: 'alipay' | 'wxpay' | 'stripe';
|
paymentType?: 'alipay' | 'wxpay' | 'stripe';
|
||||||
amount: number;
|
amount: number;
|
||||||
|
payAmount?: number;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
onStatusChange: (status: string) => void;
|
onStatusChange: (status: string) => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
@@ -42,11 +43,14 @@ export default function PaymentQRCode({
|
|||||||
checkoutUrl,
|
checkoutUrl,
|
||||||
paymentType,
|
paymentType,
|
||||||
amount,
|
amount,
|
||||||
|
payAmount: payAmountProp,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onBack,
|
onBack,
|
||||||
dark = false,
|
dark = false,
|
||||||
}: PaymentQRCodeProps) {
|
}: PaymentQRCodeProps) {
|
||||||
|
const displayAmount = payAmountProp ?? amount;
|
||||||
|
const hasFeeDiff = payAmountProp !== undefined && payAmountProp !== amount;
|
||||||
const [timeLeft, setTimeLeft] = useState('');
|
const [timeLeft, setTimeLeft] = useState('');
|
||||||
const [expired, setExpired] = useState(false);
|
const [expired, setExpired] = useState(false);
|
||||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||||
@@ -196,7 +200,12 @@ export default function PaymentQRCode({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center space-y-4">
|
<div className="flex flex-col items-center space-y-4">
|
||||||
<div className="text-center">
|
<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'}`}>
|
<div className={`mt-1 text-sm ${expired ? 'text-red-500' : dark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||||
{expired ? TEXT_EXPIRED : `${TEXT_REMAINING}: ${timeLeft}`}
|
{expired ? TEXT_EXPIRED : `${TEXT_REMAINING}: ${timeLeft}`}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { getEnv } from '@/lib/config';
|
|||||||
|
|
||||||
export class EasyPayProvider implements PaymentProvider {
|
export class EasyPayProvider implements PaymentProvider {
|
||||||
readonly name = 'easy-pay';
|
readonly name = 'easy-pay';
|
||||||
|
readonly providerKey = 'easypay';
|
||||||
readonly supportedTypes: PaymentType[] = ['alipay', 'wxpay'];
|
readonly supportedTypes: PaymentType[] = ['alipay', 'wxpay'];
|
||||||
readonly defaultLimits = {
|
readonly defaultLimits = {
|
||||||
alipay: { singleMax: 1000, dailyMax: 10000 },
|
alipay: { singleMax: 1000, dailyMax: 10000 },
|
||||||
|
|||||||
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 { prisma } from '@/lib/db';
|
||||||
import { getEnv } from '@/lib/config';
|
import { getEnv } from '@/lib/config';
|
||||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||||
|
import { getMethodFeeRate } from './fee';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取指定支付渠道的每日全平台限额(0 = 不限制)。
|
* 获取指定支付渠道的每日全平台限额(0 = 不限制)。
|
||||||
@@ -55,6 +56,8 @@ export interface MethodLimitStatus {
|
|||||||
available: boolean;
|
available: boolean;
|
||||||
/** 单笔限额,0 = 使用全局配置 MAX_RECHARGE_AMOUNT */
|
/** 单笔限额,0 = 使用全局配置 MAX_RECHARGE_AMOUNT */
|
||||||
singleMax: number;
|
singleMax: number;
|
||||||
|
/** 手续费率百分比,0 = 无手续费 */
|
||||||
|
feeRate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,6 +88,7 @@ export async function queryMethodLimits(
|
|||||||
for (const type of paymentTypes) {
|
for (const type of paymentTypes) {
|
||||||
const dailyLimit = getMethodDailyLimit(type);
|
const dailyLimit = getMethodDailyLimit(type);
|
||||||
const singleMax = getMethodSingleLimit(type);
|
const singleMax = getMethodSingleLimit(type);
|
||||||
|
const feeRate = getMethodFeeRate(type);
|
||||||
const used = usageMap[type] ?? 0;
|
const used = usageMap[type] ?? 0;
|
||||||
const remaining = dailyLimit > 0 ? Math.max(0, dailyLimit - used) : null;
|
const remaining = dailyLimit > 0 ? Math.max(0, dailyLimit - used) : null;
|
||||||
result[type] = {
|
result[type] = {
|
||||||
@@ -93,6 +97,7 @@ export async function queryMethodLimits(
|
|||||||
remaining,
|
remaining,
|
||||||
available: dailyLimit === 0 || used < dailyLimit,
|
available: dailyLimit === 0 || used < dailyLimit,
|
||||||
singleMax,
|
singleMax,
|
||||||
|
feeRate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { prisma } from '@/lib/db';
|
|||||||
import { getEnv } from '@/lib/config';
|
import { getEnv } from '@/lib/config';
|
||||||
import { generateRechargeCode } from './code-gen';
|
import { generateRechargeCode } from './code-gen';
|
||||||
import { getMethodDailyLimit } from './limits';
|
import { getMethodDailyLimit } from './limits';
|
||||||
|
import { getMethodFeeRate, calculatePayAmount } from './fee';
|
||||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||||
import type { PaymentType, PaymentNotification } from '@/lib/payment';
|
import type { PaymentType, PaymentNotification } from '@/lib/payment';
|
||||||
import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client';
|
import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client';
|
||||||
@@ -22,6 +23,8 @@ export interface CreateOrderInput {
|
|||||||
export interface CreateOrderResult {
|
export interface CreateOrderResult {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
payAmount: number;
|
||||||
|
feeRate: number;
|
||||||
status: string;
|
status: string;
|
||||||
paymentType: PaymentType;
|
paymentType: PaymentType;
|
||||||
userName: string;
|
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 expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000);
|
||||||
const order = await prisma.order.create({
|
const order = await prisma.order.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -104,6 +110,8 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
userName: user.username,
|
userName: user.username,
|
||||||
userNotes: user.notes || null,
|
userNotes: user.notes || null,
|
||||||
amount: new Prisma.Decimal(input.amount.toFixed(2)),
|
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: '',
|
rechargeCode: '',
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
paymentType: input.paymentType,
|
paymentType: input.paymentType,
|
||||||
@@ -125,9 +133,9 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
const provider = paymentRegistry.getProvider(input.paymentType);
|
const provider = paymentRegistry.getProvider(input.paymentType);
|
||||||
const paymentResult = await provider.createPayment({
|
const paymentResult = await provider.createPayment({
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
amount: input.amount,
|
amount: payAmount,
|
||||||
paymentType: input.paymentType,
|
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 || '',
|
notifyUrl: env.EASY_PAY_NOTIFY_URL || '',
|
||||||
returnUrl: env.EASY_PAY_RETURN_URL || '',
|
returnUrl: env.EASY_PAY_RETURN_URL || '',
|
||||||
clientIp: input.clientIp,
|
clientIp: input.clientIp,
|
||||||
@@ -154,6 +162,8 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
|||||||
return {
|
return {
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
amount: input.amount,
|
amount: input.amount,
|
||||||
|
payAmount,
|
||||||
|
feeRate,
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
paymentType: input.paymentType,
|
paymentType: input.paymentType,
|
||||||
userName: user.username,
|
userName: user.username,
|
||||||
@@ -313,10 +323,11 @@ export async function confirmPayment(input: {
|
|||||||
console.error(`${input.providerName} notify: non-positive amount:`, input.paidAmount);
|
console.error(`${input.providerName} notify: non-positive amount:`, input.paidAmount);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!paidAmount.equals(order.amount)) {
|
const expectedAmount = order.payAmount ?? order.amount;
|
||||||
|
if (!paidAmount.equals(expectedAmount)) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`${input.providerName} notify: amount changed, use paid amount`,
|
`${input.providerName} notify: amount changed, use paid amount`,
|
||||||
order.amount.toString(),
|
expectedAmount.toString(),
|
||||||
paidAmount.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);
|
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) {
|
if (!input.force) {
|
||||||
try {
|
try {
|
||||||
const user = await getUser(order.userId);
|
const user = await getUser(order.userId);
|
||||||
if (user.balance < amount) {
|
if (user.balance < rechargeAmount) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
warning: `User balance ${user.balance} is lower than refund ${amount}`,
|
warning: `User balance ${user.balance} is lower than refund ${rechargeAmount}`,
|
||||||
requireForce: true,
|
requireForce: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -587,18 +599,18 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
|||||||
await provider.refund({
|
await provider.refund({
|
||||||
tradeNo: order.paymentTradeNo,
|
tradeNo: order.paymentTradeNo,
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
amount,
|
amount: refundAmount,
|
||||||
reason: input.reason,
|
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({
|
await prisma.order.update({
|
||||||
where: { id: input.orderId },
|
where: { id: input.orderId },
|
||||||
data: {
|
data: {
|
||||||
status: 'REFUNDED',
|
status: 'REFUNDED',
|
||||||
refundAmount: new Prisma.Decimal(amount.toFixed(2)),
|
refundAmount: new Prisma.Decimal(refundAmount.toFixed(2)),
|
||||||
refundReason: input.reason || null,
|
refundReason: input.reason || null,
|
||||||
refundAt: new Date(),
|
refundAt: new Date(),
|
||||||
forceRefund: input.force || false,
|
forceRefund: input.force || false,
|
||||||
@@ -609,7 +621,7 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
|||||||
data: {
|
data: {
|
||||||
orderId: input.orderId,
|
orderId: input.orderId,
|
||||||
action: 'REFUND_SUCCESS',
|
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',
|
operator: 'admin',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export class PaymentProviderRegistry {
|
|||||||
const provider = this.providers.get(type as PaymentType);
|
const provider = this.providers.get(type as PaymentType);
|
||||||
return provider?.defaultLimits?.[type];
|
return provider?.defaultLimits?.[type];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取指定渠道对应的提供商 key(如 'easypay'、'stripe') */
|
||||||
|
getProviderKey(type: string): string | undefined {
|
||||||
|
const provider = this.providers.get(type as PaymentType);
|
||||||
|
return provider?.providerKey;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const paymentRegistry = new PaymentProviderRegistry();
|
export const paymentRegistry = new PaymentProviderRegistry();
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export interface MethodDefaultLimits {
|
|||||||
/** Common interface that all payment providers must implement */
|
/** Common interface that all payment providers must implement */
|
||||||
export interface PaymentProvider {
|
export interface PaymentProvider {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
|
readonly providerKey: string;
|
||||||
readonly supportedTypes: PaymentType[];
|
readonly supportedTypes: PaymentType[];
|
||||||
/** 各渠道默认限额,key 为 PaymentType(如 'alipay'),可被环境变量覆盖 */
|
/** 各渠道默认限额,key 为 PaymentType(如 'alipay'),可被环境变量覆盖 */
|
||||||
readonly defaultLimits?: Record<string, MethodDefaultLimits>;
|
readonly defaultLimits?: Record<string, MethodDefaultLimits>;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
|
|
||||||
export class StripeProvider implements PaymentProvider {
|
export class StripeProvider implements PaymentProvider {
|
||||||
readonly name = 'stripe';
|
readonly name = 'stripe';
|
||||||
|
readonly providerKey = 'stripe';
|
||||||
readonly supportedTypes: PaymentType[] = ['stripe'];
|
readonly supportedTypes: PaymentType[] = ['stripe'];
|
||||||
readonly defaultLimits = {
|
readonly defaultLimits = {
|
||||||
stripe: { singleMax: 0, dailyMax: 0 }, // 0 = unlimited
|
stripe: { singleMax: 0, dailyMax: 0 }, // 0 = unlimited
|
||||||
|
|||||||
Reference in New Issue
Block a user