Files
thebet365/apps/api/src/domains/operations/cashback/cashback.service.ts
2026-06-13 17:38:25 +08:00

642 lines
19 KiB
TypeScript

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<Set<string>> {
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<string, string>();
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<ReturnType<typeof this.prisma.cashbackBatch.create>>;
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<Decimal | null> {
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<Decimal> {
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);
}
}