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; bankName?: Record; }; }) { 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; bankName?: Record; }; }, ) { 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>>(); 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>>(); 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; bankName?: Record; }, ) { 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[]>(); 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[]>(); 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 { 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>` 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>` 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; } }