Files
thebet365/apps/api/src/domains/deposit/deposit.service.ts

987 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Injectable } from '@nestjs/common';
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 { 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';
import { PlayerMessagesService } from '../player-messages/player-messages.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
function generateOrderNo(): string {
const ts = Date.now().toString(36).toUpperCase();
const rand = Math.random().toString(36).substring(2, 8).toUpperCase();
return `DEP${ts}${rand}`;
}
/** 已通过充值订单允许撤回的时间窗口 */
const DEPOSIT_REVOKE_WINDOW_MS = 5 * 60 * 1000;
export type DepositOrderAuditAction =
| 'SUBMITTED'
| 'APPROVED'
| 'REJECTED'
| 'REVOKED'
| 'REOPENED'
| 'DELETED';
type AuditLogWriter = {
depositOrderAuditLog: {
create: (args: {
data: {
depositOrderId: bigint;
action: DepositOrderAuditAction;
actorId?: bigint | null;
actorType: string;
statusBefore?: string | null;
statusAfter: string;
amount?: Decimal | null;
approvedAmount?: Decimal | null;
remark?: string | null;
};
}) => Promise<{ id: bigint }>;
};
};
@Injectable()
export class DepositService {
constructor(
private prisma: PrismaService,
private funds: FundsPostingService,
private credit: AgentCreditService,
private playerMessages: PlayerMessagesService,
private systemConfig: SystemConfigService,
) {}
// ============ Payment Methods (Admin CRUD) ============
/** isActive 与 showOnPlayer 合并为同一开关DB 两列保持同步以兼容旧数据。 */
private normalizePaymentMethodActive(data: {
isActive?: boolean;
showOnPlayer?: boolean;
}): { isActive?: boolean; showOnPlayer?: boolean } {
if (data.isActive !== undefined) {
return { isActive: data.isActive, showOnPlayer: data.isActive };
}
if (data.showOnPlayer !== undefined) {
return { isActive: data.showOnPlayer, showOnPlayer: data.showOnPlayer };
}
return {};
}
async createPaymentMethod(data: {
methodType: string;
bankName?: string;
accountHolder?: string;
accountNumber?: string;
usdtAddress?: string;
qrCodeUrl?: string;
displayName?: string;
sortOrder?: number;
isActive?: boolean;
showOnPlayer?: boolean;
createdBy?: bigint;
translations?: {
displayName?: Record<string, string>;
bankName?: Record<string, string>;
};
}) {
const active = data.isActive ?? data.showOnPlayer ?? true;
const method = await this.prisma.paymentMethod.create({
data: {
methodType: data.methodType,
bankName: data.bankName,
accountHolder: data.accountHolder,
accountNumber: data.accountNumber,
usdtAddress: data.usdtAddress,
qrCodeUrl: data.qrCodeUrl,
displayName: data.displayName,
sortOrder: data.sortOrder ?? 0,
isActive: active,
showOnPlayer: active,
createdBy: data.createdBy,
},
});
if (data.translations) {
await this.upsertPaymentMethodTranslations(method.id, data.translations);
}
return method;
}
async updatePaymentMethod(
id: bigint,
data: {
bankName?: string;
accountHolder?: string;
accountNumber?: string;
usdtAddress?: string;
qrCodeUrl?: string;
displayName?: string;
sortOrder?: number;
isActive?: boolean;
showOnPlayer?: boolean;
translations?: {
displayName?: Record<string, string>;
bankName?: Record<string, string>;
};
},
) {
const { translations, ...rest } = data;
const activePatch = this.normalizePaymentMethodActive(rest);
const method = await this.prisma.paymentMethod.update({
where: { id },
data: { ...rest, ...activePatch },
});
if (translations) {
await this.upsertPaymentMethodTranslations(id, translations);
}
return method;
}
async deletePaymentMethod(id: bigint) {
return this.prisma.paymentMethod.update({
where: { id },
data: { isActive: false, showOnPlayer: false },
});
}
async listPaymentMethods(filters?: { methodType?: string }) {
const where: Prisma.PaymentMethodWhereInput = {};
if (filters?.methodType) {
where.methodType = filters.methodType;
}
const items = await this.prisma.paymentMethod.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
});
// Attach translations for admin editing
const ids = items.map((m) => m.id);
const translations = ids.length
? await this.prisma.entityTranslation.findMany({
where: { entityType: 'PAYMENT_METHOD', entityId: { in: ids } },
})
: [];
const tMap = new Map<string, Record<string, Record<string, string>>>();
for (const t of translations) {
const key = t.entityId.toString();
if (!tMap.has(key)) tMap.set(key, {});
const entityMap = tMap.get(key)!;
if (!entityMap[t.fieldName]) entityMap[t.fieldName] = {};
entityMap[t.fieldName][t.locale] = t.value;
}
return items.map((m) => ({
...m,
translations: tMap.get(m.id.toString()) ?? {},
}));
}
async listPlayerPaymentMethods(methodType?: string, locale?: string) {
const where: Prisma.PaymentMethodWhereInput = {
isActive: true,
};
if (methodType) {
where.methodType = methodType;
}
const items = await this.prisma.paymentMethod.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
select: {
id: true,
methodType: true,
bankName: true,
accountHolder: true,
accountNumber: true,
usdtAddress: true,
qrCodeUrl: true,
displayName: true,
sortOrder: true,
},
});
// Resolve translations for player locale
if (locale && items.length) {
const ids = items.map((m) => m.id);
const translations = await this.prisma.entityTranslation.findMany({
where: { entityType: 'PAYMENT_METHOD', entityId: { in: ids } },
});
const tMap = new Map<string, Record<string, Record<string, string>>>();
for (const t of translations) {
const key = t.entityId.toString();
if (!tMap.has(key)) tMap.set(key, {});
const entityMap = tMap.get(key)!;
if (!entityMap[t.fieldName]) entityMap[t.fieldName] = {};
entityMap[t.fieldName][t.locale] = t.value;
}
return items.map((m) => {
const t = tMap.get(m.id.toString());
const resolvedDisplayName = t?.displayName
? resolveTranslationFallback(t.displayName, locale) || m.displayName
: m.displayName;
const resolvedBankName = t?.bankName
? resolveTranslationFallback(t.bankName, locale) || m.bankName
: m.bankName;
return { ...m, displayName: resolvedDisplayName, bankName: resolvedBankName };
});
}
return items;
}
// ============ Translation helpers ============
private async upsertPaymentMethodTranslations(
entityId: bigint,
translations: {
displayName?: Record<string, string>;
bankName?: Record<string, string>;
},
) {
for (const fieldName of ['displayName', 'bankName'] as const) {
const fieldTranslations = translations[fieldName];
if (!fieldTranslations) continue;
for (const [locale, value] of Object.entries(fieldTranslations)) {
await this.prisma.entityTranslation.upsert({
where: {
entityType_entityId_locale_fieldName: {
entityType: 'PAYMENT_METHOD',
entityId,
locale,
fieldName,
},
},
create: { entityType: 'PAYMENT_METHOD', entityId, locale, fieldName, value },
update: { value },
});
}
}
}
// ============ Deposit Orders ============
private async getPlayerLocale(playerId: bigint, tx: Prisma.TransactionClient | PrismaService = this.prisma) {
const user = await tx.user.findUnique({
where: { id: playerId },
select: { locale: true },
});
return user?.locale ?? 'en-US';
}
private async recordDepositAudit(
client: AuditLogWriter,
data: {
depositOrderId: bigint;
action: DepositOrderAuditAction;
actorId?: bigint | null;
actorType: 'PLAYER' | 'ADMIN';
statusBefore?: string | null;
statusAfter: string;
amount?: Decimal | null;
approvedAmount?: Decimal | null;
remark?: string | null;
},
) {
return client.depositOrderAuditLog.create({
data: {
depositOrderId: data.depositOrderId,
action: data.action,
actorId: data.actorId ?? null,
actorType: data.actorType,
statusBefore: data.statusBefore ?? null,
statusAfter: data.statusAfter,
amount: data.amount ?? null,
approvedAmount: data.approvedAmount ?? null,
remark: data.remark ?? null,
},
});
}
private mapAuditLogRow(
row: {
id: bigint;
action: string;
actorId: bigint | null;
actorType: string;
statusBefore: string | null;
statusAfter: string;
amount: Decimal | null;
approvedAmount: Decimal | null;
remark: string | null;
createdAt: Date;
},
actorUsername?: string | null,
) {
return {
id: row.id.toString(),
action: row.action,
actorId: row.actorId?.toString() ?? null,
actorType: row.actorType,
actorUsername: actorUsername ?? null,
statusBefore: row.statusBefore,
statusAfter: row.statusAfter,
amount: row.amount?.toString() ?? null,
approvedAmount: row.approvedAmount?.toString() ?? null,
remark: row.remark,
createdAt: row.createdAt.toISOString(),
};
}
async getDepositOrderAuditLogs(orderId: bigint) {
const order = await this.prisma.depositOrder.findUnique({
where: { id: orderId },
select: { id: true },
});
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
const rows = await this.prisma.depositOrderAuditLog.findMany({
where: { depositOrderId: orderId },
orderBy: { createdAt: 'asc' },
});
const actorIds = [...new Set(rows.map((r) => r.actorId).filter((id): id is bigint => id != null))];
const actors =
actorIds.length > 0
? await this.prisma.user.findMany({
where: { id: { in: actorIds } },
select: { id: true, username: true },
})
: [];
const actorMap = new Map(actors.map((a) => [a.id.toString(), a.username]));
return rows.map((row) =>
this.mapAuditLogRow(
row,
row.actorId ? actorMap.get(row.actorId.toString()) ?? null : null,
),
);
}
async getPlayerDepositOrderAuditLogs(orderId: bigint, playerId: bigint) {
const order = await this.prisma.depositOrder.findFirst({
where: { id: orderId, playerId },
select: { id: true },
});
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
const rows = await this.prisma.depositOrderAuditLog.findMany({
where: { depositOrderId: orderId },
orderBy: { createdAt: 'asc' },
});
return rows.map((row) => {
const mapped = this.mapAuditLogRow(row);
return {
id: mapped.id,
action: mapped.action,
actorType: mapped.actorType,
statusBefore: mapped.statusBefore,
statusAfter: mapped.statusAfter,
amount: mapped.amount,
approvedAmount: mapped.approvedAmount,
remark: mapped.remark,
createdAt: mapped.createdAt,
};
});
}
private async attachPlayerAuditLogs(
orders: Array<{ id: bigint }>,
) {
if (!orders.length) return new Map<string, ReturnType<typeof this.mapAuditLogRow>[]>();
const orderIds = orders.map((o) => o.id);
const rows = await this.prisma.depositOrderAuditLog.findMany({
where: { depositOrderId: { in: orderIds } },
orderBy: { createdAt: 'asc' },
});
const map = new Map<string, ReturnType<typeof this.mapAuditLogRow>[]>();
for (const row of rows) {
const key = row.depositOrderId.toString();
if (!map.has(key)) map.set(key, []);
const mapped = this.mapAuditLogRow(row);
map.get(key)!.push({
id: mapped.id,
action: mapped.action,
actorId: null,
actorType: mapped.actorType,
actorUsername: null,
statusBefore: mapped.statusBefore,
statusAfter: mapped.statusAfter,
amount: mapped.amount,
approvedAmount: mapped.approvedAmount,
remark: mapped.remark,
createdAt: mapped.createdAt,
});
}
return map;
}
async createDepositOrder(
playerId: bigint,
paymentMethodId: bigint,
amount: number,
screenshotUrl: string,
) {
const method = await this.prisma.paymentMethod.findUnique({
where: { id: paymentMethodId },
});
if (!method || !method.isActive) {
throw appBadRequest('PAYMENT_METHOD_NOT_FOUND');
}
const order = await this.prisma.depositOrder.create({
data: {
orderNo: generateOrderNo(),
playerId,
paymentMethodId,
methodType: method.methodType,
amount: new Decimal(amount),
screenshotUrl,
status: 'PENDING',
},
});
await this.recordDepositAudit(this.prisma, {
depositOrderId: order.id,
action: 'SUBMITTED',
actorId: playerId,
actorType: 'PLAYER',
statusBefore: null,
statusAfter: 'PENDING',
amount: order.amount,
remark: null,
});
return order;
}
/** 玩家对已拒绝订单重新提交:原订单号不变,状态恢复待审核,需新转账截图。 */
async reapplyDepositOrder(
playerId: bigint,
orderId: bigint,
screenshotUrl: string,
amount?: number,
paymentMethodId?: bigint,
) {
return this.prisma.$transaction(async (tx) => {
const order = await this.lockDepositOrder(tx, orderId);
if (order.playerId !== playerId) throw appBadRequest('ORDER_NOT_FOUND');
if (order.status !== 'REJECTED') throw appBadRequest('ORDER_NOT_REJECTED');
const otherPending = await tx.depositOrder.findFirst({
where: {
playerId,
status: 'PENDING',
id: { not: orderId },
},
select: { id: true },
});
if (otherPending) throw appBadRequest('DEPOSIT_PENDING_ORDER_EXISTS');
const targetMethodId = paymentMethodId ?? order.paymentMethodId;
const method = await tx.paymentMethod.findUnique({ where: { id: targetMethodId } });
if (!method || !method.isActive) {
throw appBadRequest('PAYMENT_METHOD_NOT_FOUND');
}
const creditAmount = amount != null ? new Decimal(amount) : order.amount;
if (creditAmount.lte(0)) throw appBadRequest('INVALID_AMOUNT');
const oldScreenshotUrl = order.screenshotUrl;
await tx.depositOrder.update({
where: { id: orderId },
data: {
status: 'PENDING',
screenshotUrl,
amount: creditAmount,
paymentMethodId: targetMethodId,
methodType: method.methodType,
approvedAmount: null,
reviewerId: null,
reviewedAt: null,
rejectReason: null,
remark: null,
},
});
await this.recordDepositAudit(tx, {
depositOrderId: orderId,
action: 'REOPENED',
actorId: playerId,
actorType: 'PLAYER',
statusBefore: order.status,
statusAfter: 'PENDING',
amount: creditAmount,
remark: null,
});
await deleteUploadFileByUrl(oldScreenshotUrl);
return tx.depositOrder.findUnique({ where: { id: orderId } });
});
}
async getPlayerDepositOrders(playerId: bigint, page = 1, pageSize = 20) {
const skip = (page - 1) * pageSize;
const where = { playerId };
const [items, total] = await Promise.all([
this.prisma.depositOrder.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
include: {
paymentMethod: {
select: { bankName: true, usdtAddress: true, displayName: true, methodType: true },
},
},
}),
this.prisma.depositOrder.count({ where }),
]);
const auditMap = await this.attachPlayerAuditLogs(items);
return {
items: items.map((o) => ({
id: o.id.toString(),
orderNo: o.orderNo,
paymentMethodId: o.paymentMethodId.toString(),
methodType: o.methodType,
amount: o.amount.toString(),
screenshotUrl: o.screenshotUrl,
status: o.status,
approvedAmount: o.approvedAmount?.toString() ?? null,
rejectReason: o.rejectReason,
remark: o.remark,
createdAt: o.createdAt,
reviewedAt: o.reviewedAt,
paymentMethodName: o.paymentMethod?.displayName ?? o.paymentMethod?.bankName ?? o.paymentMethod?.usdtAddress ?? null,
auditLogs: (auditMap.get(o.id.toString()) ?? []).map((log) => ({
id: log.id,
action: log.action,
actorType: log.actorType,
statusBefore: log.statusBefore,
statusAfter: log.statusAfter,
amount: log.amount,
approvedAmount: log.approvedAmount,
remark: log.remark,
createdAt: log.createdAt,
})),
})),
total,
page,
pageSize,
};
}
async listDepositOrders(params: {
page?: number;
pageSize?: number;
status?: string;
keyword?: string;
methodType?: string;
dateFrom?: Date;
dateTo?: Date;
}) {
const page = Math.max(1, params.page ?? 1);
const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20));
const skip = (page - 1) * pageSize;
const where: Prisma.DepositOrderWhereInput = {};
if (params.status) {
where.status = params.status;
}
if (params.methodType) {
where.methodType = params.methodType;
}
if (params.dateFrom || params.dateTo) {
where.createdAt = {};
if (params.dateFrom) where.createdAt.gte = params.dateFrom;
if (params.dateTo) where.createdAt.lte = params.dateTo;
}
if (params.keyword?.trim()) {
const players = await this.prisma.user.findMany({
where: {
userType: 'PLAYER',
deletedAt: null,
username: { contains: params.keyword.trim(), mode: 'insensitive' },
},
select: { id: true },
take: 100,
});
if (!players.length) {
return { items: [], total: 0, page, pageSize };
}
where.playerId = { in: players.map((p) => p.id) };
}
const [rows, total] = await Promise.all([
this.prisma.depositOrder.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
include: {
paymentMethod: {
select: { bankName: true, usdtAddress: true, displayName: true, methodType: true },
},
},
}),
this.prisma.depositOrder.count({ where }),
]);
// Enrich with player usernames and reviewer info
const playerIds = [...new Set(rows.map((r) => r.playerId))];
const reviewerIds = [...new Set(rows.map((r) => r.reviewerId).filter((id): id is bigint => id != null))];
const [players, reviewers] = await Promise.all([
playerIds.length
? this.prisma.user.findMany({
where: { id: { in: playerIds } },
select: { id: true, username: true },
})
: [],
reviewerIds.length
? this.prisma.user.findMany({
where: { id: { in: reviewerIds } },
select: { id: true, username: true },
})
: [],
]);
const playerMap = new Map(players.map((p) => [p.id.toString(), p.username]));
const reviewerMap = new Map(reviewers.map((r) => [r.id.toString(), r.username]));
return {
items: rows.map((o) => ({
id: o.id.toString(),
orderNo: o.orderNo,
playerId: o.playerId.toString(),
playerUsername: playerMap.get(o.playerId.toString()) ?? null,
methodType: o.methodType,
amount: o.amount.toString(),
screenshotUrl: o.screenshotUrl,
status: o.status,
approvedAmount: o.approvedAmount?.toString() ?? null,
reviewerId: o.reviewerId?.toString() ?? null,
reviewerUsername: o.reviewerId ? (reviewerMap.get(o.reviewerId.toString()) ?? null) : null,
rejectReason: o.rejectReason,
remark: o.remark,
createdAt: o.createdAt,
reviewedAt: o.reviewedAt,
paymentMethodName: o.paymentMethod?.displayName ?? o.paymentMethod?.bankName ?? o.paymentMethod?.usdtAddress ?? null,
})),
total,
page,
pageSize,
};
}
async countPendingDepositOrders(): Promise<number> {
return this.prisma.depositOrder.count({ where: { status: 'PENDING' } });
}
async approveDepositOrder(
orderId: bigint,
operatorId: bigint,
approvedAmount?: number,
remark?: string,
) {
return this.prisma.$transaction(async (tx) => {
const order = await tx.depositOrder.findUnique({ where: { id: orderId } });
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
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 },
data: {
status: 'APPROVED',
approvedAmount: creditAmount,
reviewerId: operatorId,
reviewedAt: new Date(),
remark: remark ?? null,
},
});
const auditLog = await this.recordDepositAudit(tx, {
depositOrderId: orderId,
action: 'APPROVED',
actorId: operatorId,
actorType: 'ADMIN',
statusBefore: order.status,
statusAfter: 'APPROVED',
amount: order.amount,
approvedAmount: creditAmount,
remark: remark ?? null,
});
// Credit player wallet
await this.funds.deposit({
userId: order.playerId,
amount: creditAmount,
operatorId,
remark: remark ?? `Deposit order ${order.orderNo}`,
referenceId: order.orderNo,
transactionType: 'PLAYER_DEPOSIT',
tx,
businessKey: `deposit:${order.orderNo}:approve:${auditLog.id}`,
});
if (parentAgentId) {
await this.credit.recalculateUsedCredit(parentAgentId, tx);
}
const playerLocale = await this.getPlayerLocale(order.playerId, tx);
const inboxNotify = await this.systemConfig.getInboxNotifySettings();
if (inboxNotify.inboxEnabled && inboxNotify.deposit) {
await this.playerMessages.createDepositApprovedMessage(
order.playerId,
{
depositOrderId: orderId,
orderNo: order.orderNo,
amount: order.amount.toString(),
approvedAmount: creditAmount.toString(),
locale: playerLocale,
},
tx,
);
}
return { success: true };
});
}
async rejectDepositOrder(orderId: bigint, operatorId: bigint, reason: string) {
const order = await this.prisma.depositOrder.findUnique({ where: { id: orderId } });
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
if (order.status !== 'PENDING') throw appBadRequest('ORDER_NOT_PENDING');
await this.prisma.depositOrder.update({
where: { id: orderId },
data: {
status: 'REJECTED',
reviewerId: operatorId,
reviewedAt: new Date(),
rejectReason: reason,
remark: reason,
},
});
await this.recordDepositAudit(this.prisma, {
depositOrderId: orderId,
action: 'REJECTED',
actorId: operatorId,
actorType: 'ADMIN',
statusBefore: order.status,
statusAfter: 'REJECTED',
amount: order.amount,
remark: reason,
});
const playerLocale = await this.getPlayerLocale(order.playerId);
const inboxNotify = await this.systemConfig.getInboxNotifySettings();
if (inboxNotify.inboxEnabled && inboxNotify.deposit) {
await this.playerMessages.createDepositRejectedMessage(order.playerId, {
depositOrderId: orderId,
orderNo: order.orderNo,
amount: order.amount.toString(),
rejectReason: reason,
locale: playerLocale,
});
}
return { success: true };
}
private async reverseApprovedDepositCredit(
order: {
playerId: bigint;
orderNo: string;
approvedAmount: Decimal | null;
amount: Decimal;
},
operatorId: bigint,
remark: string,
) {
const credit = order.approvedAmount ?? order.amount;
await this.funds.withdraw({
userId: order.playerId,
amount: credit,
operatorId,
remark,
referenceId: order.orderNo,
transactionType: 'PLAYER_DEPOSIT_REVERSAL',
businessKey: `deposit:${order.orderNo}:reverse`,
});
}
/** 已拒绝恢复待审核已通过5 分钟内且未产生下注):扣回入账并恢复待审核 */
async reopenDepositOrderForReview(orderId: bigint, operatorId: bigint) {
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,
},
});
await this.recordDepositAudit(tx, {
depositOrderId: orderId,
action: 'REOPENED',
actorId: operatorId,
actorType: 'ADMIN',
statusBefore: order.status,
statusAfter: 'PENDING',
amount: order.amount,
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 },
});
if (betsAfterReview.length > 0) {
throw appBadRequest('DEPOSIT_REVOKE_SETTLED_BETS');
}
const credit = order.approvedAmount ?? order.amount;
const parentAgentId = await this.findPlayerParentAgentId(tx, order.playerId);
const approvalCycleKey = order.reviewedAt?.getTime();
await this.funds.withdraw({
userId: order.playerId,
amount: credit,
operatorId,
remark: `Revoke approved deposit ${order.orderNo}`,
referenceId: order.orderNo,
transactionType: 'PLAYER_DEPOSIT_REVERSAL',
referenceType: 'DEPOSIT',
tx,
businessKey: `deposit:${order.orderNo}:reopen-reverse:${approvalCycleKey}`,
});
if (parentAgentId) {
await this.credit.recalculateUsedCredit(parentAgentId, tx);
}
await tx.depositOrder.update({
where: { id: orderId },
data: {
status: 'PENDING',
approvedAmount: null,
reviewerId: null,
reviewedAt: null,
rejectReason: null,
remark: null,
},
});
await this.recordDepositAudit(tx, {
depositOrderId: orderId,
action: 'REVOKED',
actorId: operatorId,
actorType: 'ADMIN',
statusBefore: order.status,
statusAfter: 'PENDING',
amount: order.amount,
approvedAmount: credit,
remark: `Revoke approved deposit ${order.orderNo}`,
});
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 } });
await deleteUploadFileByUrl(screenshotUrl);
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;
}
}