重构 API 为 8 领域 + 应用层架构

将后端模块拆分为 domains、applications、shared 三层,结算计算器移入 domain 纯函数目录,API 路径与测试保持不变。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 14:48:41 +08:00
parent 14e49374ac
commit 4c92157299
47 changed files with 169 additions and 138 deletions

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AgentsService } from './agents.service';
import { WalletModule } from '../ledger/wallet.module';
import { AuthModule } from '../identity/auth.module';
@Module({
imports: [WalletModule, AuthModule],
providers: [AgentsService],
exports: [AgentsService],
})
export class AgentsModule {}

View File

@@ -0,0 +1,296 @@
import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { WalletService } from '../ledger/wallet.service';
import { AuthService } from '../identity/auth.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateBatchNo } from '../../shared/common/decorators';
@Injectable()
export class AgentsService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
private auth: AuthService,
) {}
async getProfile(agentId: bigint) {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: agentId },
});
if (!profile) throw new BadRequestException('Agent profile not found');
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
return { ...profile, availableCredit: available };
}
async recalculateUsedCredit(agentId: bigint) {
const directPlayers = await this.prisma.user.findMany({
where: { parentId: agentId, userType: 'PLAYER' },
include: { wallet: true },
});
let directLiability = new Decimal(0);
for (const p of directPlayers) {
if (p.wallet) {
directLiability = directLiability
.add(p.wallet.availableBalance)
.add(p.wallet.frozenBalance);
}
}
const childAgents = await this.prisma.agentProfile.findMany({
where: { parentAgentId: agentId },
});
let childExposure = new Decimal(0);
for (const child of childAgents) {
const exposure = Decimal.max(child.creditLimit, child.usedCredit);
childExposure = childExposure.add(exposure);
}
const usedCredit = directLiability.add(childExposure);
await this.prisma.agentProfile.update({
where: { userId: agentId },
data: {
usedCredit,
directPlayerLiability: directLiability,
childAgentExposure: childExposure,
},
});
return usedCredit;
}
async adjustCredit(
agentId: bigint,
amount: Decimal | number,
operatorId: bigint,
requestId: string,
remark?: string,
) {
const amt = new Decimal(amount);
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: agentId },
});
if (!profile) throw new BadRequestException('Agent not found');
const creditBefore = profile.creditLimit;
const creditAfter = creditBefore.add(amt);
if (creditAfter.lt(0)) throw new BadRequestException('Credit limit cannot be negative');
await this.prisma.$transaction(async (tx) => {
await tx.agentProfile.update({
where: { userId: agentId },
data: { creditLimit: creditAfter },
});
await tx.agentCreditTransaction.create({
data: {
agentId,
transactionType: amt.gte(0) ? 'CREDIT_INCREASE' : 'CREDIT_DECREASE',
amount: amt,
creditBefore,
creditAfter,
operatorId,
requestId,
remark,
},
});
});
if (profile.parentAgentId) {
await this.recalculateUsedCredit(profile.parentAgentId);
}
return { creditAfter };
}
async depositToPlayer(
agentId: bigint,
playerId: bigint,
amount: number,
requestId: string,
) {
const player = await this.prisma.user.findUnique({ where: { id: playerId } });
if (!player || player.parentId !== agentId) {
throw new ForbiddenException('Can only deposit to direct players');
}
const profile = await this.getProfile(agentId);
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
const amt = new Decimal(amount);
if (available.lt(amt)) {
throw new BadRequestException('Insufficient agent credit');
}
await this.wallet.deposit(playerId, amt, agentId, 'Agent deposit', requestId);
await this.recalculateUsedCredit(agentId);
return { success: true };
}
async withdrawFromPlayer(
agentId: bigint,
playerId: bigint,
amount: number,
requestId: string,
) {
const player = await this.prisma.user.findUnique({ where: { id: playerId } });
if (!player || player.parentId !== agentId) {
throw new ForbiddenException('Can only withdraw from direct players');
}
await this.wallet.withdraw(playerId, amount, agentId, 'Agent withdraw', requestId);
await this.recalculateUsedCredit(agentId);
return { success: true };
}
async createAgent(
operatorId: bigint,
data: {
username: string;
password: string;
level: number;
parentAgentId?: bigint;
creditLimit?: number;
},
) {
if (data.level === 2 && !data.parentAgentId) {
throw new BadRequestException('Level 2 agent requires parent');
}
const hash = await this.auth.hashPassword(data.password);
return this.prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
username: data.username,
userType: 'AGENT',
parentId: data.parentAgentId,
agentLevel: data.level,
},
});
await tx.userAuth.create({
data: { userId: user.id, passwordHash: hash },
});
await tx.agentProfile.create({
data: {
userId: user.id,
level: data.level,
parentAgentId: data.parentAgentId,
creditLimit: data.creditLimit ?? 0,
},
});
// Build closure table
await tx.agentClosure.create({
data: { ancestorId: user.id, descendantId: user.id, depth: 0 },
});
if (data.parentAgentId) {
const ancestors = await tx.agentClosure.findMany({
where: { descendantId: data.parentAgentId },
});
for (const a of ancestors) {
await tx.agentClosure.create({
data: {
ancestorId: a.ancestorId,
descendantId: user.id,
depth: a.depth + 1,
},
});
}
if (data.parentAgentId) {
await this.recalculateUsedCredit(data.parentAgentId);
}
}
return user;
});
}
async createPlayer(
operatorId: bigint,
data: { username: string; password: string; parentId: bigint },
) {
const hash = await this.auth.hashPassword(data.password);
return this.prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
username: data.username,
userType: 'PLAYER',
parentId: data.parentId,
},
});
await tx.userAuth.create({
data: { userId: user.id, passwordHash: hash },
});
await tx.wallet.create({
data: { userId: user.id },
});
await tx.userPreference.create({
data: { userId: user.id },
});
const parent = await tx.user.findUnique({ where: { id: data.parentId } });
if (parent?.userType === 'AGENT') {
await this.recalculateUsedCredit(data.parentId);
}
return user;
});
}
async getDirectPlayers(agentId: bigint) {
return this.prisma.user.findMany({
where: { parentId: agentId, userType: 'PLAYER' },
include: { wallet: true },
});
}
async getChildAgents(agentId: bigint) {
return this.prisma.agentProfile.findMany({
where: { parentAgentId: agentId },
include: { user: true },
});
}
async getReportSummary(agentId: bigint) {
const profile = await this.getProfile(agentId);
const players = await this.getDirectPlayers(agentId);
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayBets = await this.prisma.bet.aggregate({
where: {
agentId,
placedAt: { gte: today },
},
_sum: { stake: true, actualReturn: true },
_count: true,
});
return {
profile,
directPlayerCount: players.length,
directPlayerTotalBalance: players.reduce(
(sum, p) =>
sum +
Number(p.wallet?.availableBalance ?? 0) +
Number(p.wallet?.frozenBalance ?? 0),
0,
),
todayBetCount: todayBets._count,
todayStake: todayBets._sum.stake,
todayReturn: todayBets._sum.actualReturn,
};
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { BetsService } from './bets.service';
import { WalletModule } from '../ledger/wallet.module';
@Module({
imports: [WalletModule],
providers: [BetsService],
exports: [BetsService],
})
export class BetsModule {}

View File

@@ -0,0 +1,211 @@
import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { WalletService } from '../ledger/wallet.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateBetNo } from '../../shared/common/decorators';
import { isQuarterHandicapOrTotal } from '../settlement/domain/settlement-calculator';
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
interface BetSelectionInput {
selectionId: bigint;
oddsVersion: bigint;
stake?: number;
}
@Injectable()
export class BetsService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
) {}
private async validateSelection(selectionId: bigint, oddsVersion: bigint) {
const selection = await this.prisma.marketSelection.findUnique({
where: { id: selectionId },
include: { market: { include: { match: true } } },
});
if (!selection) throw new BadRequestException('Selection not found');
if (selection.status !== 'OPEN') throw new BadRequestException('Selection closed');
if (selection.market.status !== 'OPEN') throw new BadRequestException('Market closed');
if (selection.market.match.status !== 'PUBLISHED') {
throw new BadRequestException('Match not available for betting');
}
if (selection.oddsVersion !== oddsVersion) {
throw new BadRequestException('Odds changed, please confirm again');
}
return selection;
}
async placeSingleBet(
userId: bigint,
agentId: bigint | null,
selectionId: bigint,
oddsVersion: bigint,
stake: number,
requestId: string,
) {
if (stake <= 0) throw new BadRequestException('Invalid stake');
const existing = await this.prisma.bet.findUnique({
where: { userId_requestId: { userId, requestId } },
});
if (existing) return existing;
const selection = await this.validateSelection(selectionId, oddsVersion);
const odds = new Decimal(selection.odds.toString());
const stakeDec = new Decimal(stake);
const potentialReturn = stakeDec.mul(odds);
const betNo = generateBetNo();
const bet = await this.prisma.$transaction(async (tx) => {
const created = await tx.bet.create({
data: {
betNo,
userId,
agentId,
betType: 'SINGLE',
stake: stakeDec,
totalOdds: odds,
potentialReturn,
requestId,
selections: {
create: {
matchId: selection.market.matchId,
marketId: selection.marketId,
selectionId: selection.id,
marketType: selection.market.marketType,
period: selection.market.period,
selectionNameSnapshot: selection.selectionName,
handicapLine: selection.market.lineValue,
totalLine: selection.market.lineValue,
odds,
oddsVersion,
},
},
},
include: { selections: true },
});
await this.wallet.freezeForBet(userId, stakeDec, betNo);
return created;
});
return bet;
}
async placeParlayBet(
userId: bigint,
agentId: bigint | null,
legs: BetSelectionInput[],
stake: number,
requestId: string,
) {
if (stake <= 0) throw new BadRequestException('Invalid stake');
if (legs.length < PARLAY_MIN_LEGS || legs.length > PARLAY_MAX_LEGS) {
throw new BadRequestException(`Parlay must have ${PARLAY_MIN_LEGS}-${PARLAY_MAX_LEGS} legs`);
}
const existing = await this.prisma.bet.findUnique({
where: { userId_requestId: { userId, requestId } },
});
if (existing) return existing;
const selections: Awaited<ReturnType<typeof this.validateSelection>>[] = [];
const matchIds = new Set<string>();
for (const leg of legs) {
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion);
if (sel.market.marketType === 'OUTRIGHT_WINNER') {
throw new BadRequestException('Outright cannot be in parlay');
}
const line = sel.market.lineValue ? Number(sel.market.lineValue) : null;
if (
['FT_HANDICAP', 'HT_HANDICAP', 'FT_OVER_UNDER', 'HT_OVER_UNDER'].includes(
sel.market.marketType,
) &&
isQuarterHandicapOrTotal(line)
) {
throw new BadRequestException('Quarter line markets cannot be in parlay');
}
const matchKey = sel.market.matchId.toString();
if (matchIds.has(matchKey)) {
throw new BadRequestException('Same match cannot be in parlay');
}
matchIds.add(matchKey);
selections.push(sel);
}
let totalOdds = new Decimal(1);
for (const sel of selections) {
totalOdds = totalOdds.mul(sel.odds.toString());
}
const stakeDec = new Decimal(stake);
const potentialReturn = stakeDec.mul(totalOdds);
const betNo = generateBetNo();
const bet = await this.prisma.$transaction(async (tx) => {
const created = await tx.bet.create({
data: {
betNo,
userId,
agentId,
betType: 'PARLAY',
stake: stakeDec,
totalOdds,
potentialReturn,
requestId,
selections: {
create: selections.map((sel, i) => ({
matchId: sel.market.matchId,
marketId: sel.marketId,
selectionId: sel.id,
marketType: sel.market.marketType,
period: sel.market.period,
selectionNameSnapshot: sel.selectionName,
handicapLine: sel.market.lineValue,
totalLine: sel.market.lineValue,
odds: sel.odds,
oddsVersion: legs[i].oddsVersion,
sortOrder: i,
})),
},
},
include: { selections: true },
});
await this.wallet.freezeForBet(userId, stakeDec, betNo);
return created;
});
return bet;
}
async getUserBets(userId: bigint, status?: string, page = 1, pageSize = 20) {
const where = { userId, ...(status ? { status } : {}) };
const skip = (page - 1) * pageSize;
const [items, total] = await Promise.all([
this.prisma.bet.findMany({
where,
include: { selections: true },
orderBy: { placedAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.bet.count({ where }),
]);
return { items, total, page, pageSize };
}
async getBetByNo(betNo: string, userId?: bigint) {
return this.prisma.bet.findFirst({
where: { betNo, ...(userId ? { userId } : {}) },
include: { selections: true },
});
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MatchesService } from './matches.service';
@Module({
providers: [MatchesService],
exports: [MatchesService],
})
export class MatchesModule {}

View File

@@ -0,0 +1,161 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../../shared/prisma/prisma.service';
@Injectable()
export class MatchesService {
constructor(private prisma: PrismaService) {}
async createLeague(code: string, translations: Record<string, string>) {
const league = await this.prisma.league.create({ data: { code } });
for (const [locale, value] of Object.entries(translations)) {
await this.prisma.entityTranslation.create({
data: {
entityType: 'LEAGUE',
entityId: league.id,
locale,
fieldName: 'name',
value,
},
});
}
return league;
}
async createTeam(code: string, translations: Record<string, string>) {
const team = await this.prisma.team.create({ data: { code } });
for (const [locale, value] of Object.entries(translations)) {
await this.prisma.entityTranslation.create({
data: {
entityType: 'TEAM',
entityId: team.id,
locale,
fieldName: 'name',
value,
},
});
}
return team;
}
async createMatch(data: {
leagueId: bigint;
homeTeamId: bigint;
awayTeamId: bigint;
startTime: Date;
isHot?: boolean;
createdBy?: bigint;
}) {
return this.prisma.match.create({
data: {
leagueId: data.leagueId,
homeTeamId: data.homeTeamId,
awayTeamId: data.awayTeamId,
startTime: data.startTime,
isHot: data.isHot ?? false,
createdBy: data.createdBy,
status: 'DRAFT',
},
});
}
async publishMatch(matchId: bigint) {
return this.prisma.match.update({
where: { id: matchId },
data: { status: 'PUBLISHED', publishTime: new Date() },
});
}
async closeMatch(matchId: bigint) {
return this.prisma.match.update({
where: { id: matchId },
data: { status: 'CLOSED', closeTime: new Date() },
});
}
async cancelMatch(matchId: bigint) {
return this.prisma.match.update({
where: { id: matchId },
data: { status: 'CANCELLED' },
});
}
async getTranslation(entityType: string, entityId: bigint, locale: string) {
const translations = await this.prisma.entityTranslation.findMany({
where: { entityType, entityId },
});
const map = Object.fromEntries(
translations.filter((t) => t.fieldName === 'name').map((t) => [t.locale, t.value]),
);
return map[locale] || map['en-US'] || map['zh-CN'] || Object.values(map)[0] || '';
}
async enrichMatch(match: Record<string, unknown>, locale: string) {
const m = match as {
id: bigint;
leagueId: bigint;
homeTeamId: bigint;
awayTeamId: bigint;
league?: unknown;
homeTeam?: unknown;
awayTeam?: unknown;
markets?: unknown[];
};
const [leagueName, homeName, awayName] = await Promise.all([
this.getTranslation('LEAGUE', m.leagueId, locale),
this.getTranslation('TEAM', m.homeTeamId, locale),
this.getTranslation('TEAM', m.awayTeamId, locale),
]);
return {
...match,
leagueName,
homeTeamName: homeName,
awayTeamName: awayName,
};
}
async listPublished(locale = 'en-US', leagueId?: bigint) {
const matches = await this.prisma.match.findMany({
where: {
status: 'PUBLISHED',
...(leagueId ? { leagueId } : {}),
},
include: {
markets: {
where: { status: 'OPEN' },
include: { selections: { where: { status: 'OPEN' } } },
},
},
orderBy: [{ isHot: 'desc' }, { startTime: 'asc' }],
});
return Promise.all(matches.map((m) => this.enrichMatch(m, locale)));
}
async getMatchDetail(matchId: bigint, locale = 'en-US') {
const match = await this.prisma.match.findUnique({
where: { id: matchId },
include: {
markets: {
include: { selections: true },
orderBy: { sortOrder: 'asc' },
},
score: true,
},
});
if (!match) throw new NotFoundException('Match not found');
return this.enrichMatch(match, locale);
}
@Cron(CronExpression.EVERY_MINUTE)
async autoCloseMatches() {
const now = new Date();
await this.prisma.match.updateMany({
where: {
status: 'PUBLISHED',
startTime: { lte: now },
},
data: { status: 'CLOSED', closeTime: now },
});
}
}

View File

@@ -0,0 +1,45 @@
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LoginDto, ChangePasswordDto } from './auth.dto';
import { Public, CurrentUser } from '../../shared/common/decorators';
import { JwtAuthGuard } from './guards';
import { jsonResponse } from '../../shared/common/filters';
@ApiTags('Auth')
@Controller()
export class AuthController {
constructor(private auth: AuthService) {}
@Public()
@Post('player/auth/login')
async playerLogin(@Body() dto: LoginDto) {
const result = await this.auth.login(dto.username, dto.password, 'player');
return jsonResponse(result);
}
@Public()
@Post('admin/auth/login')
async adminLogin(@Body() dto: LoginDto) {
const result = await this.auth.login(dto.username, dto.password, 'admin');
return jsonResponse(result);
}
@Public()
@Post('agent/auth/login')
async agentLogin(@Body() dto: LoginDto) {
const result = await this.auth.login(dto.username, dto.password, 'agent');
return jsonResponse(result);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Post('player/auth/change-password')
async playerChangePassword(
@CurrentUser('id') userId: bigint,
@Body() dto: ChangePasswordDto,
) {
await this.auth.changePassword(userId, dto.oldPassword, dto.newPassword);
return jsonResponse(null, 'Password changed');
}
}

View File

@@ -0,0 +1,24 @@
import { IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty()
@IsString()
username!: string;
@ApiProperty()
@IsString()
@MinLength(1)
password!: string;
}
export class ChangePasswordDto {
@ApiProperty()
@IsString()
oldPassword!: string;
@ApiProperty()
@IsString()
@MinLength(8)
newPassword!: string;
}

View File

@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { AuthController } from './auth.controller';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
secret: config.get('JWT_SECRET', 'dev-secret'),
signOptions: { expiresIn: config.get('JWT_PLAYER_EXPIRES', '24h') },
}),
inject: [ConfigService],
}),
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
exports: [AuthService, JwtModule],
})
export class AuthModule {}

View File

@@ -0,0 +1,111 @@
import { Injectable, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../../shared/prisma/prisma.service';
const MAX_LOGIN_FAILS = 5;
const LOCK_DURATION_MS = 15 * 60 * 1000;
export interface JwtPayload {
sub: string;
username: string;
userType: string;
role?: string;
}
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwt: JwtService,
private config: ConfigService,
) {}
async login(username: string, password: string, portal: 'player' | 'admin' | 'agent') {
const user = await this.prisma.user.findUnique({
where: { username },
include: { auth: true, adminRole: { include: { role: true } } },
});
if (!user || !user.auth) {
throw new UnauthorizedException('Invalid credentials');
}
const expectedType = portal === 'admin' ? 'ADMIN' : portal === 'agent' ? 'AGENT' : 'PLAYER';
if (user.userType !== expectedType) {
throw new UnauthorizedException('Invalid credentials');
}
if (user.status === 'DISABLED') {
throw new ForbiddenException('Account disabled');
}
if (user.auth.lockedUntil && user.auth.lockedUntil > new Date()) {
throw new ForbiddenException('Account locked, try again later');
}
const valid = await bcrypt.compare(password, user.auth.passwordHash);
if (!valid) {
const failCount = user.auth.loginFailCount + 1;
const lockedUntil =
failCount >= MAX_LOGIN_FAILS ? new Date(Date.now() + LOCK_DURATION_MS) : null;
await this.prisma.userAuth.update({
where: { userId: user.id },
data: { loginFailCount: failCount, lockedUntil },
});
throw new UnauthorizedException('Invalid credentials');
}
await this.prisma.userAuth.update({
where: { userId: user.id },
data: { loginFailCount: 0, lockedUntil: null, lastLoginAt: new Date() },
});
const expiresIn =
portal === 'admin'
? this.config.get('JWT_ADMIN_EXPIRES', '2h')
: portal === 'agent'
? this.config.get('JWT_AGENT_EXPIRES', '8h')
: this.config.get('JWT_PLAYER_EXPIRES', '24h');
const payload: JwtPayload = {
sub: user.id.toString(),
username: user.username,
userType: user.userType,
role: user.adminRole?.role?.code,
};
const token = this.jwt.sign(payload, { expiresIn });
return {
token,
user: {
id: user.id.toString(),
username: user.username,
userType: user.userType,
locale: user.locale,
role: user.adminRole?.role?.code,
},
};
}
async changePassword(userId: bigint, oldPassword: string, newPassword: string) {
const auth = await this.prisma.userAuth.findUnique({ where: { userId } });
if (!auth) throw new UnauthorizedException('User not found');
const valid = await bcrypt.compare(oldPassword, auth.passwordHash);
if (!valid) throw new UnauthorizedException('Invalid old password');
const hash = await bcrypt.hash(newPassword, 10);
await this.prisma.userAuth.update({
where: { userId },
data: { passwordHash: hash },
});
return { success: true };
}
async hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 10);
}
}

View File

@@ -0,0 +1,87 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { IS_PUBLIC_KEY, PERMISSIONS_KEY } from '../../shared/common/decorators';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
handleRequest<TUser = unknown>(err: Error | null, user: TUser): TUser {
if (err || !user) throw err || new UnauthorizedException();
return user;
}
}
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>(PERMISSIONS_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required?.length) return true;
const { user } = context.switchToHttp().getRequest();
const userPerms: string[] = user?.permissions ?? [];
if (user?.role === 'SUPER_ADMIN') return true;
const hasAll = required.every((p) => userPerms.includes(p));
if (!hasAll) throw new ForbiddenException('Insufficient permissions');
return true;
}
}
export function UserTypeGuard(...types: string[]) {
@Injectable()
class MixedUserTypeGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const { user } = context.switchToHttp().getRequest();
if (!types.includes(user?.userType)) {
throw new ForbiddenException('Access denied for this portal');
}
return true;
}
}
return MixedUserTypeGuard;
}
@Injectable()
export class PlayerGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const { user } = context.switchToHttp().getRequest();
if (user?.userType !== 'PLAYER') throw new ForbiddenException('Player access only');
return true;
}
}
@Injectable()
export class AdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const { user } = context.switchToHttp().getRequest();
if (user?.userType !== 'ADMIN') throw new ForbiddenException('Admin access only');
return true;
}
}
@Injectable()
export class AgentGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const { user } = context.switchToHttp().getRequest();
if (user?.userType !== 'AGENT') throw new ForbiddenException('Agent access only');
return true;
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { AuthModule } from './auth.module';
import { UsersModule } from './users.module';
@Module({
imports: [AuthModule, UsersModule],
exports: [AuthModule, UsersModule],
})
export class IdentityModule {}

View File

@@ -0,0 +1,54 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { JwtPayload } from './auth.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
config: ConfigService,
private prisma: PrismaService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get('JWT_SECRET', 'dev-secret'),
});
}
async validate(payload: JwtPayload) {
const user = await this.prisma.user.findUnique({
where: { id: BigInt(payload.sub) },
include: {
adminRole: {
include: {
role: {
include: {
permissions: {
include: { permission: true },
},
},
},
},
},
},
});
if (!user || user.status !== 'ACTIVE') {
throw new UnauthorizedException();
}
const permissions =
user.adminRole?.role?.permissions?.map((rp) => rp.permission.code) ?? [];
return {
id: user.id,
username: user.username,
userType: user.userType,
parentId: user.parentId,
agentLevel: user.agentLevel,
locale: user.locale,
role: payload.role,
permissions,
};
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
@Module({
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

View File

@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
async findById(id: bigint) {
return this.prisma.user.findUnique({
where: { id },
include: { wallet: true, agentProfile: true, preferences: true },
});
}
async updateLocale(userId: bigint, locale: string) {
await this.prisma.user.update({
where: { id: userId },
data: { locale },
});
await this.prisma.userPreference.upsert({
where: { userId },
create: { userId, locale },
update: { locale },
});
return { locale };
}
async listPlayers(page = 1, pageSize = 20, parentId?: bigint) {
const where = { userType: 'PLAYER', ...(parentId ? { parentId } : {}) };
const skip = (page - 1) * pageSize;
const [items, total] = await Promise.all([
this.prisma.user.findMany({
where,
include: { wallet: true },
skip,
take: pageSize,
orderBy: { createdAt: 'desc' },
}),
this.prisma.user.count({ where }),
]);
return { items, total, page, pageSize };
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { WalletService } from './wallet.service';
@Module({
providers: [WalletService],
exports: [WalletService],
})
export class WalletModule {}

View File

@@ -0,0 +1,222 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateTransactionId } from '../../shared/common/decorators';
@Injectable()
export class WalletService {
constructor(private prisma: PrismaService) {}
async getWallet(userId: bigint) {
const wallet = await this.prisma.wallet.findUnique({ where: { userId } });
if (!wallet) throw new BadRequestException('Wallet not found');
return wallet;
}
async createWallet(userId: bigint, currency = 'USD') {
return this.prisma.wallet.create({
data: { userId, currency },
});
}
private async lockWallet(tx: Parameters<Parameters<PrismaService['$transaction']>[0]>[0], userId: bigint) {
const wallets = await tx.$queryRaw<Array<{ id: bigint; available_balance: Decimal; frozen_balance: Decimal; version: number }>>`
SELECT id, available_balance, frozen_balance, version FROM wallets WHERE user_id = ${userId} FOR UPDATE
`;
if (!wallets.length) throw new BadRequestException('Wallet not found');
return wallets[0];
}
async deposit(
userId: bigint,
amount: Decimal | number,
operatorId: bigint,
remark?: string,
referenceId?: string,
) {
const amt = new Decimal(amount);
if (amt.lte(0)) throw new BadRequestException('Amount must be positive');
return this.prisma.$transaction(async (tx) => {
const w = await this.lockWallet(tx, userId);
const balanceBefore = new Decimal(w.available_balance);
const balanceAfter = balanceBefore.add(amt);
await tx.wallet.update({
where: { id: w.id },
data: {
availableBalance: balanceAfter,
version: { increment: 1 },
},
});
await tx.walletTransaction.create({
data: {
transactionId: generateTransactionId(),
userId,
walletId: w.id,
transactionType: 'MANUAL_DEPOSIT',
amount: amt,
balanceBefore,
balanceAfter,
frozenBefore: w.frozen_balance,
frozenAfter: w.frozen_balance,
referenceType: 'DEPOSIT',
referenceId,
operatorId,
remark,
},
});
return { balanceAfter };
});
}
async withdraw(
userId: bigint,
amount: Decimal | number,
operatorId: bigint,
remark?: string,
referenceId?: string,
) {
const amt = new Decimal(amount);
if (amt.lte(0)) throw new BadRequestException('Amount must be positive');
return this.prisma.$transaction(async (tx) => {
const w = await this.lockWallet(tx, userId);
const balanceBefore = new Decimal(w.available_balance);
if (balanceBefore.lt(amt)) throw new BadRequestException('Insufficient balance');
const balanceAfter = balanceBefore.sub(amt);
await tx.wallet.update({
where: { id: w.id },
data: {
availableBalance: balanceAfter,
version: { increment: 1 },
},
});
await tx.walletTransaction.create({
data: {
transactionId: generateTransactionId(),
userId,
walletId: w.id,
transactionType: 'MANUAL_WITHDRAW',
amount: amt.neg(),
balanceBefore,
balanceAfter,
frozenBefore: w.frozen_balance,
frozenAfter: w.frozen_balance,
referenceType: 'WITHDRAW',
referenceId,
operatorId,
remark,
},
});
return { balanceAfter };
});
}
async freezeForBet(userId: bigint, stake: Decimal | number, betId: string) {
const amt = new Decimal(stake);
return this.prisma.$transaction(async (tx) => {
const w = await this.lockWallet(tx, userId);
const avail = new Decimal(w.available_balance);
if (avail.lt(amt)) throw new BadRequestException('Insufficient balance');
const balanceAfter = avail.sub(amt);
const frozenAfter = new Decimal(w.frozen_balance).add(amt);
await tx.wallet.update({
where: { id: w.id },
data: {
availableBalance: balanceAfter,
frozenBalance: frozenAfter,
version: { increment: 1 },
},
});
await tx.walletTransaction.create({
data: {
transactionId: generateTransactionId(),
userId,
walletId: w.id,
transactionType: 'BET_FREEZE',
amount: amt.neg(),
balanceBefore: avail,
balanceAfter,
frozenBefore: w.frozen_balance,
frozenAfter,
referenceType: 'BET',
referenceId: betId,
},
});
});
}
async settleBet(
userId: bigint,
stake: Decimal,
payout: Decimal,
betId: string,
result: 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE',
) {
const txTypeMap: Record<string, string> = {
WIN: 'BET_SETTLE_WIN',
LOSE: 'BET_SETTLE_LOSE',
PUSH: 'BET_SETTLE_PUSH',
VOID: 'BET_VOID_REFUND',
HALF_WIN: 'BET_SETTLE_WIN',
HALF_LOSE: 'BET_SETTLE_LOSE',
};
return this.prisma.$transaction(async (tx) => {
const w = await this.lockWallet(tx, userId);
const avail = new Decimal(w.available_balance);
const frozen = new Decimal(w.frozen_balance);
const frozenAfter = frozen.sub(stake);
const balanceAfter = avail.add(payout);
await tx.wallet.update({
where: { id: w.id },
data: {
availableBalance: balanceAfter,
frozenBalance: frozenAfter.lt(0) ? new Decimal(0) : frozenAfter,
version: { increment: 1 },
},
});
await tx.walletTransaction.create({
data: {
transactionId: generateTransactionId(),
userId,
walletId: w.id,
transactionType: txTypeMap[result] || 'BET_SETTLE_WIN',
amount: payout,
balanceBefore: avail,
balanceAfter,
frozenBefore: frozen,
frozenAfter: frozenAfter.lt(0) ? new Decimal(0) : frozenAfter,
referenceType: 'BET',
referenceId: betId,
},
});
});
}
async getTransactions(userId: bigint, page = 1, pageSize = 20) {
const skip = (page - 1) * pageSize;
const [items, total] = await Promise.all([
this.prisma.walletTransaction.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.walletTransaction.count({ where: { userId } }),
]);
return { items, total, page, pageSize };
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MarketsService } from './markets.service';
@Module({
providers: [MarketsService],
exports: [MarketsService],
})
export class MarketsModule {}

View File

@@ -0,0 +1,203 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { Decimal } from '@prisma/client/runtime/library';
import {
FT_CORRECT_SCORE_TEMPLATE,
HT_CORRECT_SCORE_TEMPLATE,
} from '../settlement/domain/settlement-calculator';
@Injectable()
export class MarketsService {
constructor(private prisma: PrismaService) {}
async generateTemplates(matchId: bigint, marketTypes: string[]) {
const match = await this.prisma.match.findUnique({ where: { id: matchId } });
if (!match) throw new NotFoundException('Match not found');
const created = [];
for (const marketType of marketTypes) {
const existing = await this.prisma.market.findFirst({
where: { matchId, marketType },
});
if (existing) continue;
const config = this.getMarketConfig(marketType);
const market = await this.prisma.market.create({
data: {
matchId,
marketType,
period: config.period,
lineValue: config.lineValue,
allowSingle: true,
allowParlay: config.allowParlay,
sortOrder: config.sortOrder,
selections: {
create: config.selections.map((s, i) => ({
selectionCode: s.code,
selectionName: s.name,
odds: s.odds ?? 1.01,
sortOrder: i,
})),
},
},
include: { selections: true },
});
created.push(market);
}
return created;
}
private getMarketConfig(marketType: string) {
const configs: Record<string, {
period: string;
lineValue?: number;
allowParlay: boolean;
sortOrder: number;
selections: Array<{ code: string; name: string; odds?: number }>;
}> = {
FT_1X2: {
period: 'FT',
allowParlay: true,
sortOrder: 1,
selections: [
{ code: 'HOME', name: 'Home', odds: 2.5 },
{ code: 'DRAW', name: 'Draw', odds: 3.2 },
{ code: 'AWAY', name: 'Away', odds: 2.8 },
],
},
HT_1X2: {
period: 'HT',
allowParlay: true,
sortOrder: 5,
selections: [
{ code: 'HOME', name: 'HT Home', odds: 3.0 },
{ code: 'DRAW', name: 'HT Draw', odds: 2.0 },
{ code: 'AWAY', name: 'HT Away', odds: 3.5 },
],
},
FT_HANDICAP: {
period: 'FT',
lineValue: -0.5,
allowParlay: true,
sortOrder: 2,
selections: [
{ code: 'HOME', name: 'Home -0.5', odds: 1.9 },
{ code: 'AWAY', name: 'Away +0.5', odds: 1.9 },
],
},
HT_HANDICAP: {
period: 'HT',
lineValue: -0.5,
allowParlay: true,
sortOrder: 6,
selections: [
{ code: 'HOME', name: 'HT Home -0.5', odds: 1.9 },
{ code: 'AWAY', name: 'HT Away +0.5', odds: 1.9 },
],
},
FT_OVER_UNDER: {
period: 'FT',
lineValue: 2.5,
allowParlay: true,
sortOrder: 3,
selections: [
{ code: 'OVER', name: 'Over 2.5', odds: 1.85 },
{ code: 'UNDER', name: 'Under 2.5', odds: 1.95 },
],
},
HT_OVER_UNDER: {
period: 'HT',
lineValue: 1.5,
allowParlay: true,
sortOrder: 7,
selections: [
{ code: 'OVER', name: 'HT Over 1.5', odds: 2.0 },
{ code: 'UNDER', name: 'HT Under 1.5', odds: 1.75 },
],
},
FT_ODD_EVEN: {
period: 'FT',
allowParlay: true,
sortOrder: 4,
selections: [
{ code: 'ODD', name: 'Odd', odds: 1.9 },
{ code: 'EVEN', name: 'Even', odds: 1.9 },
],
},
FT_CORRECT_SCORE: {
period: 'FT',
allowParlay: true,
sortOrder: 8,
selections: FT_CORRECT_SCORE_TEMPLATE.map((code) => ({
code,
name: code.replace('SCORE_', '').replace('_', '-') || code,
odds: 8.0,
})),
},
HT_CORRECT_SCORE: {
period: 'HT',
allowParlay: true,
sortOrder: 9,
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
code,
name: code.replace('SCORE_', '').replace('_', '-') || code,
odds: 6.0,
})),
},
SH_CORRECT_SCORE: {
period: 'SH',
allowParlay: true,
sortOrder: 10,
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
code,
name: code.replace('SCORE_', '').replace('_', '-') || code,
odds: 6.0,
})),
},
};
const config = configs[marketType];
if (!config) throw new BadRequestException(`Unknown market type: ${marketType}`);
return config;
}
async updateOdds(selectionId: bigint, newOdds: number, operatorId: bigint) {
const selection = await this.prisma.marketSelection.findUnique({
where: { id: selectionId },
});
if (!selection) throw new NotFoundException('Selection not found');
if (newOdds <= 1) throw new BadRequestException('Odds must be > 1.00');
const newVersion = selection.oddsVersion + BigInt(1);
return this.prisma.$transaction(async (tx) => {
await tx.oddsChangeLog.create({
data: {
selectionId,
oldOdds: selection.odds,
newOdds,
oddsVersion: newVersion,
changedBy: operatorId,
},
});
return tx.marketSelection.update({
where: { id: selectionId },
data: { odds: newOdds, oddsVersion: newVersion },
});
});
}
async batchUpdateOdds(
updates: Array<{ selectionId: bigint; odds: number }>,
operatorId: bigint,
) {
const results = [];
for (const u of updates) {
results.push(await this.updateOdds(u.selectionId, u.odds, operatorId));
}
return results;
}
}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { AuditService } from './audit.service';
@Global()
@Module({
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}

View File

@@ -0,0 +1,48 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../shared/prisma/prisma.service';
@Injectable()
export class AuditService {
constructor(private prisma: PrismaService) {}
async log(data: {
operatorId?: bigint;
operatorType: string;
action: string;
module: string;
targetType?: string;
targetId?: string;
beforeData?: unknown;
afterData?: unknown;
ipAddress?: string;
}) {
return this.prisma.auditLog.create({
data: {
operatorId: data.operatorId,
operatorType: data.operatorType,
action: data.action,
module: data.module,
targetType: data.targetType,
targetId: data.targetId,
beforeData: data.beforeData ? JSON.stringify(data.beforeData) : null,
afterData: data.afterData ? JSON.stringify(data.afterData) : null,
ipAddress: data.ipAddress,
},
});
}
async list(page = 1, pageSize = 50, module?: string) {
const skip = (page - 1) * pageSize;
const where = module ? { module } : {};
const [items, total] = await Promise.all([
this.prisma.auditLog.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.auditLog.count({ where }),
]);
return { items, total, page, pageSize };
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CashbackService } from './cashback.service';
import { WalletModule } from '../../ledger/wallet.module';
@Module({
imports: [WalletModule],
providers: [CashbackService],
exports: [CashbackService],
})
export class CashbackModule {}

View File

@@ -0,0 +1,108 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../../shared/prisma/prisma.service';
import { WalletService } from '../../ledger/wallet.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateBatchNo } from '../../../shared/common/decorators';
@Injectable()
export class CashbackService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
) {}
async previewBatch(periodStart: Date, periodEnd: Date) {
const settledBets = await this.prisma.bet.findMany({
where: {
status: { in: ['WON', 'LOST', 'SETTLED'] },
settledAt: { gte: periodStart, lte: periodEnd },
},
include: { user: { include: { agentProfile: true } } },
});
const playerStakes = new Map<string, { userId: bigint; stake: Decimal; rate: Decimal }>();
for (const bet of settledBets) {
if (bet.status === 'PUSH' || bet.status === 'VOID') continue;
const key = bet.userId.toString();
const existing = playerStakes.get(key) ?? {
userId: bet.userId,
stake: new Decimal(0),
rate: new Decimal(0.01),
};
existing.stake = existing.stake.add(bet.stake);
playerStakes.set(key, existing);
}
const items = Array.from(playerStakes.values()).map((p) => ({
userId: p.userId,
effectiveStake: p.stake,
rate: p.rate,
amount: p.stake.mul(p.rate),
}));
const totalAmount = items.reduce((s, i) => s.add(i.amount), new Decimal(0));
const batch = await this.prisma.cashbackBatch.create({
data: {
batchNo: generateBatchNo('CB'),
periodStart,
periodEnd,
status: 'PREVIEW',
totalAmount,
playerCount: items.length,
},
});
for (const item of items) {
await this.prisma.cashbackItem.create({
data: {
batchId: batch.id,
userId: item.userId,
effectiveStake: item.effectiveStake,
rate: item.rate,
amount: item.amount,
},
});
}
return { batch, items, totalAmount };
}
async confirmBatch(batchId: bigint, operatorId: bigint) {
const batch = await this.prisma.cashbackBatch.findUnique({
where: { id: batchId },
include: { items: true },
});
if (!batch) throw new BadRequestException('Batch not found');
if (batch.status !== 'PREVIEW') throw new BadRequestException('Already confirmed');
for (const item of batch.items) {
if (item.amount.gt(0)) {
await this.wallet.deposit(
item.userId,
item.amount,
operatorId,
`Cashback batch ${batch.batchNo}`,
batch.batchNo,
);
}
}
await this.prisma.cashbackBatch.update({
where: { id: batchId },
data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId },
});
return { success: true };
}
async getUserCashbacks(userId: bigint) {
return this.prisma.cashbackItem.findMany({
where: { userId },
include: { batch: true },
orderBy: { createdAt: 'desc' },
});
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { ContentService } from './content.service';
@Module({
providers: [ContentService],
exports: [ContentService],
})
export class ContentModule {}

View File

@@ -0,0 +1,59 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../shared/prisma/prisma.service';
@Injectable()
export class ContentService {
constructor(private prisma: PrismaService) {}
async listActive(contentType: string, locale: string) {
const now = new Date();
const items = await this.prisma.content.findMany({
where: {
contentType,
status: 'ACTIVE',
OR: [{ startTime: null }, { startTime: { lte: now } }],
AND: [{ OR: [{ endTime: null }, { endTime: { gte: now } }] }],
},
include: { translations: true },
orderBy: { sortOrder: 'asc' },
});
return items.map((item) => {
const t =
item.translations.find((tr) => tr.locale === locale) ||
item.translations.find((tr) => tr.locale === 'en-US') ||
item.translations[0];
return { ...item, translation: t };
});
}
async create(data: {
contentType: string;
sortOrder?: number;
linkType?: string;
linkTarget?: string;
translations: Array<{ locale: string; title?: string; body?: string; imageUrl?: string }>;
}) {
return this.prisma.content.create({
data: {
contentType: data.contentType,
sortOrder: data.sortOrder ?? 0,
linkType: data.linkType,
linkTarget: data.linkTarget,
status: 'ACTIVE',
translations: {
create: data.translations,
},
},
include: { translations: true },
});
}
async listAll(contentType?: string) {
return this.prisma.content.findMany({
where: contentType ? { contentType } : {},
include: { translations: true },
orderBy: { sortOrder: 'asc' },
});
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { I18nService } from './i18n.service';
@Module({
providers: [I18nService],
exports: [I18nService],
})
export class I18nModule {}

View File

@@ -0,0 +1,55 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../shared/prisma/prisma.service';
import { DEFAULT_LOCALE } from '@thebet365/shared';
const FALLBACK_ORDER = ['en-US', 'zh-CN', 'ms-MY'];
@Injectable()
export class I18nService {
constructor(private prisma: PrismaService) {}
async getMessages(locale: string) {
const messages = await this.prisma.i18nMessage.findMany({
where: { locale: { in: [locale, ...FALLBACK_ORDER] } },
});
const byKey: Record<string, Record<string, string>> = {};
for (const m of messages) {
if (!byKey[m.msgKey]) byKey[m.msgKey] = {};
byKey[m.msgKey][m.locale] = m.value;
}
const result: Record<string, string> = {};
for (const [key, locales] of Object.entries(byKey)) {
result[key] =
locales[locale] ||
FALLBACK_ORDER.map((l) => locales[l]).find(Boolean) ||
key;
}
return result;
}
async upsertMessage(msgKey: string, locale: string, value: string) {
return this.prisma.i18nMessage.upsert({
where: { msgKey_locale: { msgKey, locale } },
create: { msgKey, locale, value },
update: { value },
});
}
async listMissing() {
const keys = await this.prisma.i18nMessage.groupBy({ by: ['msgKey'] });
const locales = ['zh-CN', 'ms-MY', 'en-US'];
const missing = [];
for (const { msgKey } of keys) {
for (const locale of locales) {
const exists = await this.prisma.i18nMessage.findUnique({
where: { msgKey_locale: { msgKey, locale } },
});
if (!exists) missing.push({ msgKey, locale });
}
}
return missing;
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuditModule } from './audit/audit.module';
import { CashbackModule } from './cashback/cashback.module';
import { ContentModule } from './content/content.module';
import { I18nModule } from './i18n/i18n.module';
@Module({
imports: [AuditModule, CashbackModule, ContentModule, I18nModule],
exports: [AuditModule, CashbackModule, ContentModule, I18nModule],
})
export class OperationsModule {}

View File

@@ -0,0 +1,187 @@
import {
settleSelection,
calculatePayout,
calculateParlayPayout,
isQuarterHandicapOrTotal,
ScoreInput,
} from './settlement-calculator';
describe('SettlementCalculator', () => {
const score: ScoreInput = { htHome: 1, htAway: 0, ftHome: 2, ftAway: 1 };
describe('FT_1X2', () => {
it('S001: home win', () => {
expect(
settleSelection({ marketType: 'FT_1X2', selectionCode: 'HOME', score }),
).toBe('WIN');
expect(
settleSelection({ marketType: 'FT_1X2', selectionCode: 'DRAW', score }),
).toBe('LOSE');
});
it('S002: draw', () => {
const draw = { htHome: 0, htAway: 0, ftHome: 1, ftAway: 1 };
expect(
settleSelection({ marketType: 'FT_1X2', selectionCode: 'DRAW', score: draw }),
).toBe('WIN');
});
});
describe('HT_1X2', () => {
it('S003: half time result', () => {
expect(
settleSelection({ marketType: 'HT_1X2', selectionCode: 'HOME', score }),
).toBe('WIN');
});
});
describe('FT_ODD_EVEN', () => {
it('S004: 0-0 is even', () => {
const s = { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 };
expect(
settleSelection({ marketType: 'FT_ODD_EVEN', selectionCode: 'EVEN', score: s }),
).toBe('WIN');
});
});
describe('Correct Score', () => {
it('S005: exact score 2-1', () => {
expect(
settleSelection({
marketType: 'FT_CORRECT_SCORE',
selectionCode: 'SCORE_2_1',
score,
}),
).toBe('WIN');
});
it('S006: other home win', () => {
const s = { htHome: 2, htAway: 0, ftHome: 5, ftAway: 0 };
expect(
settleSelection({
marketType: 'FT_CORRECT_SCORE',
selectionCode: 'OTHER_HOME',
score: s,
templateScores: ['SCORE_1_0', 'SCORE_2_0'],
}),
).toBe('WIN');
});
it('S008: second half correct score', () => {
expect(
settleSelection({
marketType: 'SH_CORRECT_SCORE',
selectionCode: 'SCORE_1_1',
score,
}),
).toBe('WIN');
});
});
describe('Handicap', () => {
it('S009: full win', () => {
const r = settleSelection({
marketType: 'FT_HANDICAP',
selectionCode: 'HOME',
handicapLine: -1,
score: { htHome: 0, htAway: 0, ftHome: 2, ftAway: 0 },
});
expect(r).toBe('WIN');
expect(calculatePayout(100, 1.85, r).toNumber()).toBe(185);
});
it('S010: push', () => {
const r = settleSelection({
marketType: 'FT_HANDICAP',
selectionCode: 'HOME',
handicapLine: -1,
score: { htHome: 0, htAway: 0, ftHome: 1, ftAway: 0 },
});
expect(r).toBe('PUSH');
expect(calculatePayout(100, 1.85, r).toNumber()).toBe(100);
});
it('S011: half win -0.25', () => {
const r = settleSelection({
marketType: 'FT_HANDICAP',
selectionCode: 'HOME',
handicapLine: -0.25,
score: { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 },
});
expect(r).toBe('HALF_LOSE');
});
});
describe('Over/Under', () => {
it('S013: over 2.5 wins with 3 goals', () => {
const s = { htHome: 1, htAway: 1, ftHome: 2, ftAway: 1 };
expect(
settleSelection({
marketType: 'FT_OVER_UNDER',
selectionCode: 'OVER',
totalLine: 2.5,
score: s,
}),
).toBe('WIN');
});
it('S014: push on integer line', () => {
const s = { htHome: 1, htAway: 0, ftHome: 1, ftAway: 1 };
expect(
settleSelection({
marketType: 'FT_OVER_UNDER',
selectionCode: 'OVER',
totalLine: 2,
score: s,
}),
).toBe('PUSH');
});
});
describe('Parlay', () => {
it('S016: all win', () => {
const result = calculateParlayPayout(100, [
{ odds: 1.8, result: 'WIN' },
{ odds: 2.0, result: 'WIN' },
]);
expect(result.betResult).toBe('WON');
expect(result.payout.toNumber()).toBe(360);
});
it('S017: one lose', () => {
const result = calculateParlayPayout(100, [
{ odds: 1.8, result: 'WIN' },
{ odds: 2.0, result: 'LOSE' },
]);
expect(result.betResult).toBe('LOST');
expect(result.payout.toNumber()).toBe(0);
});
it('S018: one push', () => {
const result = calculateParlayPayout(100, [
{ odds: 1.8, result: 'WIN' },
{ odds: 2.0, result: 'PUSH' },
{ odds: 1.9, result: 'WIN' },
]);
expect(result.betResult).toBe('WON');
expect(result.payout.toNumber()).toBe(342);
});
it('S019: all push', () => {
const result = calculateParlayPayout(100, [
{ odds: 1.8, result: 'PUSH' },
{ odds: 2.0, result: 'VOID' },
]);
expect(result.betResult).toBe('PUSH');
expect(result.payout.toNumber()).toBe(100);
});
});
describe('Quarter line detection', () => {
it('detects quarter lines', () => {
expect(isQuarterHandicapOrTotal(-0.25)).toBe(true);
expect(isQuarterHandicapOrTotal(2.5)).toBe(false);
expect(isQuarterHandicapOrTotal(-1)).toBe(false);
});
});
});

View File

@@ -0,0 +1,279 @@
import Decimal from 'decimal.js';
export type SelectionResult = 'WIN' | 'HALF_WIN' | 'PUSH' | 'HALF_LOSE' | 'LOSE' | 'VOID';
export interface ScoreInput {
htHome: number;
htAway: number;
ftHome: number;
ftAway: number;
}
export interface SettlementInput {
marketType: string;
selectionCode: string;
handicapLine?: number | null;
totalLine?: number | null;
score: ScoreInput;
templateScores?: string[];
}
export function getShScore(score: ScoreInput): { home: number; away: number } {
return {
home: score.ftHome - score.htHome,
away: score.ftAway - score.htAway,
};
}
function isQuarterLine(line: number): boolean {
const frac = Math.abs(line % 1);
return Math.abs(frac - 0.25) < 0.001 || Math.abs(frac - 0.75) < 0.001;
}
function splitQuarterLine(line: number): [number, number] {
const sign = line >= 0 ? 1 : -1;
const abs = Math.abs(line);
const lower = Math.floor(abs * 2) / 2 * sign;
const upper = (Math.floor(abs * 2) + 1) / 2 * sign;
if (Math.abs(abs % 1 - 0.25) < 0.001) {
return [lower, upper];
}
// 0.75 case: e.g. -0.75 => -0.5 and -1
const l = Math.floor(abs) * sign;
const u = (Math.floor(abs) + 0.5) * sign;
return abs % 1 > 0.5 ? [l, u] : [lower, upper];
}
function settleHandicap(
teamGoals: number,
oppGoals: number,
handicap: number,
isHome: boolean,
): SelectionResult {
const adj = teamGoals + handicap - oppGoals;
if (!isQuarterLine(handicap)) {
if (adj > 0) return 'WIN';
if (adj === 0) return 'PUSH';
return 'LOSE';
}
const [line1, line2] = splitQuarterLine(handicap);
const r1 = teamGoals + line1 - oppGoals;
const r2 = teamGoals + line2 - oppGoals;
const results: SelectionResult[] = [];
for (const r of [r1, r2]) {
if (r > 0) results.push('WIN');
else if (r === 0) results.push('PUSH');
else results.push('LOSE');
}
const winCount = results.filter((r) => r === 'WIN').length;
const loseCount = results.filter((r) => r === 'LOSE').length;
if (winCount === 2) return 'WIN';
if (loseCount === 2) return 'LOSE';
if (winCount === 1 && loseCount === 0) return 'HALF_WIN';
if (loseCount === 1 && winCount === 0) return 'HALF_LOSE';
return 'PUSH';
}
function settleOverUnder(
totalGoals: number,
line: number,
isOver: boolean,
): SelectionResult {
if (!isQuarterLine(line)) {
if (isOver) {
if (totalGoals > line) return 'WIN';
if (totalGoals === line) return 'PUSH';
return 'LOSE';
}
if (totalGoals < line) return 'WIN';
if (totalGoals === line) return 'PUSH';
return 'LOSE';
}
const [line1, line2] = splitQuarterLine(line);
const r1 = settleOverUnder(totalGoals, line1, isOver);
const r2 = settleOverUnder(totalGoals, line2, isOver);
const winCount = [r1, r2].filter((r) => r === 'WIN').length;
const loseCount = [r1, r2].filter((r) => r === 'LOSE').length;
if (winCount === 2) return 'WIN';
if (loseCount === 2) return 'LOSE';
if (winCount === 1) return 'HALF_WIN';
if (loseCount === 1) return 'HALF_LOSE';
return 'PUSH';
}
function parseScoreCode(code: string): { home: number; away: number } | null {
const match = code.match(/SCORE_(\d+)_(\d+)/);
if (match) return { home: parseInt(match[1]), away: parseInt(match[2]) };
return null;
}
function settleCorrectScore(
home: number,
away: number,
selectionCode: string,
templateScores: string[],
): SelectionResult {
const parsed = parseScoreCode(selectionCode);
if (parsed) {
return parsed.home === home && parsed.away === away ? 'WIN' : 'LOSE';
}
const actualKey = `SCORE_${home}_${away}`;
if (templateScores.includes(actualKey)) {
return 'LOSE';
}
const isDraw = home === away;
const isHomeWin = home > away;
if (selectionCode === 'OTHER_DRAW' && isDraw) return 'WIN';
if (selectionCode === 'OTHER_HOME' && isHomeWin) return 'WIN';
if (selectionCode === 'OTHER_AWAY' && !isHomeWin && !isDraw) return 'WIN';
return 'LOSE';
}
export function settleSelection(input: SettlementInput): SelectionResult {
const { marketType, selectionCode, handicapLine, totalLine, score } = input;
const templates = input.templateScores ?? [];
switch (marketType) {
case 'FT_1X2': {
if (selectionCode === 'HOME') return score.ftHome > score.ftAway ? 'WIN' : 'LOSE';
if (selectionCode === 'DRAW') return score.ftHome === score.ftAway ? 'WIN' : 'LOSE';
if (selectionCode === 'AWAY') return score.ftHome < score.ftAway ? 'WIN' : 'LOSE';
break;
}
case 'HT_1X2': {
if (selectionCode === 'HOME') return score.htHome > score.htAway ? 'WIN' : 'LOSE';
if (selectionCode === 'DRAW') return score.htHome === score.htAway ? 'WIN' : 'LOSE';
if (selectionCode === 'AWAY') return score.htHome < score.htAway ? 'WIN' : 'LOSE';
break;
}
case 'FT_ODD_EVEN': {
const total = score.ftHome + score.ftAway;
const isOdd = total % 2 === 1;
if (selectionCode === 'ODD') return isOdd ? 'WIN' : 'LOSE';
if (selectionCode === 'EVEN') return !isOdd ? 'WIN' : 'LOSE';
break;
}
case 'FT_HANDICAP': {
const line = handicapLine ?? 0;
const isHome = selectionCode === 'HOME';
const goals = isHome ? score.ftHome : score.ftAway;
const opp = isHome ? score.ftAway : score.ftHome;
return settleHandicap(goals, opp, isHome ? line : -line, isHome);
}
case 'HT_HANDICAP': {
const line = handicapLine ?? 0;
const isHome = selectionCode === 'HOME';
const goals = isHome ? score.htHome : score.htAway;
const opp = isHome ? score.htAway : score.htHome;
return settleHandicap(goals, opp, isHome ? line : -line, isHome);
}
case 'FT_OVER_UNDER': {
const total = score.ftHome + score.ftAway;
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
}
case 'HT_OVER_UNDER': {
const total = score.htHome + score.htAway;
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
}
case 'FT_CORRECT_SCORE':
return settleCorrectScore(score.ftHome, score.ftAway, selectionCode, templates);
case 'HT_CORRECT_SCORE':
return settleCorrectScore(score.htHome, score.htAway, selectionCode, templates);
case 'SH_CORRECT_SCORE': {
const sh = getShScore(score);
return settleCorrectScore(sh.home, sh.away, selectionCode, templates);
}
case 'OUTRIGHT_WINNER':
return selectionCode === `TEAM_${input.score.ftHome}` ? 'WIN' : 'LOSE';
}
return 'LOSE';
}
export function calculatePayout(
stake: Decimal | number,
odds: Decimal | number,
result: SelectionResult,
): Decimal {
const s = new Decimal(stake);
const o = new Decimal(odds);
switch (result) {
case 'WIN':
return s.mul(o);
case 'HALF_WIN':
return s.div(2).mul(o).add(s.div(2));
case 'PUSH':
case 'VOID':
return s;
case 'HALF_LOSE':
return s.div(2);
case 'LOSE':
return new Decimal(0);
default:
return new Decimal(0);
}
}
export function calculateParlayPayout(
stake: Decimal | number,
selections: Array<{ odds: Decimal | number; result: SelectionResult }>,
): { betResult: 'WON' | 'LOST' | 'PUSH'; payout: Decimal; effectiveOdds: Decimal } {
const s = new Decimal(stake);
if (selections.some((sel) => sel.result === 'LOSE')) {
return { betResult: 'LOST', payout: new Decimal(0), effectiveOdds: new Decimal(0) };
}
let combinedOdds = new Decimal(1);
for (const sel of selections) {
if (sel.result === 'WIN') {
combinedOdds = combinedOdds.mul(sel.odds);
} else if (sel.result === 'HALF_WIN') {
combinedOdds = combinedOdds.mul(new Decimal(sel.odds).add(1).div(2));
} else if (sel.result === 'HALF_LOSE') {
combinedOdds = combinedOdds.mul(0.5);
}
// PUSH/VOID => odds 1.00
}
const allPush = selections.every(
(sel) => sel.result === 'PUSH' || sel.result === 'VOID',
);
if (allPush) {
return { betResult: 'PUSH', payout: s, effectiveOdds: new Decimal(1) };
}
return { betResult: 'WON', payout: s.mul(combinedOdds), effectiveOdds: combinedOdds };
}
export function isQuarterHandicapOrTotal(line: number | null | undefined): boolean {
if (line == null) return false;
return isQuarterLine(line);
}
export const FT_CORRECT_SCORE_TEMPLATE = [
'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'SCORE_3_3', 'SCORE_4_4', 'OTHER_DRAW',
'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'SCORE_3_1', 'SCORE_3_2',
'SCORE_4_0', 'SCORE_4_1', 'SCORE_4_2', 'SCORE_4_3', 'OTHER_HOME',
'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'SCORE_1_3', 'SCORE_2_3',
'SCORE_0_4', 'SCORE_1_4', 'SCORE_2_4', 'SCORE_3_4', 'OTHER_AWAY',
];
export const HT_CORRECT_SCORE_TEMPLATE = [
'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'OTHER_DRAW',
'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'OTHER_HOME',
'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'OTHER_AWAY',
];

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { SettlementService } from './settlement.service';
import { WalletModule } from '../ledger/wallet.module';
import { AgentsModule } from '../agent/agents.module';
@Module({
imports: [WalletModule, AgentsModule],
providers: [SettlementService],
exports: [SettlementService],
})
export class SettlementModule {}

View File

@@ -0,0 +1,312 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { WalletService } from '../ledger/wallet.service';
import { AgentsService } from '../agent/agents.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateBatchNo } from '../../shared/common/decorators';
import {
settleSelection,
calculatePayout,
calculateParlayPayout,
ScoreInput,
FT_CORRECT_SCORE_TEMPLATE,
HT_CORRECT_SCORE_TEMPLATE,
} from './domain/settlement-calculator';
@Injectable()
export class SettlementService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
private agents: AgentsService,
) {}
async recordScore(
matchId: bigint,
htHome: number,
htAway: number,
ftHome: number,
ftAway: number,
operatorId: bigint,
) {
await this.prisma.matchScore.upsert({
where: { matchId },
create: { matchId, htHomeScore: htHome, htAwayScore: htAway, ftHomeScore: ftHome, ftAwayScore: ftAway, recordedBy: operatorId },
update: { htHomeScore: htHome, htAwayScore: htAway, ftHomeScore: ftHome, ftAwayScore: ftAway, recordedBy: operatorId },
});
await this.prisma.match.update({
where: { id: matchId },
data: { status: 'PENDING_SETTLEMENT' },
});
return { matchId, htHome, htAway, ftHome, ftAway };
}
async previewSettlement(matchId: bigint, operatorId: bigint) {
const score = await this.prisma.matchScore.findUnique({ where: { matchId } });
if (!score) throw new BadRequestException('Score not recorded');
const scoreInput: ScoreInput = {
htHome: score.htHomeScore ?? 0,
htAway: score.htAwayScore ?? 0,
ftHome: score.ftHomeScore ?? 0,
ftAway: score.ftAwayScore ?? 0,
};
const pendingBets = await this.prisma.bet.findMany({
where: {
status: 'PENDING',
selections: { some: { matchId } },
},
include: { selections: true },
});
const parlayBets = await this.prisma.bet.findMany({
where: {
status: 'PENDING',
betType: 'PARLAY',
selections: { some: { matchId } },
},
include: { selections: true },
});
let totalPayout = new Decimal(0);
let totalRefund = new Decimal(0);
const items: Array<{ betId: bigint; betNo: string; result: string; payout: Decimal }> = [];
for (const bet of pendingBets) {
if (bet.betType === 'SINGLE') {
const sel = bet.selections[0];
const template =
sel.marketType === 'FT_CORRECT_SCORE'
? FT_CORRECT_SCORE_TEMPLATE
: sel.marketType.includes('CORRECT_SCORE')
? HT_CORRECT_SCORE_TEMPLATE
: [];
const result = settleSelection({
marketType: sel.marketType,
selectionCode: sel.selectionNameSnapshot.includes('-')
? `SCORE_${sel.selectionNameSnapshot.replace('-', '_')}`
: sel.selectionNameSnapshot,
handicapLine: sel.handicapLine ? Number(sel.handicapLine) : null,
totalLine: sel.totalLine ? Number(sel.totalLine) : null,
score: scoreInput,
templateScores: template,
});
const payout = calculatePayout(bet.stake, sel.odds, result);
items.push({ betId: bet.id, betNo: bet.betNo, result, payout });
if (result === 'LOSE') {
// no payout
} else if (result === 'PUSH' || result === 'VOID') {
totalRefund = totalRefund.add(bet.stake);
} else {
totalPayout = totalPayout.add(payout);
}
}
}
const batch = await this.prisma.settlementBatch.create({
data: {
matchId,
batchNo: generateBatchNo('STL'),
htHomeScore: score.htHomeScore,
htAwayScore: score.htAwayScore,
ftHomeScore: score.ftHomeScore,
ftAwayScore: score.ftAwayScore,
status: 'PREVIEW',
totalBets: pendingBets.length,
totalPayout,
totalRefund,
operatorId,
},
});
return {
batch,
score: scoreInput,
singleBetCount: pendingBets.filter((b) => b.betType === 'SINGLE').length,
parlayBetCount: parlayBets.length,
items,
totalPayout,
totalRefund,
};
}
async confirmSettlement(batchId: bigint, operatorId: bigint) {
const batch = await this.prisma.settlementBatch.findUnique({
where: { id: batchId },
include: { match: true },
});
if (!batch) throw new NotFoundException('Batch not found');
if (batch.status !== 'PREVIEW') throw new BadRequestException('Batch already confirmed');
const score = await this.prisma.matchScore.findUnique({
where: { matchId: batch.matchId },
});
if (!score) throw new BadRequestException('Score not found');
const scoreInput: ScoreInput = {
htHome: score.htHomeScore ?? 0,
htAway: score.htAwayScore ?? 0,
ftHome: score.ftHomeScore ?? 0,
ftAway: score.ftAwayScore ?? 0,
};
const pendingBets = await this.prisma.bet.findMany({
where: {
status: 'PENDING',
selections: { some: { matchId: batch.matchId } },
},
include: { selections: true, user: true },
});
const agentIds = new Set<bigint>();
await this.prisma.$transaction(async (tx) => {
for (const bet of pendingBets) {
if (bet.betType === 'SINGLE') {
const sel = bet.selections[0];
const selection = await tx.marketSelection.findUnique({
where: { id: sel.selectionId },
});
const result = settleSelection({
marketType: sel.marketType,
selectionCode: selection?.selectionCode ?? sel.selectionNameSnapshot,
handicapLine: sel.handicapLine ? Number(sel.handicapLine) : null,
totalLine: sel.totalLine ? Number(sel.totalLine) : null,
score: scoreInput,
templateScores:
sel.marketType === 'FT_CORRECT_SCORE'
? FT_CORRECT_SCORE_TEMPLATE
: HT_CORRECT_SCORE_TEMPLATE,
});
const payout = calculatePayout(bet.stake, sel.odds, result);
const betStatus =
result === 'LOSE' ? 'LOST' : result === 'PUSH' || result === 'VOID' ? 'PUSH' : 'WON';
await tx.bet.update({
where: { id: bet.id },
data: {
status: betStatus,
actualReturn: payout,
settledAt: new Date(),
},
});
await tx.betSelection.update({
where: { id: sel.id },
data: { resultStatus: result, effectiveOdds: sel.odds },
});
await this.wallet.settleBet(
bet.userId,
bet.stake,
payout,
bet.betNo,
result === 'HALF_WIN' ? 'HALF_WIN' : result === 'HALF_LOSE' ? 'HALF_LOSE' : result as 'WIN' | 'LOSE' | 'PUSH' | 'VOID',
);
if (bet.agentId) agentIds.add(bet.agentId);
await tx.settlementItem.create({
data: {
batchId,
betId: bet.id,
userId: bet.userId,
result: betStatus,
payout,
},
});
} else {
// Parlay: update this leg's result, check if all legs settled
for (const sel of bet.selections) {
if (sel.matchId?.toString() === batch.matchId.toString()) {
const selection = await tx.marketSelection.findUnique({
where: { id: sel.selectionId },
});
const result = settleSelection({
marketType: sel.marketType,
selectionCode: selection?.selectionCode ?? '',
handicapLine: sel.handicapLine ? Number(sel.handicapLine) : null,
totalLine: sel.totalLine ? Number(sel.totalLine) : null,
score: scoreInput,
});
await tx.betSelection.update({
where: { id: sel.id },
data: { resultStatus: result },
});
}
}
const updated = await tx.betSelection.findMany({ where: { betId: bet.id } });
const allHaveResult = updated.every((s) => s.resultStatus != null);
if (allHaveResult) {
const legResults = updated.map((s) => ({
odds: s.odds,
result: s.resultStatus as 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE',
}));
const parlayResult = calculateParlayPayout(bet.stake, legResults);
await tx.bet.update({
where: { id: bet.id },
data: {
status: parlayResult.betResult === 'LOST' ? 'LOST' : parlayResult.betResult === 'PUSH' ? 'PUSH' : 'WON',
actualReturn: parlayResult.payout,
settledAt: new Date(),
},
});
await this.wallet.settleBet(
bet.userId,
bet.stake,
parlayResult.payout,
bet.betNo,
parlayResult.betResult === 'LOST' ? 'LOSE' : parlayResult.betResult === 'PUSH' ? 'PUSH' : 'WIN',
);
if (bet.agentId) agentIds.add(bet.agentId);
}
}
}
await tx.settlementBatch.update({
where: { id: batchId },
data: { status: 'CONFIRMED', confirmedAt: new Date() },
});
await tx.match.update({
where: { id: batch.matchId },
data: { status: 'SETTLED' },
});
});
for (const agentId of agentIds) {
await this.agents.recalculateUsedCredit(agentId);
}
return { success: true, batchId: batchId.toString() };
}
async voidMatchBets(matchId: bigint) {
const bets = await this.prisma.bet.findMany({
where: { status: 'PENDING', selections: { some: { matchId } } },
});
for (const bet of bets) {
await this.wallet.settleBet(bet.userId, bet.stake, bet.stake, bet.betNo, 'VOID');
await this.prisma.bet.update({
where: { id: bet.id },
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
});
}
return { voidedCount: bets.length };
}
}