642 lines
19 KiB
TypeScript
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);
|
|
}
|
|
}
|