重构
This commit is contained in:
@@ -3,7 +3,8 @@ import { Prisma } from '@prisma/client';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { resolveTranslationFallback } from '@thebet365/shared';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../ledger/wallet.service';
|
||||
import { FundsPostingService } from '../ledger/funds-posting.service';
|
||||
import { AgentCreditService } from '../agent/agent-credit.service';
|
||||
import { appBadRequest } from '../../shared/common/app-error';
|
||||
import { deleteUploadFileByUrl } from '../../shared/uploads/delete-upload-file';
|
||||
|
||||
@@ -20,7 +21,8 @@ const DEPOSIT_REVOKE_WINDOW_MS = 5 * 60 * 1000;
|
||||
export class DepositService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
private funds: FundsPostingService,
|
||||
private credit: AgentCreditService,
|
||||
) {}
|
||||
|
||||
// ============ Payment Methods (Admin CRUD) ============
|
||||
@@ -413,6 +415,7 @@ export class DepositService {
|
||||
if (order.status !== 'PENDING') throw appBadRequest('ORDER_NOT_PENDING');
|
||||
|
||||
const creditAmount = approvedAmount != null ? new Decimal(approvedAmount) : order.amount;
|
||||
const parentAgentId = await this.assertParentCreditForDeposit(tx, order.playerId, creditAmount);
|
||||
|
||||
await tx.depositOrder.update({
|
||||
where: { id: orderId },
|
||||
@@ -426,14 +429,19 @@ export class DepositService {
|
||||
});
|
||||
|
||||
// Credit player wallet
|
||||
await this.wallet.deposit(
|
||||
order.playerId,
|
||||
creditAmount,
|
||||
await this.funds.deposit({
|
||||
userId: order.playerId,
|
||||
amount: creditAmount,
|
||||
operatorId,
|
||||
remark ?? `Deposit order ${order.orderNo}`,
|
||||
order.orderNo,
|
||||
'PLAYER_DEPOSIT',
|
||||
);
|
||||
remark: remark ?? `Deposit order ${order.orderNo}`,
|
||||
referenceId: order.orderNo,
|
||||
transactionType: 'PLAYER_DEPOSIT',
|
||||
tx,
|
||||
businessKey: `deposit:${order.orderNo}:approve`,
|
||||
});
|
||||
if (parentAgentId) {
|
||||
await this.credit.recalculateUsedCredit(parentAgentId, tx);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
@@ -469,86 +477,76 @@ export class DepositService {
|
||||
remark: string,
|
||||
) {
|
||||
const credit = order.approvedAmount ?? order.amount;
|
||||
await this.wallet.withdraw(
|
||||
order.playerId,
|
||||
credit,
|
||||
await this.funds.withdraw({
|
||||
userId: order.playerId,
|
||||
amount: credit,
|
||||
operatorId,
|
||||
remark,
|
||||
order.orderNo,
|
||||
'PLAYER_DEPOSIT_REVERSAL',
|
||||
);
|
||||
referenceId: order.orderNo,
|
||||
transactionType: 'PLAYER_DEPOSIT_REVERSAL',
|
||||
businessKey: `deposit:${order.orderNo}:reverse`,
|
||||
});
|
||||
}
|
||||
|
||||
/** 已拒绝:恢复待审核;已通过(5 分钟内):作废期间待结算注单并扣回入账 */
|
||||
/** 已拒绝:恢复待审核;已通过(5 分钟内且未产生下注):扣回入账并恢复待审核 */
|
||||
async reopenDepositOrderForReview(orderId: bigint, operatorId: bigint) {
|
||||
const order = await this.prisma.depositOrder.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
|
||||
if (order.status === 'PENDING') throw appBadRequest('ORDER_ALREADY_PENDING');
|
||||
|
||||
if (order.status === 'REJECTED') {
|
||||
await this.prisma.depositOrder.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: 'PENDING',
|
||||
approvedAmount: null,
|
||||
reviewerId: null,
|
||||
reviewedAt: null,
|
||||
rejectReason: null,
|
||||
remark: null,
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (order.status !== 'APPROVED') {
|
||||
throw appBadRequest('ORDER_NOT_APPROVED');
|
||||
}
|
||||
|
||||
if (!order.reviewedAt || Date.now() - order.reviewedAt.getTime() > DEPOSIT_REVOKE_WINDOW_MS) {
|
||||
throw appBadRequest('DEPOSIT_REVOKE_WINDOW_EXPIRED');
|
||||
}
|
||||
|
||||
const reviewedAt = order.reviewedAt;
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const order = await this.lockDepositOrder(tx, orderId);
|
||||
if (order.status === 'PENDING') throw appBadRequest('ORDER_ALREADY_PENDING');
|
||||
|
||||
if (order.status === 'REJECTED') {
|
||||
await tx.depositOrder.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: 'PENDING',
|
||||
approvedAmount: null,
|
||||
reviewerId: null,
|
||||
reviewedAt: null,
|
||||
rejectReason: null,
|
||||
remark: null,
|
||||
},
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (order.status !== 'APPROVED') {
|
||||
throw appBadRequest('ORDER_NOT_APPROVED');
|
||||
}
|
||||
|
||||
if (!order.reviewedAt || Date.now() - order.reviewedAt.getTime() > DEPOSIT_REVOKE_WINDOW_MS) {
|
||||
throw appBadRequest('DEPOSIT_REVOKE_WINDOW_EXPIRED');
|
||||
}
|
||||
|
||||
await this.lockPlayerWallet(tx, order.playerId);
|
||||
const reviewedAt = order.reviewedAt;
|
||||
const betsAfterReview = await tx.bet.findMany({
|
||||
where: {
|
||||
userId: order.playerId,
|
||||
placedAt: { gte: reviewedAt },
|
||||
status: { not: 'VOID' },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const settled = betsAfterReview.filter((b) => b.status !== 'PENDING');
|
||||
if (settled.length > 0) {
|
||||
if (betsAfterReview.length > 0) {
|
||||
throw appBadRequest('DEPOSIT_REVOKE_SETTLED_BETS');
|
||||
}
|
||||
|
||||
for (const bet of betsAfterReview) {
|
||||
await this.wallet.settleBet(
|
||||
bet.userId,
|
||||
bet.stake,
|
||||
bet.stake,
|
||||
bet.betNo,
|
||||
'VOID',
|
||||
tx,
|
||||
);
|
||||
await tx.bet.update({
|
||||
where: { id: bet.id },
|
||||
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
const credit = order.approvedAmount ?? order.amount;
|
||||
await this.wallet.withdraw(
|
||||
order.playerId,
|
||||
credit,
|
||||
const parentAgentId = await this.findPlayerParentAgentId(tx, order.playerId);
|
||||
await this.funds.withdraw({
|
||||
userId: order.playerId,
|
||||
amount: credit,
|
||||
operatorId,
|
||||
`Revoke approved deposit ${order.orderNo}`,
|
||||
order.orderNo,
|
||||
'PLAYER_DEPOSIT_REVERSAL',
|
||||
remark: `Revoke approved deposit ${order.orderNo}`,
|
||||
referenceId: order.orderNo,
|
||||
transactionType: 'PLAYER_DEPOSIT_REVERSAL',
|
||||
tx,
|
||||
);
|
||||
businessKey: `deposit:${order.orderNo}:reopen-reverse`,
|
||||
});
|
||||
if (parentAgentId) {
|
||||
await this.credit.recalculateUsedCredit(parentAgentId, tx);
|
||||
}
|
||||
|
||||
await tx.depositOrder.update({
|
||||
where: { id: orderId },
|
||||
@@ -562,14 +560,17 @@ export class DepositService {
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, voidedBets: betsAfterReview.length };
|
||||
return { success: true, voidedBets: 0 };
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除充值订单记录及截图(不调整玩家钱包或注单,与撤销无关) */
|
||||
/** 删除充值订单记录及截图,仅允许删除未资金化订单。 */
|
||||
async deleteDepositOrder(orderId: bigint, _operatorId: bigint) {
|
||||
const order = await this.prisma.depositOrder.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
|
||||
if (order.status === 'APPROVED') {
|
||||
throw appBadRequest('DEPOSIT_ORDER_FUNDED_DELETE_FORBIDDEN');
|
||||
}
|
||||
|
||||
const screenshotUrl = order.screenshotUrl;
|
||||
await this.prisma.depositOrder.delete({ where: { id: orderId } });
|
||||
@@ -577,4 +578,51 @@ export class DepositService {
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async lockDepositOrder(tx: Prisma.TransactionClient, orderId: bigint) {
|
||||
const rows = await tx.$queryRaw<Array<{ id: bigint }>>`
|
||||
SELECT id FROM deposit_orders WHERE id = ${orderId} FOR UPDATE
|
||||
`;
|
||||
if (!rows.length) throw appBadRequest('ORDER_NOT_FOUND');
|
||||
const order = await tx.depositOrder.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
|
||||
return order;
|
||||
}
|
||||
|
||||
private async lockPlayerWallet(tx: Prisma.TransactionClient, playerId: bigint) {
|
||||
const rows = await tx.$queryRaw<Array<{ id: bigint }>>`
|
||||
SELECT id FROM wallets WHERE user_id = ${playerId} FOR UPDATE
|
||||
`;
|
||||
if (!rows.length) throw appBadRequest('WALLET_NOT_FOUND');
|
||||
}
|
||||
|
||||
private async findPlayerParentAgentId(tx: Prisma.TransactionClient, playerId: bigint) {
|
||||
const player = await tx.user.findFirst({
|
||||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||||
select: { parentId: true },
|
||||
});
|
||||
return player?.parentId ?? null;
|
||||
}
|
||||
|
||||
private async assertParentCreditForDeposit(
|
||||
tx: Prisma.TransactionClient,
|
||||
playerId: bigint,
|
||||
amount: Decimal,
|
||||
) {
|
||||
const parentAgentId = await this.findPlayerParentAgentId(tx, playerId);
|
||||
if (!parentAgentId) return null;
|
||||
|
||||
await this.credit.recalculateUsedCredit(parentAgentId, tx);
|
||||
const profile = await tx.agentProfile.findUnique({
|
||||
where: { userId: parentAgentId },
|
||||
select: { creditLimit: true, usedCredit: true },
|
||||
});
|
||||
if (!profile) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
|
||||
|
||||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||||
if (available.lt(amount)) {
|
||||
throw appBadRequest('CREDIT_TOPUP_EXCEEDED');
|
||||
}
|
||||
return parentAgentId;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user