Files
J-Board-Lite/src/services/wallet.ts
2026-05-01 04:39:15 +10:00

553 lines
16 KiB
TypeScript

import crypto from "crypto";
import { Prisma } from "@prisma/client";
import { prisma, type DbClient } from "@/lib/prisma";
import { formatDate } from "@/lib/utils";
import { createNotification } from "@/services/notifications";
import { provisionSubscriptionWithDb } from "@/services/provision";
import { getPaymentProviderName } from "@/services/payment/catalog";
import { getPlanAvailability, type PlanAvailability } from "@/services/plan-availability";
const MONEY_SCALE = 100;
function toMoneyDecimal(value: number | string | Prisma.Decimal) {
const amount = value instanceof Prisma.Decimal ? value.toNumber() : Number(value);
if (!Number.isFinite(amount) || amount <= 0) {
throw new Error("金额必须大于 0");
}
return new Prisma.Decimal(Math.round(amount * MONEY_SCALE) / MONEY_SCALE);
}
function generateRedeemCode(prefix = "JB") {
const random = crypto.randomUUID().replace(/-/g, "").slice(0, 18).toUpperCase();
return `${prefix}-${random.slice(0, 6)}-${random.slice(6, 12)}-${random.slice(12, 18)}`;
}
async function createUniqueRedeemCode(db: DbClient, prefix?: string) {
for (let index = 0; index < 8; index += 1) {
const code = generateRedeemCode(prefix);
const existing = await db.rechargeCard.findUnique({
where: { code },
select: { id: true },
});
if (!existing) return code;
}
throw new Error("充值卡卡密生成失败,请重试");
}
export async function getOrCreateWallet(db: DbClient, userId: string) {
return db.walletAccount.upsert({
where: { userId },
update: {},
create: { userId, balance: 0 },
});
}
export async function getWalletBalance(userId: string) {
const wallet = await getOrCreateWallet(prisma, userId);
return Number(wallet.balance);
}
export async function creditWallet(
db: DbClient,
input: {
userId: string;
amount: number | string | Prisma.Decimal;
type: "BALANCE_RECHARGE" | "CARD_REDEEM" | "ADMIN_ADJUST" | "REFUND" | "SUBSCRIPTION_TRANSFER_REFUND";
description?: string;
orderId?: string | null;
rechargeOrderId?: string | null;
rechargeCardId?: string | null;
metadata?: Prisma.InputJsonValue;
},
) {
const amount = toMoneyDecimal(input.amount);
await getOrCreateWallet(db, input.userId);
const wallet = await db.walletAccount.update({
where: { userId: input.userId },
data: { balance: { increment: amount } },
});
await db.walletTransaction.create({
data: {
walletId: wallet.id,
userId: input.userId,
type: input.type,
amount,
balanceAfter: wallet.balance,
description: input.description ?? null,
orderId: input.orderId ?? null,
rechargeOrderId: input.rechargeOrderId ?? null,
rechargeCardId: input.rechargeCardId ?? null,
metadata: input.metadata ?? undefined,
},
});
return wallet;
}
export async function debitWallet(
db: DbClient,
input: {
userId: string;
amount: number | string | Prisma.Decimal;
type: "BALANCE_PAYMENT" | "SUBSCRIPTION_TRANSFER_FEE";
description?: string;
orderId?: string | null;
metadata?: Prisma.InputJsonValue;
},
) {
const amount = toMoneyDecimal(input.amount);
await getOrCreateWallet(db, input.userId);
const claimed = await db.walletAccount.updateMany({
where: {
userId: input.userId,
balance: { gte: amount },
},
data: { balance: { decrement: amount } },
});
if (claimed.count === 0) {
throw new Error("余额不足,请先充值后再操作");
}
const wallet = await db.walletAccount.findUniqueOrThrow({
where: { userId: input.userId },
});
await db.walletTransaction.create({
data: {
walletId: wallet.id,
userId: input.userId,
type: input.type,
amount: amount.negated(),
balanceAfter: wallet.balance,
description: input.description ?? "余额扣费",
orderId: input.orderId ?? null,
metadata: input.metadata ?? undefined,
},
});
return wallet;
}
export async function debitWalletForOrder(
db: DbClient,
input: {
userId: string;
orderId: string;
amount: number | string | Prisma.Decimal;
description?: string;
},
) {
const amount = toMoneyDecimal(input.amount);
return debitWallet(db, {
userId: input.userId,
orderId: input.orderId,
amount,
type: "BALANCE_PAYMENT",
description: input.description ?? "余额支付订单",
});
}
type PlanCardSnapshot = {
type: "PROXY" | "STREAMING";
nodeId: string | null;
inboundId: string | null;
fixedTrafficGb: number | null;
minTrafficGb: number | null;
totalTrafficGb: number | null;
inboundOptions?: Array<{ inboundId: string; inbound: { isActive: boolean; serverId: string } }>;
};
function resolvePlanCardConfig(card: {
selectedInboundId: string | null;
trafficGb: number | null;
plan: PlanCardSnapshot;
}) {
const plan = card.plan;
if (plan.type === "STREAMING") {
return { selectedInboundId: null, trafficGb: null };
}
const selectedInboundId =
card.selectedInboundId
?? plan.inboundId
?? card.plan.inboundOptions?.find(
(item) => item.inbound.isActive && (!plan.nodeId || item.inbound.serverId === plan.nodeId),
)?.inboundId
?? null;
const trafficGb =
card.trafficGb
?? plan.fixedTrafficGb
?? plan.minTrafficGb
?? plan.totalTrafficGb
?? null;
if (!selectedInboundId) {
throw new Error("套餐充值卡缺少可用线路,无法自动开通代理套餐");
}
if (!trafficGb || trafficGb <= 0) {
throw new Error("套餐充值卡缺少可用流量配置");
}
return { selectedInboundId, trafficGb };
}
function getPlanCardGenerationLimit(planType: "PROXY" | "STREAMING", availability: PlanAvailability) {
const limits: number[] = [];
if (availability.remainingByPlanLimit != null) {
limits.push(availability.remainingByPlanLimit);
}
if (planType === "STREAMING" && availability.remainingByServiceCapacity != null) {
limits.push(availability.remainingByServiceCapacity);
}
return limits.length > 0 ? Math.min(...limits) : null;
}
function getUnavailableRechargeCardMessage(card: {
status: "UNUSED" | "REDEEMED" | "EXPIRED" | "DISABLED";
redeemedAt: Date | null;
expiresAt: Date | null;
}) {
if (card.status === "REDEEMED") {
return card.redeemedAt
? `这张充值卡已在 ${formatDate(card.redeemedAt)} 兑换,不能重复使用。`
: "这张充值卡已兑换,不能重复使用。";
}
if (card.status === "EXPIRED") {
return card.expiresAt
? `这张充值卡已于 ${formatDate(card.expiresAt)} 过期。`
: "这张充值卡已过期。";
}
if (card.status === "DISABLED") {
return "这张充值卡已停用,请联系管理员处理。";
}
return "这张充值卡当前不可兑换。";
}
export type RedeemRechargeCardResult =
| {
type: "BALANCE";
amount: number;
balanceAfter: number;
}
| {
type: "PLAN";
planName: string;
};
export async function payOrderWithWallet(orderId: string, userId: string) {
const tradeNo = `BAL-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
return prisma.$transaction(async (tx) => {
const config = await tx.paymentConfig.findUnique({
where: { provider: "balance" },
select: { enabled: true },
});
if (config && !config.enabled) {
throw new Error("余额支付当前已关闭");
}
const order = await tx.order.findUnique({
where: { id: orderId },
include: { plan: true, user: true },
});
if (!order || order.userId !== userId) {
throw new Error("订单不存在");
}
if (order.status !== "PENDING") {
throw new Error("这笔订单已经不在待支付状态");
}
const amount = toMoneyDecimal(order.amount);
await debitWalletForOrder(tx, {
userId,
orderId,
amount,
description: `余额支付 ${order.plan.name}`,
});
const paidOrder = await tx.order.update({
where: { id: orderId },
data: {
status: "PAID",
paymentMethod: "balance",
paymentRef: tradeNo,
paymentUrl: null,
tradeNo,
expireAt: null,
note: null,
},
include: { plan: true, user: true },
});
const affectedNodeIds = await provisionSubscriptionWithDb(paidOrder, tx);
return { tradeNo, affectedNodeIds };
});
}
export async function createWalletRechargeOrder(userId: string, amountValue: number) {
const amount = toMoneyDecimal(amountValue);
return prisma.walletRechargeOrder.create({
data: { userId, amount },
});
}
export async function processWalletRechargeSuccess(
tradeNo: string,
paidAmount: number,
paymentRef?: string,
) {
if (!Number.isFinite(paidAmount) || paidAmount <= 0) {
return { processed: false, finalStatus: null as null | "PENDING" | "PAID" };
}
const rechargeOrder = await prisma.walletRechargeOrder.findUnique({
where: { tradeNo },
});
if (!rechargeOrder) {
return { processed: false, finalStatus: null as null | "PENDING" | "PAID" };
}
const expectedAmount = Number(rechargeOrder.amount);
if (Math.abs(expectedAmount - paidAmount) > 0.01) {
throw new Error("支付金额与充值金额不一致");
}
return prisma.$transaction(async (tx) => {
const claimed = await tx.walletRechargeOrder.updateMany({
where: { id: rechargeOrder.id, status: "PENDING" },
data: {
status: "PAID",
paymentRef: paymentRef ?? rechargeOrder.paymentRef,
note: null,
},
});
if (claimed.count === 0) {
const current = await tx.walletRechargeOrder.findUnique({
where: { id: rechargeOrder.id },
select: { status: true },
});
return { processed: false, finalStatus: current?.status ?? null };
}
await creditWallet(tx, {
userId: rechargeOrder.userId,
amount: rechargeOrder.amount,
type: "BALANCE_RECHARGE",
description: `${getPaymentProviderName(rechargeOrder.paymentMethod ?? "")} 余额充值`,
rechargeOrderId: rechargeOrder.id,
metadata: { tradeNo },
});
await createNotification(
{
userId: rechargeOrder.userId,
type: "ORDER",
level: "SUCCESS",
title: "余额充值成功",
body: `已充值 ¥${expectedAmount.toFixed(2)} 到账户余额。`,
link: "/wallet",
dedupeKey: `wallet-recharge:${rechargeOrder.id}`,
},
tx,
);
return { processed: true, finalStatus: "PAID" as const };
});
}
export async function createRechargeCards(input: {
createdById: string;
type: "BALANCE" | "PLAN";
quantity: number;
balanceAmount?: number;
planId?: string;
batchName?: string | null;
expiresAt?: Date | null;
}) {
const quantity = Math.min(Math.max(input.quantity, 1), 200);
return prisma.$transaction(async (tx) => {
let plan:
| (NonNullable<Awaited<ReturnType<typeof tx.subscriptionPlan.findUnique>>> & {
inboundOptions: Array<{ inboundId: string; inbound: { isActive: boolean; serverId: string } }>;
})
| null = null;
if (input.type === "PLAN") {
if (!input.planId) throw new Error("请选择套餐");
plan = await tx.subscriptionPlan.findUnique({
where: { id: input.planId },
include: {
inboundOptions: {
include: { inbound: { select: { isActive: true, serverId: true } } },
},
},
});
if (!plan) throw new Error("套餐不存在");
if (!plan.isActive) throw new Error("只能为上架中的套餐生成兑换卡");
const availability = await getPlanAvailability(plan, { db: tx });
const remaining = getPlanCardGenerationLimit(plan.type, availability);
if (remaining != null && quantity > remaining) {
throw new Error(`套餐剩余库存不足,当前最多可生成 ${remaining}`);
}
}
const cards = [];
for (let index = 0; index < quantity; index += 1) {
const code = await createUniqueRedeemCode(tx, input.type === "PLAN" ? "JP" : "JB");
cards.push(
await tx.rechargeCard.create({
data: {
code,
type: input.type,
balanceAmount:
input.type === "BALANCE" ? toMoneyDecimal(input.balanceAmount ?? 0) : null,
planId: input.type === "PLAN" ? input.planId! : null,
trafficGb: input.type === "PLAN" && plan?.type === "PROXY"
? plan.fixedTrafficGb ?? plan.minTrafficGb ?? plan.totalTrafficGb
: null,
selectedInboundId: input.type === "PLAN" && plan?.type === "PROXY"
? plan.inboundId
?? plan.inboundOptions.find(
(item) => item.inbound.isActive && (!plan?.nodeId || item.inbound.serverId === plan.nodeId),
)?.inboundId
?? null
: null,
batchName: input.batchName || null,
expiresAt: input.expiresAt ?? null,
createdById: input.createdById,
},
}),
);
}
return cards;
});
}
export async function redeemRechargeCard(userId: string, rawCode: string): Promise<RedeemRechargeCardResult> {
const code = rawCode.trim().toUpperCase();
if (!code) throw new Error("请输入充值卡卡密");
return prisma.$transaction(async (tx) => {
const redeemedAt = new Date();
const card = await tx.rechargeCard.findUnique({
where: { code },
include: {
plan: {
include: {
inboundOptions: {
include: { inbound: { select: { isActive: true, serverId: true } } },
},
},
},
},
});
if (!card) throw new Error("充值卡不存在");
if (card.status !== "UNUSED") throw new Error(getUnavailableRechargeCardMessage(card));
if (card.expiresAt && card.expiresAt < redeemedAt) {
await tx.rechargeCard.updateMany({
where: { id: card.id, status: "UNUSED" },
data: { status: "EXPIRED" },
});
throw new Error(getUnavailableRechargeCardMessage({ ...card, status: "EXPIRED" }));
}
const claimed = await tx.rechargeCard.updateMany({
where: { id: card.id, status: "UNUSED" },
data: {
status: "REDEEMED",
redeemedById: userId,
redeemedAt,
},
});
if (claimed.count === 0) {
throw new Error("这张充值卡刚刚已被兑换,请刷新后查看最新状态。");
}
let result: RedeemRechargeCardResult;
if (card.type === "BALANCE") {
if (!card.balanceAmount || Number(card.balanceAmount) <= 0) {
throw new Error("余额充值卡金额无效");
}
const wallet = await creditWallet(tx, {
userId,
amount: card.balanceAmount,
type: "CARD_REDEEM",
description: "兑换余额充值卡",
rechargeCardId: card.id,
});
result = {
type: "BALANCE",
amount: Number(card.balanceAmount),
balanceAfter: Number(wallet.balance),
};
} else {
if (!card.plan) throw new Error("套餐充值卡绑定的套餐不存在");
const { selectedInboundId, trafficGb } = resolvePlanCardConfig({
selectedInboundId: card.selectedInboundId,
trafficGb: card.trafficGb,
plan: card.plan,
});
const order = await tx.order.create({
data: {
userId,
planId: card.plan.id,
kind: "NEW_PURCHASE",
selectedInboundId,
trafficGb,
amount: 0,
subtotalAmount: 0,
discountAmount: 0,
status: "PAID",
paymentMethod: "recharge_card",
paymentRef: card.code,
tradeNo: `CARD-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`,
},
include: { plan: true, user: true },
});
await provisionSubscriptionWithDb(order, tx);
result = { type: "PLAN", planName: card.plan.name };
}
await createNotification(
{
userId,
type: "ORDER",
level: "SUCCESS",
title: card.type === "BALANCE" ? "余额充值卡兑换成功" : "套餐充值卡兑换成功",
body: card.type === "BALANCE"
? `已充值 ¥${Number(card.balanceAmount).toFixed(2)} 到账户余额。`
: `${card.plan?.name ?? "套餐"} 已激活。`,
link: card.type === "BALANCE" ? "/wallet" : "/subscriptions",
dedupeKey: `recharge-card-redeemed:${card.id}`,
},
tx,
);
return result;
});
}
export async function expireOldRechargeCards() {
await prisma.rechargeCard.updateMany({
where: {
status: "UNUSED",
expiresAt: { lt: new Date() },
},
data: { status: "EXPIRED" },
});
}