import { Injectable } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../../shared/prisma/prisma.service'; import { FundsPostingService } from '../../ledger/funds-posting.service'; import { SystemConfigService } from '../../../shared/config/system-config.service'; import { Decimal } from '@prisma/client/runtime/library'; import { generateBatchNo } from '../../../shared/common/decorators'; import { appBadRequest } from '../../../shared/common/app-error'; import { resolveCashbackRateForBet, type CashbackRuleRow, } from './cashback-rate.resolver'; type AggregatedItem = { userId: bigint; effectiveStake: Decimal; betCount: number; rate: Decimal; amount: Decimal; username: string; agentUsername: string | null; availableBalance: Decimal; }; type BetCashbackLine = { betId: bigint; userId: bigint; stake: Decimal; amount: Decimal; }; @Injectable() export class CashbackService { constructor( private prisma: PrismaService, private funds: FundsPostingService, private systemConfig: SystemConfigService, ) {} /** 已被待发放/已发放返水批次占用的注单 */ private async loadClaimedBetIds(): Promise> { const rows = await this.prisma.cashbackBet.findMany({ where: { batch: { status: { in: ['PREVIEW', 'CONFIRMED'] } }, }, select: { betId: true }, }); return new Set(rows.map((r) => r.betId.toString())); } private async aggregatePeriod(periodStart: Date, periodEnd: Date): Promise<{ items: AggregatedItem[]; betLines: BetCashbackLine[]; totalAmount: Decimal; totalEffectiveStake: Decimal; totalBetCount: number; eligibleBetCount: number; skippedClaimedCount: number; }> { const [settledBets, rules, agentProfiles, claimedBetIds, platformDirectRateRaw] = await Promise.all([ this.prisma.bet.findMany({ where: { status: { in: ['WON', 'LOST'] }, settledAt: { gte: periodStart, lte: periodEnd }, }, include: { user: { select: { id: true, parentId: true, inviteSponsorId: true } }, selections: { select: { marketType: true } }, }, }), this.prisma.cashbackRule.findMany({ where: { isActive: true } }), this.prisma.agentProfile.findMany({ select: { userId: true, cashbackRate: true }, }), this.loadClaimedBetIds(), this.systemConfig.getPlatformDirectCashbackSettings(), ]); const platformDirectDefaultRate = new Decimal(platformDirectRateRaw.platformDirectRate); const adminInviteDefaultRate = new Decimal(platformDirectRateRaw.adminInviteRate); const agentRateById = new Map( agentProfiles.map((p) => [p.userId.toString(), new Decimal(p.cashbackRate)]), ); const sponsorTypeById = new Map(); const sponsorIds = [ ...new Set( settledBets .map((b) => b.user.inviteSponsorId) .filter((id): id is bigint => id != null), ), ]; if (sponsorIds.length > 0) { const sponsors = await this.prisma.user.findMany({ where: { id: { in: sponsorIds } }, select: { id: true, userType: true }, }); for (const sponsor of sponsors) { sponsorTypeById.set(sponsor.id.toString(), sponsor.userType); } } const resolveDefaultRate = (user: { parentId: bigint | null; inviteSponsorId: bigint | null; }) => { if (user.parentId) { return agentRateById.get(user.parentId.toString()) ?? new Decimal(0); } if (user.inviteSponsorId) { const sponsorType = sponsorTypeById.get(user.inviteSponsorId.toString()); if (sponsorType === 'ADMIN') return adminInviteDefaultRate; } return platformDirectDefaultRate; }; const ruleRows: CashbackRuleRow[] = rules.map((r) => ({ targetType: r.targetType, targetId: r.targetId, rate: new Decimal(r.rate), marketType: r.marketType, })); const playerAgg = new Map< string, { userId: bigint; stake: Decimal; amount: Decimal; betCount: number } >(); const betLines: BetCashbackLine[] = []; let eligibleBetCount = 0; let skippedClaimedCount = 0; for (const bet of settledBets) { const agentId = bet.user.parentId; const agentDefaultRate = resolveDefaultRate(bet.user); const marketTypes = bet.selections.map((s) => s.marketType); const rate = resolveCashbackRateForBet({ userId: bet.userId, agentId, marketTypes, agentDefaultRate, rules: ruleRows, }); if (rate.lte(0)) continue; eligibleBetCount += 1; if (claimedBetIds.has(bet.id.toString())) { skippedClaimedCount += 1; continue; } const lineAmount = bet.stake.mul(rate); betLines.push({ betId: bet.id, userId: bet.userId, stake: bet.stake, amount: lineAmount, }); const key = bet.userId.toString(); const existing = playerAgg.get(key) ?? { userId: bet.userId, stake: new Decimal(0), amount: new Decimal(0), betCount: 0, }; existing.stake = existing.stake.add(bet.stake); existing.amount = existing.amount.add(lineAmount); existing.betCount += 1; playerAgg.set(key, existing); } const rawItems = Array.from(playerAgg.values()) .map((p) => ({ userId: p.userId, effectiveStake: p.stake, betCount: p.betCount, rate: p.stake.gt(0) ? p.amount.div(p.stake) : new Decimal(0), amount: p.amount, })) .sort((a, b) => (b.amount.gt(a.amount) ? 1 : a.amount.gt(b.amount) ? -1 : 0)); const userIds = rawItems.map((i) => i.userId); const users = userIds.length > 0 ? await this.prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, username: true, parent: { select: { username: true } }, }, }) : []; const userById = new Map(users.map((u) => [u.id.toString(), u])); const wallets = userIds.length > 0 ? await this.prisma.wallet.findMany({ where: { userId: { in: userIds } }, select: { userId: true, availableBalance: true }, }) : []; const balanceByUserId = new Map( wallets.map((w) => [w.userId.toString(), w.availableBalance]), ); const items: AggregatedItem[] = rawItems.map((item) => { const user = userById.get(item.userId.toString()); return { ...item, username: user?.username ?? '', agentUsername: user?.parent?.username ?? null, availableBalance: balanceByUserId.get(item.userId.toString()) ?? new Decimal(0), }; }); const totalAmount = items.reduce((s, i) => s.add(i.amount), new Decimal(0)); const totalEffectiveStake = items.reduce((s, i) => s.add(i.effectiveStake), new Decimal(0)); const totalBetCount = items.reduce((s, i) => s + i.betCount, 0); return { items, betLines, totalAmount, totalEffectiveStake, totalBetCount, eligibleBetCount, skippedClaimedCount, }; } private normalizePeriodStart(input: Date): Date { const d = new Date(input); d.setHours(0, 0, 0, 0); return d; } private normalizePeriodEnd(input: Date): Date { const d = new Date(input); d.setHours(23, 59, 59, 999); return d; } private async removePreviewBatchesForPeriod( periodStart: Date, periodEnd: Date, tx: Prisma.TransactionClient, ) { const stale = await tx.cashbackBatch.findMany({ where: { status: 'PREVIEW', periodStart, periodEnd }, select: { id: true }, }); for (const b of stale) { await tx.cashbackBet.deleteMany({ where: { batchId: b.id } }); await tx.cashbackItem.deleteMany({ where: { batchId: b.id } }); await tx.cashbackBatch.delete({ where: { id: b.id } }); } return stale.length; } async previewBatch(periodStart: Date, periodEnd: Date) { const start = this.normalizePeriodStart(periodStart); const end = this.normalizePeriodEnd(periodEnd); if (start > end) { throw appBadRequest('CASHBACK_DATE_RANGE_INVALID'); } const alreadyPaid = await this.prisma.cashbackBatch.findFirst({ where: { status: 'CONFIRMED', periodStart: start, periodEnd: end }, }); if (alreadyPaid) { throw appBadRequest('CASHBACK_ALREADY_ISSUED'); } const { items, betLines, totalAmount, totalEffectiveStake, totalBetCount, eligibleBetCount, skippedClaimedCount, } = await this.aggregatePeriod(start, end); if (items.length === 0 || totalAmount.lte(0)) { if (eligibleBetCount > 0 && skippedClaimedCount >= eligibleBetCount) { throw appBadRequest('CASHBACK_BETS_IN_OTHER_BATCH'); } throw appBadRequest('CASHBACK_NO_ELIGIBLE_BETS'); } let batch!: Awaited>; const replacedPreviewCount = await this.prisma.$transaction(async (tx) => { const replaced = await this.removePreviewBatchesForPeriod(start, end, tx); batch = await tx.cashbackBatch.create({ data: { batchNo: generateBatchNo('CB'), periodStart: start, periodEnd: end, status: 'PREVIEW', totalAmount, totalEffectiveStake, totalBetCount, playerCount: items.length, }, }); for (const item of items) { await tx.cashbackItem.create({ data: { batchId: batch.id, userId: item.userId, effectiveStake: item.effectiveStake, betCount: item.betCount, rate: item.rate, amount: item.amount, }, }); } for (const line of betLines) { await tx.cashbackBet.create({ data: { batchId: batch.id, betId: line.betId, userId: line.userId, stake: line.stake, amount: line.amount, }, }); } return replaced; }); const avgRate = totalEffectiveStake.gt(0) ? totalAmount.div(totalEffectiveStake) : new Decimal(0); return { batch, items, totalAmount, totalEffectiveStake, totalBetCount, avgRate, replacedPreviewCount, }; } async listBatches(params: { page: number; pageSize: number; status?: string }) { const page = Math.max(1, params.page); const pageSize = Math.min(100, Math.max(1, params.pageSize)); const where = params.status?.trim() ? { status: params.status.trim() } : {}; const [rows, total] = await Promise.all([ this.prisma.cashbackBatch.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, }), this.prisma.cashbackBatch.count({ where }), ]); const operatorIds = rows .map((r) => r.operatorId) .filter((id): id is bigint => id != null); const operators = operatorIds.length > 0 ? await this.prisma.user.findMany({ where: { id: { in: operatorIds } }, select: { id: true, username: true }, }) : []; const operatorById = new Map(operators.map((u) => [u.id.toString(), u.username])); const items = rows.map((row) => ({ ...row, operatorUsername: row.operatorId ? operatorById.get(row.operatorId.toString()) ?? null : null, })); return { items, total, page, pageSize }; } async getBatchDetail(batchId: bigint) { const batch = await this.prisma.cashbackBatch.findUnique({ where: { id: batchId }, include: { items: { orderBy: { amount: 'desc' } }, }, }); if (!batch) throw appBadRequest('CASHBACK_BATCH_NOT_FOUND'); const userIds = batch.items.map((i) => i.userId); const users = userIds.length > 0 ? await this.prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, username: true, parent: { select: { username: true } }, }, }) : []; const userById = new Map(users.map((u) => [u.id.toString(), u])); const wallets = userIds.length > 0 ? await this.prisma.wallet.findMany({ where: { userId: { in: userIds } }, select: { userId: true, availableBalance: true }, }) : []; const balanceByUserId = new Map( wallets.map((w) => [w.userId.toString(), w.availableBalance]), ); let operatorUsername: string | null = null; if (batch.operatorId) { const op = await this.prisma.user.findUnique({ where: { id: batch.operatorId }, select: { username: true }, }); operatorUsername = op?.username ?? null; } const items = batch.items.map((item) => { const user = userById.get(item.userId.toString()); return { id: item.id, userId: item.userId, username: user?.username ?? '', agentUsername: user?.parent?.username ?? null, availableBalance: balanceByUserId.get(item.userId.toString()) ?? new Decimal(0), effectiveStake: item.effectiveStake, betCount: item.betCount, rate: item.rate, amount: item.amount, }; }); const avgRate = batch.totalEffectiveStake.gt(0) ? batch.totalAmount.div(batch.totalEffectiveStake) : new Decimal(0); return { batch: { ...batch, operatorUsername }, items, totalAmount: batch.totalAmount, totalEffectiveStake: batch.totalEffectiveStake, totalBetCount: batch.totalBetCount, avgRate, }; } async confirmBatch(batchId: bigint, operatorId: bigint) { const batch = await this.prisma.cashbackBatch.findUnique({ where: { id: batchId }, include: { items: true, bets: true }, }); if (!batch) throw appBadRequest('CASHBACK_BATCH_NOT_FOUND'); if (batch.status !== 'PREVIEW') throw appBadRequest('CASHBACK_BATCH_NOT_ISSUABLE'); if (batch.items.length === 0 || batch.totalAmount.lte(0)) { throw appBadRequest('CASHBACK_NO_AMOUNT'); } const duplicate = await this.prisma.cashbackBatch.findFirst({ where: { status: 'CONFIRMED', periodStart: batch.periodStart, periodEnd: batch.periodEnd, id: { not: batchId }, }, }); if (duplicate) { throw appBadRequest('CASHBACK_PERIOD_ALREADY_ISSUED'); } const betIds = batch.bets.map((b) => b.betId); if (betIds.length > 0) { const conflict = await this.prisma.cashbackBet.findFirst({ where: { betId: { in: betIds }, batch: { status: 'CONFIRMED' }, }, }); if (conflict) { throw appBadRequest('CASHBACK_BETS_ALREADY_PAID'); } } await this.prisma.$transaction(async (tx) => { for (const item of batch.items) { if (item.amount.gt(0)) { await this.funds.deposit({ userId: item.userId, amount: item.amount, operatorId, remark: `Cashback batch ${batch.batchNo}`, referenceId: batch.batchNo, transactionType: 'CASHBACK_DEPOSIT', tx, businessKey: `cashback:${batch.batchNo}:${item.userId}`, }); } } if (betIds.length > 0) { await tx.bet.updateMany({ where: { id: { in: betIds } }, data: { isCashbacked: true }, }); } await tx.cashbackBatch.update({ where: { id: batchId }, data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId }, }); }); return { success: true }; } async cancelBatch(batchId: bigint) { const batch = await this.prisma.cashbackBatch.findUnique({ where: { id: batchId } }); if (!batch) throw appBadRequest('CASHBACK_BATCH_NOT_FOUND'); if (batch.status !== 'PREVIEW') { throw appBadRequest('CASHBACK_PREVIEW_ONLY_VOID'); } await this.prisma.$transaction(async (tx) => { await tx.cashbackBet.deleteMany({ where: { batchId } }); await tx.cashbackBatch.update({ where: { id: batchId }, data: { status: 'CANCELLED' }, }); }); return { success: true }; } async getUserCashbacks(userId: bigint) { const items = await this.prisma.cashbackItem.findMany({ where: { userId, batch: { status: 'CONFIRMED' } }, include: { batch: { select: { batchNo: true, periodStart: true, periodEnd: true, confirmedAt: true, status: true, }, }, }, orderBy: { createdAt: 'desc' }, }); return items.map((item) => ({ id: item.id.toString(), batchNo: item.batch.batchNo, periodStart: item.batch.periodStart, periodEnd: item.batch.periodEnd, confirmedAt: item.batch.confirmedAt, effectiveStake: item.effectiveStake.toString(), betCount: item.betCount, rate: item.rate.toString(), amount: item.amount.toString(), createdAt: item.createdAt, })); } async getPlayerCustomCashbackRate(userId: bigint): Promise { const rule = await this.prisma.cashbackRule.findFirst({ where: { targetType: 'USER', targetId: userId, isActive: true, marketType: null, }, orderBy: { updatedAt: 'desc' }, }); if (!rule) return null; const rate = new Decimal(rule.rate); return rate.gt(0) ? rate : null; } async setPlayerCustomCashbackRate(userId: bigint, rate: Decimal | null) { await this.prisma.$transaction(async (tx) => { await tx.cashbackRule.updateMany({ where: { targetType: 'USER', targetId: userId }, data: { isActive: false }, }); if (rate && rate.gt(0)) { await tx.cashbackRule.create({ data: { name: `Player ${userId.toString()}`, targetType: 'USER', targetId: userId, rate, isActive: true, }, }); } }); } async resolvePlayerDefaultCashbackRate(params: { parentId: bigint | null; inviteSponsorId?: bigint | null; }): Promise { if (params.parentId) { const profile = await this.prisma.agentProfile.findUnique({ where: { userId: params.parentId }, select: { cashbackRate: true }, }); return profile ? new Decimal(profile.cashbackRate) : new Decimal(0); } const settings = await this.systemConfig.getPlatformDirectCashbackSettings(); if (params.inviteSponsorId) { const sponsor = await this.prisma.user.findUnique({ where: { id: params.inviteSponsorId }, select: { userType: true }, }); if (sponsor?.userType === 'ADMIN') { return new Decimal(settings.adminInviteRate); } } return new Decimal(settings.platformDirectRate); } }