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 { AgentsService } from '../agent/agents.service'; import { Decimal } from '@prisma/client/runtime/library'; import { appBadRequest, appNotFound } from '../../shared/common/app-error'; import { generateBatchNo } from '../../shared/common/decorators'; import { settleSelection, calculatePayout, calculateParlayPayout, ScoreInput, MatchStatsInput, SelectionResult, } from './domain/settlement-calculator'; import { resolveSelectionCode, templateScoresForMarket, } from './domain/settlement-helpers'; const SETTLEMENT_ENTRY_STATUSES = new Set(['CLOSED', 'PENDING_SETTLEMENT', 'SETTLED']); const STAT_MARKET_REQUIREMENTS = { FT_CORNERS_HANDICAP: ['homeCorners', 'awayCorners'], FT_CORNERS_OVER_UNDER: ['homeCorners', 'awayCorners'], FT_CARDS_OVER_UNDER: ['homeCards', 'awayCards'], } as const satisfies Record; type TxClient = Prisma.TransactionClient; type PrismaClientLike = PrismaService | TxClient; type SettlementScoreSource = ScoreInput & MatchStatsInput & { winnerTeamId?: bigint | null }; type BetSelectionLeg = { id: bigint; matchId: bigint | null; marketType: string; selectionId: bigint; selectionNameSnapshot: string; handicapLine: Decimal | null; totalLine: Decimal | null; odds: Decimal; resultStatus: string | null; sortOrder: number; }; @Injectable() export class SettlementService { constructor( private prisma: PrismaService, private funds: FundsPostingService, private agents: AgentsService, ) {} private async resolveWinnerTeamCode( winnerTeamId: bigint | null | undefined, tx?: TxClient, ): Promise { if (!winnerTeamId) return null; const client: PrismaClientLike = tx ?? this.prisma; const team = await client.team.findUnique({ where: { id: winnerTeamId } }); return team?.code ?? null; } private async assertOutrightLeagueFixturesSettled(match: { leagueId: bigint; isOutright: boolean; }) { if (!match.isOutright) return; const unsettled = await this.prisma.match.count({ where: { leagueId: match.leagueId, isOutright: false, deletedAt: null, status: { notIn: ['SETTLED', 'CANCELLED'] }, }, }); if (unsettled > 0) { throw appBadRequest('OUTRIGHT_LEAGUE_FIXTURES_UNSETTLED'); } } private buildSettleInput( sel: BetSelectionLeg, selectionCode: string, scoreInput: ScoreInput, statsInput: MatchStatsInput, winnerTeamCode: string | null, ) { return { marketType: sel.marketType, selectionCode, handicapLine: sel.handicapLine != null ? Number(sel.handicapLine) : null, totalLine: sel.totalLine != null ? Number(sel.totalLine) : null, score: scoreInput, stats: statsInput, templateScores: templateScoresForMarket(sel.marketType), winnerTeamCode, }; } private settleLegResult( sel: BetSelectionLeg, selectionCode: string, scoreInput: ScoreInput, statsInput: MatchStatsInput, winnerTeamCode: string | null, ): SelectionResult { return settleSelection(this.buildSettleInput(sel, selectionCode, scoreInput, statsInput, winnerTeamCode)); } private cardTotalFromBreakdown( total: number | null | undefined, yellow: number | null | undefined, red: number | null | undefined, ): number | null { const hasBreakdown = yellow != null || red != null; if (!hasBreakdown) return total ?? null; if (yellow == null || red == null) return null; return yellow + red; } private statsInputFromSource(source: MatchStatsInput): MatchStatsInput { const homeYellowCards = source.homeYellowCards ?? null; const awayYellowCards = source.awayYellowCards ?? null; const homeRedCards = source.homeRedCards ?? null; const awayRedCards = source.awayRedCards ?? null; return { homeCorners: source.homeCorners ?? null, awayCorners: source.awayCorners ?? null, homeYellowCards, awayYellowCards, homeRedCards, awayRedCards, homeCards: this.cardTotalFromBreakdown(source.homeCards, homeYellowCards, homeRedCards), awayCards: this.cardTotalFromBreakdown(source.awayCards, awayYellowCards, awayRedCards), }; } private collectRequiredStatFields(marketTypes: Iterable) { const fields = new Set(); for (const marketType of marketTypes) { const required = STAT_MARKET_REQUIREMENTS[marketType as keyof typeof STAT_MARKET_REQUIREMENTS]; if (!required) continue; for (const field of required) fields.add(field); } return fields; } private async assertRequiredStatsAvailable( matchId: bigint, statsInput: MatchStatsInput, betMarketTypes: Iterable, client?: PrismaClientLike, ) { const db = client ?? this.prisma; const visibleMarkets = await db.market.findMany({ where: { matchId, showOnPlayer: true }, select: { marketType: true }, }); const required = this.collectRequiredStatFields([ ...visibleMarkets.map((market) => market.marketType), ...betMarketTypes, ]); const missing = [...required].filter((field) => statsInput[field] == null); if (missing.length) { throw appBadRequest('SETTLEMENT_FACTS_REQUIRED', { fields: missing.join(','), }); } } private walletResultFromSelection( result: SelectionResult, ): 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE' { if (result === 'HALF_WIN') return 'HALF_WIN'; if (result === 'HALF_LOSE') return 'HALF_LOSE'; return result as 'WIN' | 'LOSE' | 'PUSH' | 'VOID'; } private betStatusFromSelection(result: SelectionResult): 'LOST' | 'PUSH' | 'WON' { if (result === 'LOSE') return 'LOST'; if (result === 'PUSH' || result === 'VOID') return 'PUSH'; return 'WON'; } private assertMatchClosedForSettlement(status: string) { if (!SETTLEMENT_ENTRY_STATUSES.has(status)) { throw appBadRequest('MATCH_MUST_CLOSE_FOR_SETTLEMENT'); } } async recordScore( matchId: bigint, htHome: number, htAway: number, ftHome: number, ftAway: number, operatorId: bigint, winnerTeamId?: bigint, statsInput: MatchStatsInput = {}, ) { const match = await this.prisma.match.findFirst({ where: { id: matchId, deletedAt: null }, }); if (!match) throw appNotFound('MATCH_NOT_FOUND'); this.assertMatchClosedForSettlement(match.status); if (match.isOutright) { await this.assertOutrightLeagueFixturesSettled(match); } if (match.isOutright) { if (!winnerTeamId) { throw appBadRequest('SETTLEMENT_WINNER_REQUIRED'); } const team = await this.prisma.team.findUnique({ where: { id: winnerTeamId } }); if (!team) throw appBadRequest('SETTLEMENT_WINNER_NOT_FOUND'); const outrightSel = await this.prisma.marketSelection.findFirst({ where: { market: { matchId, marketType: 'OUTRIGHT_WINNER' }, selectionCode: team.code, }, }); if (!outrightSel) { throw appBadRequest('SETTLEMENT_WINNER_NOT_IN_MARKET'); } } const stats = this.statsInputFromSource(statsInput); await this.prisma.matchScore.upsert({ where: { matchId }, create: { matchId, htHomeScore: match.isOutright ? 0 : htHome, htAwayScore: match.isOutright ? 0 : htAway, ftHomeScore: match.isOutright ? 0 : ftHome, ftAwayScore: match.isOutright ? 0 : ftAway, homeCorners: match.isOutright ? null : stats.homeCorners, awayCorners: match.isOutright ? null : stats.awayCorners, homeYellowCards: match.isOutright ? null : stats.homeYellowCards, awayYellowCards: match.isOutright ? null : stats.awayYellowCards, homeRedCards: match.isOutright ? null : stats.homeRedCards, awayRedCards: match.isOutright ? null : stats.awayRedCards, homeCards: match.isOutright ? null : stats.homeCards, awayCards: match.isOutright ? null : stats.awayCards, winnerTeamId: match.isOutright ? winnerTeamId : null, recordedBy: operatorId, }, update: { htHomeScore: match.isOutright ? 0 : htHome, htAwayScore: match.isOutright ? 0 : htAway, ftHomeScore: match.isOutright ? 0 : ftHome, ftAwayScore: match.isOutright ? 0 : ftAway, homeCorners: match.isOutright ? null : stats.homeCorners, awayCorners: match.isOutright ? null : stats.awayCorners, homeYellowCards: match.isOutright ? null : stats.homeYellowCards, awayYellowCards: match.isOutright ? null : stats.awayYellowCards, homeRedCards: match.isOutright ? null : stats.homeRedCards, awayRedCards: match.isOutright ? null : stats.awayRedCards, homeCards: match.isOutright ? null : stats.homeCards, awayCards: match.isOutright ? null : stats.awayCards, winnerTeamId: match.isOutright ? winnerTeamId : null, recordedBy: operatorId, }, }); await this.prisma.match.update({ where: { id: matchId }, data: { status: 'PENDING_SETTLEMENT' }, }); return { matchId, htHome, htAway, ftHome, ftAway, ...stats, winnerTeamId: winnerTeamId?.toString() ?? null, }; } async previewSettlement( matchId: bigint, operatorId: bigint, opts?: { page?: number; pageSize?: number; htHome?: number; htAway?: number; ftHome?: number; ftAway?: number; homeCorners?: number | null; awayCorners?: number | null; homeYellowCards?: number | null; awayYellowCards?: number | null; homeRedCards?: number | null; awayRedCards?: number | null; homeCards?: number | null; awayCards?: number | null; winnerTeamId?: bigint; }, ) { const match = await this.prisma.match.findFirst({ where: { id: matchId, deletedAt: null }, }); if (!match) throw appNotFound('MATCH_NOT_FOUND'); this.assertMatchClosedForSettlement(match.status); if (match.isOutright) { await this.assertOutrightLeagueFixturesSettled(match); } const scoreSource = await this.resolvePreviewScoreSource(matchId, match.isOutright, opts); const computation = await this.computePreviewComputation(matchId, scoreSource); const batch = await this.prisma.settlementBatch.create({ data: { matchId, batchNo: generateBatchNo('STL'), htHomeScore: scoreSource.htHome, htAwayScore: scoreSource.htAway, ftHomeScore: scoreSource.ftHome, ftAwayScore: scoreSource.ftAway, homeCorners: scoreSource.homeCorners ?? null, awayCorners: scoreSource.awayCorners ?? null, homeYellowCards: scoreSource.homeYellowCards ?? null, awayYellowCards: scoreSource.awayYellowCards ?? null, homeRedCards: scoreSource.homeRedCards ?? null, awayRedCards: scoreSource.awayRedCards ?? null, homeCards: scoreSource.homeCards ?? null, awayCards: scoreSource.awayCards ?? null, status: 'PREVIEW', totalBets: computation.pendingBets.length, totalPayout: computation.totalPayout, totalRefund: computation.totalRefund, operatorId, }, }); if (match.isOutright && scoreSource.winnerTeamId) { await this.upsertMatchScoreRecord(matchId, scoreSource, operatorId); } if (match.status !== 'PENDING_SETTLEMENT' && match.status !== 'SETTLED') { await this.prisma.match.update({ where: { id: matchId }, data: { status: 'PENDING_SETTLEMENT' }, }); } return this.buildPreviewResponse(computation, batch, opts); } async getPreviewSettlementItems( batchId: bigint, opts?: { page?: number; pageSize?: number }, ) { const batch = await this.prisma.settlementBatch.findUnique({ where: { id: batchId } }); if (!batch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND'); if (batch.status !== 'PREVIEW') { throw appBadRequest('SETTLEMENT_BATCH_NOT_PREVIEW'); } const existingScore = await this.prisma.matchScore.findUnique({ where: { matchId: batch.matchId }, }); const computation = await this.computePreviewComputation(batch.matchId, { htHome: batch.htHomeScore ?? 0, htAway: batch.htAwayScore ?? 0, ftHome: batch.ftHomeScore ?? 0, ftAway: batch.ftAwayScore ?? 0, homeCorners: batch.homeCorners, awayCorners: batch.awayCorners, homeYellowCards: batch.homeYellowCards, awayYellowCards: batch.awayYellowCards, homeRedCards: batch.homeRedCards, awayRedCards: batch.awayRedCards, homeCards: batch.homeCards, awayCards: batch.awayCards, winnerTeamId: existingScore?.winnerTeamId ?? null, }); const itemsPage = this.paginatePreviewItems(computation.items, opts); return { items: itemsPage.items.map((item) => this.serializePreviewItem(item)), total: itemsPage.total, page: itemsPage.page, pageSize: itemsPage.pageSize, }; } async getActivePreview( matchId: bigint, opts?: { page?: number; pageSize?: number }, ) { const batch = await this.prisma.settlementBatch.findFirst({ where: { matchId, status: 'PREVIEW' }, orderBy: { createdAt: 'desc' }, }); if (!batch) return null; const existingScore = await this.prisma.matchScore.findUnique({ where: { matchId }, }); const computation = await this.computePreviewComputation(matchId, { htHome: batch.htHomeScore ?? 0, htAway: batch.htAwayScore ?? 0, ftHome: batch.ftHomeScore ?? 0, ftAway: batch.ftAwayScore ?? 0, homeCorners: batch.homeCorners ?? null, awayCorners: batch.awayCorners ?? null, homeYellowCards: batch.homeYellowCards ?? null, awayYellowCards: batch.awayYellowCards ?? null, homeRedCards: batch.homeRedCards ?? null, awayRedCards: batch.awayRedCards ?? null, homeCards: batch.homeCards ?? null, awayCards: batch.awayCards ?? null, winnerTeamId: existingScore?.winnerTeamId ?? null, }); return this.buildPreviewResponse(computation, batch, opts); } async getMatchSettlementHistory(matchId: bigint) { const batches = await this.prisma.settlementBatch.findMany({ where: { matchId, status: 'CONFIRMED' }, orderBy: { confirmedAt: 'desc' }, }); const operatorIds = batches .map((b) => b.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 operatorMap = new Map(operators.map((o) => [o.id.toString(), o.username])); return batches.map((b) => ({ id: b.id.toString(), batchNo: b.batchNo, htHomeScore: b.htHomeScore, htAwayScore: b.htAwayScore, ftHomeScore: b.ftHomeScore, ftAwayScore: b.ftAwayScore, homeCorners: b.homeCorners, awayCorners: b.awayCorners, homeYellowCards: b.homeYellowCards, awayYellowCards: b.awayYellowCards, homeRedCards: b.homeRedCards, awayRedCards: b.awayRedCards, homeCards: b.homeCards, awayCards: b.awayCards, totalBets: b.totalBets, totalPayout: b.totalPayout.toString(), totalRefund: b.totalRefund.toString(), confirmedAt: b.confirmedAt?.toISOString() ?? null, isResettle: b.isResettle, reason: b.reason, operatorUsername: b.operatorId ? operatorMap.get(b.operatorId.toString()) ?? '—' : '—', })); } private buildPreviewResponse( computation: { scoreInput: ScoreInput; winnerTeamCode: string | null; pendingBets: Array<{ betType: string }>; items: Array<{ betId: bigint; betNo: string; betType: string; result: string; payout: Decimal; note?: string; }>; totalPayout: Decimal; totalRefund: Decimal; lostOnThisMatch: number; pendingOtherMatches: number; wonLegsOnMatch: number; }, batch: { id: bigint }, opts?: { page?: number; pageSize?: number }, ) { const { pendingBets } = computation; const itemsPage = this.paginatePreviewItems(computation.items, opts); return { batch, score: computation.scoreInput, winnerTeamCode: computation.winnerTeamCode, pendingBetCount: pendingBets.length, singleBetCount: pendingBets.filter((b) => b.betType === 'SINGLE').length, parlayBetCount: pendingBets.filter((b) => b.betType === 'PARLAY').length, lostOnThisMatch: computation.lostOnThisMatch, pendingOtherMatches: computation.pendingOtherMatches, wonLegsOnMatch: computation.wonLegsOnMatch, items: { items: itemsPage.items.map((item) => this.serializePreviewItem(item)), total: itemsPage.total, page: itemsPage.page, pageSize: itemsPage.pageSize, }, totalPayout: computation.totalPayout.toString(), totalRefund: computation.totalRefund.toString(), }; } private serializePreviewItem(item: { betId: bigint; betNo: string; betType: string; result: string; payout: Decimal; note?: string; }) { return { betId: item.betId.toString(), betNo: item.betNo, betType: item.betType, result: item.result, payout: item.payout.toString(), note: item.note, }; } private paginatePreviewItems(all: T[], opts?: { page?: number; pageSize?: number }) { const page = Math.max(1, opts?.page ?? 1); const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 10)); const total = all.length; const start = (page - 1) * pageSize; return { items: all.slice(start, start + pageSize), total, page, pageSize, }; } private async upsertMatchScoreRecord( matchId: bigint, scoreSource: { htHome: number; htAway: number; ftHome: number; ftAway: number; homeCorners?: number | null; awayCorners?: number | null; homeYellowCards?: number | null; awayYellowCards?: number | null; homeRedCards?: number | null; awayRedCards?: number | null; homeCards?: number | null; awayCards?: number | null; winnerTeamId?: bigint | null; }, operatorId: bigint, tx?: Parameters[0]>[0], ) { const client = tx ?? this.prisma; const stats = this.statsInputFromSource(scoreSource); await client.matchScore.upsert({ where: { matchId }, create: { matchId, htHomeScore: scoreSource.htHome, htAwayScore: scoreSource.htAway, ftHomeScore: scoreSource.ftHome, ftAwayScore: scoreSource.ftAway, homeCorners: stats.homeCorners, awayCorners: stats.awayCorners, homeYellowCards: stats.homeYellowCards, awayYellowCards: stats.awayYellowCards, homeRedCards: stats.homeRedCards, awayRedCards: stats.awayRedCards, homeCards: stats.homeCards, awayCards: stats.awayCards, winnerTeamId: scoreSource.winnerTeamId ?? null, recordedBy: operatorId, }, update: { htHomeScore: scoreSource.htHome, htAwayScore: scoreSource.htAway, ftHomeScore: scoreSource.ftHome, ftAwayScore: scoreSource.ftAway, homeCorners: stats.homeCorners, awayCorners: stats.awayCorners, homeYellowCards: stats.homeYellowCards, awayYellowCards: stats.awayYellowCards, homeRedCards: stats.homeRedCards, awayRedCards: stats.awayRedCards, homeCards: stats.homeCards, awayCards: stats.awayCards, winnerTeamId: scoreSource.winnerTeamId ?? null, recordedBy: operatorId, }, }); } private async resolvePreviewScoreSource( matchId: bigint, isOutright: boolean, opts?: { htHome?: number; htAway?: number; ftHome?: number; ftAway?: number; homeCorners?: number | null; awayCorners?: number | null; homeYellowCards?: number | null; awayYellowCards?: number | null; homeRedCards?: number | null; awayRedCards?: number | null; homeCards?: number | null; awayCards?: number | null; winnerTeamId?: bigint; }, ) { const hasRequestScore = opts?.htHome !== undefined || opts?.htAway !== undefined || opts?.ftHome !== undefined || opts?.ftAway !== undefined || opts?.homeCorners !== undefined || opts?.awayCorners !== undefined || opts?.homeYellowCards !== undefined || opts?.awayYellowCards !== undefined || opts?.homeRedCards !== undefined || opts?.awayRedCards !== undefined || opts?.homeCards !== undefined || opts?.awayCards !== undefined || opts?.winnerTeamId !== undefined; if (hasRequestScore) { if (isOutright) { if (!opts?.winnerTeamId) throw appBadRequest('SETTLEMENT_WINNER_REQUIRED'); const team = await this.prisma.team.findUnique({ where: { id: opts.winnerTeamId } }); if (!team) throw appBadRequest('SETTLEMENT_WINNER_NOT_FOUND'); const outrightSel = await this.prisma.marketSelection.findFirst({ where: { market: { matchId, marketType: 'OUTRIGHT_WINNER' }, selectionCode: team.code, }, }); if (!outrightSel) throw appBadRequest('SETTLEMENT_WINNER_NOT_IN_MARKET'); } const stats = this.statsInputFromSource({ homeCorners: opts?.homeCorners ?? null, awayCorners: opts?.awayCorners ?? null, homeYellowCards: opts?.homeYellowCards ?? null, awayYellowCards: opts?.awayYellowCards ?? null, homeRedCards: opts?.homeRedCards ?? null, awayRedCards: opts?.awayRedCards ?? null, homeCards: opts?.homeCards ?? null, awayCards: opts?.awayCards ?? null, }); return { htHome: opts?.htHome ?? 0, htAway: opts?.htAway ?? 0, ftHome: opts?.ftHome ?? 0, ftAway: opts?.ftAway ?? 0, ...stats, winnerTeamId: isOutright ? (opts?.winnerTeamId ?? null) : null, }; } const score = await this.prisma.matchScore.findUnique({ where: { matchId } }); if (!score) throw appBadRequest('SCORE_NOT_RECORDED'); return { htHome: score.htHomeScore ?? 0, htAway: score.htAwayScore ?? 0, ftHome: score.ftHomeScore ?? 0, ftAway: score.ftAwayScore ?? 0, homeCorners: score.homeCorners, awayCorners: score.awayCorners, homeYellowCards: score.homeYellowCards, awayYellowCards: score.awayYellowCards, homeRedCards: score.homeRedCards, awayRedCards: score.awayRedCards, homeCards: score.homeCards, awayCards: score.awayCards, winnerTeamId: score.winnerTeamId, }; } private async computePreviewComputation( matchId: bigint, scoreSource?: SettlementScoreSource, ) { let scoreInput: ScoreInput; let statsInput: MatchStatsInput; let winnerTeamCode: string | null; if (scoreSource) { scoreInput = { htHome: scoreSource.htHome, htAway: scoreSource.htAway, ftHome: scoreSource.ftHome, ftAway: scoreSource.ftAway, }; statsInput = this.statsInputFromSource(scoreSource); winnerTeamCode = await this.resolveWinnerTeamCode(scoreSource.winnerTeamId ?? null); } else { const score = await this.prisma.matchScore.findUnique({ where: { matchId } }); if (!score) throw appBadRequest('SCORE_NOT_RECORDED'); scoreInput = { htHome: score.htHomeScore ?? 0, htAway: score.htAwayScore ?? 0, ftHome: score.ftHomeScore ?? 0, ftAway: score.ftAwayScore ?? 0, }; statsInput = this.statsInputFromSource(score); winnerTeamCode = await this.resolveWinnerTeamCode(score.winnerTeamId); } const pendingBets = await this.prisma.bet.findMany({ where: { status: 'PENDING', selections: { some: { matchId } }, }, include: { selections: { orderBy: { sortOrder: 'asc' } } }, }); const selectionCodes = await this.loadSelectionCodes(pendingBets); await this.assertRequiredStatsAvailable( matchId, statsInput, pendingBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)), ); let totalPayout = new Decimal(0); let totalRefund = new Decimal(0); let lostOnThisMatch = 0; let pendingOtherMatches = 0; let wonLegsOnMatch = 0; const items: Array<{ betId: bigint; betNo: string; betType: string; result: string; payout: Decimal; note?: string; }> = []; for (const bet of pendingBets) { if (bet.betType === 'SINGLE' && bet.selections.length === 1) { const sel = bet.selections[0]; const code = resolveSelectionCode( selectionCodes.get(sel.selectionId.toString()), sel.selectionNameSnapshot, ); const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode); const payout = calculatePayout(bet.stake, sel.odds, result); if (result === 'WIN' || result === 'HALF_WIN') wonLegsOnMatch += 1; items.push({ betId: bet.id, betNo: bet.betNo, betType: 'SINGLE', result, payout }); if (result === 'PUSH' || result === 'VOID') { totalRefund = totalRefund.add(bet.stake); } else if (result !== 'LOSE') { totalPayout = totalPayout.add(payout); } } else if (bet.betType === 'SINGLE' && bet.selections.length > 1) { const legResults = bet.selections.map((sel) => { const code = resolveSelectionCode( selectionCodes.get(sel.selectionId.toString()), sel.selectionNameSnapshot, ); return { odds: sel.odds, result: this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode), }; }); const parlay = calculateParlayPayout(bet.stake, legResults); items.push({ betId: bet.id, betNo: bet.betNo, betType: 'SINGLE', result: parlay.betResult === 'LOST' ? 'LOSE' : parlay.betResult, payout: parlay.payout, note: '单关含多腿,按串关规则合并结算', }); if (parlay.betResult === 'PUSH') { totalRefund = totalRefund.add(bet.stake); } else if (parlay.betResult === 'WON') { totalPayout = totalPayout.add(parlay.payout); } } else { for (const sel of bet.selections) { if (sel.matchId?.toString() !== matchId.toString()) continue; const code = resolveSelectionCode( selectionCodes.get(sel.selectionId.toString()), sel.selectionNameSnapshot, ); const legResult = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode); if (legResult === 'WIN' || legResult === 'HALF_WIN') wonLegsOnMatch += 1; } const preview = this.previewParlayForMatch( bet, matchId, scoreInput, statsInput, winnerTeamCode, selectionCodes, ); if (preview.kind === 'SETTLED') { items.push({ betId: bet.id, betNo: bet.betNo, betType: 'PARLAY', result: preview.betResult, payout: preview.payout, }); if (preview.betResult === 'PUSH') { totalRefund = totalRefund.add(bet.stake); } else if (preview.betResult === 'WON') { totalPayout = totalPayout.add(preview.payout); } } else { pendingOtherMatches += 1; items.push({ betId: bet.id, betNo: bet.betNo, betType: 'PARLAY', result: 'PENDING_OTHER_MATCHES', payout: new Decimal(0), note: '本场腿已出结果,待其他场次结算后统一结算', }); } } } return { scoreInput, winnerTeamCode, pendingBets, items, totalPayout, totalRefund, lostOnThisMatch, pendingOtherMatches, wonLegsOnMatch, }; } private previewParlayForMatch( bet: { stake: Decimal; selections: BetSelectionLeg[] }, matchId: bigint, scoreInput: ScoreInput, statsInput: MatchStatsInput, winnerTeamCode: string | null, selectionCodes: Map, ): | { kind: 'SETTLED'; betResult: 'WON' | 'LOST' | 'PUSH'; payout: Decimal } | { kind: 'PENDING_OTHER_MATCHES' } { const legResults: Array<{ odds: Decimal; result: SelectionResult }> = []; for (const sel of bet.selections) { if (sel.matchId?.toString() === matchId.toString()) { const code = resolveSelectionCode( selectionCodes.get(sel.selectionId.toString()), sel.selectionNameSnapshot, ); legResults.push({ odds: sel.odds, result: this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode), }); } else if (sel.resultStatus) { legResults.push({ odds: sel.odds, result: sel.resultStatus as SelectionResult, }); } else { return { kind: 'PENDING_OTHER_MATCHES' }; } } if (legResults.length !== bet.selections.length) { return { kind: 'PENDING_OTHER_MATCHES' }; } const settled = calculateParlayPayout(bet.stake, legResults); return { kind: 'SETTLED', betResult: settled.betResult, payout: settled.payout }; } private async loadSelectionCodes( bets: Array<{ selections: Array<{ selectionId: bigint }> }>, tx?: TxClient, ): Promise> { const ids = new Set(); for (const bet of bets) { for (const sel of bet.selections) ids.add(sel.selectionId); } if (!ids.size) return new Map(); const client: PrismaClientLike = tx ?? this.prisma; const rows = await client.marketSelection.findMany({ where: { id: { in: [...ids] } }, select: { id: true, selectionCode: true }, }); return new Map(rows.map((r) => [r.id.toString(), r.selectionCode])); } async confirmSettlement(batchId: bigint, operatorId: bigint) { const batch = await this.prisma.settlementBatch.findUnique({ where: { id: batchId }, include: { match: true }, }); if (!batch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND'); if (batch.status !== 'PREVIEW') throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED'); if (batch.match.status !== 'PENDING_SETTLEMENT') { throw appBadRequest('MATCH_NOT_SETTLEABLE'); } if (batch.match.isOutright) { await this.assertOutrightLeagueFixturesSettled(batch.match); } const agentIds = new Set(); await this.prisma.$transaction(async (tx) => { const claimed = await tx.settlementBatch.updateMany({ where: { id: batchId, status: 'PREVIEW' }, data: { status: 'CONFIRMING', operatorId }, }); if (claimed.count !== 1) throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED'); const currentBatch = await tx.settlementBatch.findUnique({ where: { id: batchId }, include: { match: true }, }); if (!currentBatch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND'); if (currentBatch.match.status !== 'PENDING_SETTLEMENT') { throw appBadRequest('MATCH_NOT_SETTLEABLE'); } const scoreInput: ScoreInput = { htHome: currentBatch.htHomeScore ?? 0, htAway: currentBatch.htAwayScore ?? 0, ftHome: currentBatch.ftHomeScore ?? 0, ftAway: currentBatch.ftAwayScore ?? 0, }; const statsInput = this.statsInputFromSource(currentBatch); const existingScore = await tx.matchScore.findUnique({ where: { matchId: currentBatch.matchId }, }); const winnerTeamCode = await this.resolveWinnerTeamCode( existingScore?.winnerTeamId ?? null, tx, ); const pendingBets = await tx.bet.findMany({ where: { status: 'PENDING', selections: { some: { matchId: currentBatch.matchId } }, }, include: { selections: { orderBy: { sortOrder: 'asc' } }, user: true }, }); const selectionCodes = await this.loadSelectionCodes(pendingBets, tx); await this.assertRequiredStatsAvailable( currentBatch.matchId, statsInput, pendingBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)), tx, ); let settledCount = 0; await this.upsertMatchScoreRecord( currentBatch.matchId, { htHome: scoreInput.htHome, htAway: scoreInput.htAway, ftHome: scoreInput.ftHome, ftAway: scoreInput.ftAway, ...statsInput, winnerTeamId: existingScore?.winnerTeamId ?? null, }, operatorId, tx, ); for (const bet of pendingBets) { if (bet.betType === 'SINGLE' && bet.selections.length === 1) { const sel = bet.selections[0]; const code = resolveSelectionCode( selectionCodes.get(sel.selectionId.toString()), sel.selectionNameSnapshot, ); const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode); const payout = calculatePayout(bet.stake, sel.odds, result); const betStatus = this.betStatusFromSelection(result); const updatedBet = await tx.bet.updateMany({ where: { id: bet.id, status: 'PENDING' }, data: { status: betStatus, actualReturn: payout, settledAt: new Date(), }, }); if (updatedBet.count !== 1) continue; await tx.betSelection.update({ where: { id: sel.id }, data: { resultStatus: result, effectiveOdds: sel.odds }, }); await this.funds.settleBet({ userId: bet.userId, stake: bet.stake, payout, betNo: bet.betNo, batchNo: batch.batchNo, result: this.walletResultFromSelection(result), tx, }); if (bet.agentId) agentIds.add(bet.agentId); settledCount += 1; await tx.settlementItem.create({ data: { batchId, betId: bet.id, userId: bet.userId, result: betStatus, payout, }, }); } else if (bet.betType === 'SINGLE' && bet.selections.length > 1) { const legResults: Array<{ odds: Decimal; result: SelectionResult }> = []; for (const sel of bet.selections) { const code = resolveSelectionCode( selectionCodes.get(sel.selectionId.toString()), sel.selectionNameSnapshot, ); const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode); legResults.push({ odds: sel.odds, result }); await tx.betSelection.update({ where: { id: sel.id }, data: { resultStatus: result, effectiveOdds: sel.odds }, }); } const parlayResult = calculateParlayPayout(bet.stake, legResults); const betStatus = parlayResult.betResult === 'LOST' ? 'LOST' : parlayResult.betResult === 'PUSH' ? 'PUSH' : 'WON'; const updatedBet = await tx.bet.updateMany({ where: { id: bet.id, status: 'PENDING' }, data: { status: betStatus, actualReturn: parlayResult.payout, settledAt: new Date(), }, }); if (updatedBet.count !== 1) continue; await this.funds.settleBet({ userId: bet.userId, stake: bet.stake, payout: parlayResult.payout, betNo: bet.betNo, batchNo: batch.batchNo, result: parlayResult.betResult === 'LOST' ? 'LOSE' : parlayResult.betResult === 'PUSH' ? 'PUSH' : 'WIN', tx, }); if (bet.agentId) agentIds.add(bet.agentId); settledCount += 1; await tx.settlementItem.create({ data: { batchId, betId: bet.id, userId: bet.userId, result: betStatus, payout: parlayResult.payout, }, }); } else { for (const sel of bet.selections) { if (sel.matchId?.toString() === batch.matchId.toString()) { const code = resolveSelectionCode( selectionCodes.get(sel.selectionId.toString()), sel.selectionNameSnapshot, ); const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode); await tx.betSelection.update({ where: { id: sel.id }, data: { resultStatus: result }, }); } } const updated = await tx.betSelection.findMany({ where: { betId: bet.id }, orderBy: { sortOrder: 'asc' }, }); const allHaveResult = updated.every((s) => s.resultStatus != null); if (allHaveResult) { const legResults = updated.map((s) => ({ odds: s.odds, result: s.resultStatus as SelectionResult, })); const parlayResult = calculateParlayPayout(bet.stake, legResults); const betStatus = parlayResult.betResult === 'LOST' ? 'LOST' : parlayResult.betResult === 'PUSH' ? 'PUSH' : 'WON'; const updatedBet = await tx.bet.updateMany({ where: { id: bet.id, status: 'PENDING' }, data: { status: betStatus, actualReturn: parlayResult.payout, settledAt: new Date(), }, }); if (updatedBet.count !== 1) continue; await this.funds.settleBet({ userId: bet.userId, stake: bet.stake, payout: parlayResult.payout, betNo: bet.betNo, batchNo: batch.batchNo, result: parlayResult.betResult === 'LOST' ? 'LOSE' : parlayResult.betResult === 'PUSH' ? 'PUSH' : 'WIN', tx, }); if (bet.agentId) agentIds.add(bet.agentId); settledCount += 1; await tx.settlementItem.create({ data: { batchId, betId: bet.id, userId: bet.userId, result: betStatus, payout: parlayResult.payout, }, }); } } } await tx.settlementBatch.update({ where: { id: batchId }, data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId, totalBets: settledCount, }, }); await tx.match.update({ where: { id: currentBatch.matchId }, data: { status: 'SETTLED', isHot: false }, }); }); for (const agentId of agentIds) { await this.agents.recalculateUsedCredit(agentId); } return { success: true, batchId: batchId.toString() }; } private async assertMatchReadyForBetStats(matchId: bigint) { const match = await this.prisma.match.findFirst({ where: { id: matchId, deletedAt: null }, }); if (!match) throw appNotFound('MATCH_NOT_FOUND'); this.assertMatchClosedForSettlement(match.status); return match; } async getMatchBetStatsSummary(matchId: bigint) { await this.assertMatchReadyForBetStats(matchId); const betWhere = { selections: { some: { matchId } } }; const [ legCount, totalBets, singleBets, parlayBets, stakeAgg, statusGroups, legsForSelection, ] = await Promise.all([ this.prisma.betSelection.count({ where: { matchId } }), this.prisma.bet.count({ where: betWhere }), this.prisma.bet.count({ where: { ...betWhere, betType: 'SINGLE' } }), this.prisma.bet.count({ where: { ...betWhere, betType: 'PARLAY' } }), this.prisma.bet.aggregate({ where: betWhere, _sum: { stake: true, potentialReturn: true }, }), this.prisma.bet.groupBy({ by: ['status'], where: betWhere, _count: { _all: true }, }), this.prisma.betSelection.findMany({ where: { matchId }, select: { marketId: true, selectionId: true, marketType: true, period: true, selectionNameSnapshot: true, bet: { select: { betType: true, stake: true } }, }, orderBy: [{ marketType: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }], }), ]); const statusCounts: Record = {}; for (const row of statusGroups) { statusCounts[row.status] = row._count._all; } type SelAgg = { marketType: string; period: string | null; selectionName: string; selectionId: string; legCount: number; singleStake: Decimal; parlayLegCount: number; }; const selMap = new Map(); for (const leg of legsForSelection) { const key = `${leg.marketId.toString()}:${leg.selectionId.toString()}`; let row = selMap.get(key); if (!row) { row = { marketType: leg.marketType, period: leg.period, selectionName: leg.selectionNameSnapshot, selectionId: leg.selectionId.toString(), legCount: 0, singleStake: new Decimal(0), parlayLegCount: 0, }; selMap.set(key, row); } row.legCount += 1; if (leg.bet.betType === 'SINGLE') { row.singleStake = row.singleStake.add(leg.bet.stake); } else if (leg.bet.betType === 'PARLAY') { row.parlayLegCount += 1; } } const bySelection = Array.from(selMap.values()) .map((r) => ({ marketType: r.marketType, period: r.period, selectionName: r.selectionName, selectionId: r.selectionId, legCount: r.legCount, singleStake: r.singleStake.toString(), parlayLegCount: r.parlayLegCount, })) .sort((a, b) => { const mk = a.marketType.localeCompare(b.marketType); if (mk !== 0) return mk; return a.selectionName.localeCompare(b.selectionName); }); return { summary: { totalBets, singleBets, parlayBets, totalStake: (stakeAgg._sum.stake ?? new Decimal(0)).toString(), totalPotentialReturn: (stakeAgg._sum.potentialReturn ?? new Decimal(0)).toString(), statusCounts, legCount, }, bySelection, }; } async getMatchBetStatsBets( matchId: bigint, opts?: { page?: number; pageSize?: number }, ) { await this.assertMatchReadyForBetStats(matchId); const betWhere = { selections: { some: { matchId } } }; const totalBets = await this.prisma.bet.count({ where: betWhere }); const page = Math.max(1, opts?.page ?? 1); const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 10)); const betRows = await this.prisma.bet.findMany({ where: betWhere, orderBy: { placedAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, include: { user: { select: { username: true } }, selections: { where: { matchId }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], }, }, }); const items = betRows.map((bet) => ({ id: bet.id.toString(), betNo: bet.betNo, username: bet.user.username, betType: bet.betType, status: bet.status, settlementStatus: bet.settlementStatus, stake: bet.stake.toString(), potentialReturn: bet.potentialReturn?.toString() ?? null, actualReturn: bet.actualReturn.toString(), placedAt: bet.placedAt.toISOString(), legCountOnMatch: bet.selections.length, selections: bet.selections.map((leg) => ({ marketType: leg.marketType, period: leg.period, selectionName: leg.selectionNameSnapshot, odds: leg.odds.toString(), })), })); return { items, total: totalBets, page, pageSize, }; } async getMatchBetStats( matchId: bigint, opts?: { page?: number; pageSize?: number }, ) { const [summaryPart, bets] = await Promise.all([ this.getMatchBetStatsSummary(matchId), this.getMatchBetStatsBets(matchId, opts), ]); return { ...summaryPart, bets, }; } private async computeBetOutcome( bet: { id: bigint; betType: string; stake: Decimal; actualReturn: Decimal; selections: BetSelectionLeg[]; }, matchId: bigint, scoreInput: ScoreInput, statsInput: MatchStatsInput, winnerTeamCode: string | null, selectionCodes: Map, ) { if (bet.betType === 'SINGLE') { const sel = bet.selections[0]; const code = resolveSelectionCode( selectionCodes.get(sel.selectionId.toString()), sel.selectionNameSnapshot, ); const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode); const payout = calculatePayout(bet.stake, sel.odds, result); return { payout, betStatus: this.betStatusFromSelection(result), legUpdates: new Map([[sel.id.toString(), result]]), }; } const legResults: Array<{ odds: Decimal; result: SelectionResult }> = []; const legUpdates = new Map(); for (const sel of bet.selections) { let result: SelectionResult; if (sel.matchId?.toString() === matchId.toString()) { const code = resolveSelectionCode( selectionCodes.get(sel.selectionId.toString()), sel.selectionNameSnapshot, ); result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode); legUpdates.set(sel.id.toString(), result); } else { if (!sel.resultStatus) { throw appBadRequest('PARLAY_UNSETTLED_LEGS', { betId: bet.id.toString() }); } result = sel.resultStatus as SelectionResult; } legResults.push({ odds: sel.odds, result }); } const parlayResult = calculateParlayPayout(bet.stake, legResults); const betStatus = parlayResult.betResult === 'LOST' ? 'LOST' : parlayResult.betResult === 'PUSH' ? 'PUSH' : 'WON'; return { payout: parlayResult.payout, betStatus, legUpdates }; } async previewResettlement( matchId: bigint, scoreInput: ScoreInput & MatchStatsInput, operatorId: bigint, reason?: string, winnerTeamId?: bigint, ) { const match = await this.prisma.match.findFirst({ where: { id: matchId, deletedAt: null }, }); if (!match) throw appNotFound('MATCH_NOT_FOUND'); if (match.status !== 'SETTLED') { throw appBadRequest('RESETTLE_SETTLED_ONLY'); } const winnerTeamCode = winnerTeamId ? await this.resolveWinnerTeamCode(winnerTeamId) : null; const statsInput = this.statsInputFromSource(scoreInput); const settledBets = await this.prisma.bet.findMany({ where: { status: { in: ['WON', 'LOST', 'PUSH', 'VOID'] }, selections: { some: { matchId } }, }, include: { selections: { orderBy: { sortOrder: 'asc' } } }, }); const selectionCodes = await this.loadSelectionCodes(settledBets); await this.assertRequiredStatsAvailable( matchId, statsInput, settledBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)), ); const items: Array<{ betId: bigint; betNo: string; oldPayout: Decimal; newPayout: Decimal; delta: Decimal; oldStatus: string; newStatus: string; }> = []; let totalClawback = new Decimal(0); let totalTopup = new Decimal(0); for (const bet of settledBets) { const oldPayout = new Decimal(bet.actualReturn); const outcome = await this.computeBetOutcome( bet, matchId, scoreInput, statsInput, winnerTeamCode, selectionCodes, ); const delta = outcome.payout.sub(oldPayout); if (delta.eq(0) && outcome.betStatus === bet.status) continue; items.push({ betId: bet.id, betNo: bet.betNo, oldPayout, newPayout: outcome.payout, delta, oldStatus: bet.status, newStatus: outcome.betStatus, }); if (delta.gt(0)) totalTopup = totalTopup.add(delta); else if (delta.lt(0)) totalClawback = totalClawback.add(delta.abs()); } const batch = await this.prisma.settlementBatch.create({ data: { matchId, batchNo: generateBatchNo('RST'), htHomeScore: scoreInput.htHome, htAwayScore: scoreInput.htAway, ftHomeScore: scoreInput.ftHome, ftAwayScore: scoreInput.ftAway, homeCorners: statsInput.homeCorners ?? null, awayCorners: statsInput.awayCorners ?? null, homeYellowCards: statsInput.homeYellowCards ?? null, awayYellowCards: statsInput.awayYellowCards ?? null, homeRedCards: statsInput.homeRedCards ?? null, awayRedCards: statsInput.awayRedCards ?? null, homeCards: statsInput.homeCards ?? null, awayCards: statsInput.awayCards ?? null, status: 'PREVIEW', totalBets: items.length, totalPayout: totalTopup, totalRefund: totalClawback, operatorId, isResettle: true, reason: reason?.trim() || null, }, }); if (match.isOutright && winnerTeamId) { await this.upsertMatchScoreRecord( matchId, { htHome: scoreInput.htHome, htAway: scoreInput.htAway, ftHome: scoreInput.ftHome, ftAway: scoreInput.ftAway, ...statsInput, winnerTeamId, }, operatorId, ); } return { batch, score: scoreInput, winnerTeamCode, items, totalClawback, totalTopup, affectedCount: items.length, }; } async confirmResettlement(batchId: bigint, operatorId: bigint) { const batch = await this.prisma.settlementBatch.findUnique({ where: { id: batchId }, include: { match: true }, }); if (!batch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND'); if (!batch.isResettle) throw appBadRequest('RESETTLE_BATCH_ONLY'); if (batch.status !== 'PREVIEW') throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED'); const agentIds = new Set(); await this.prisma.$transaction(async (tx) => { const claimed = await tx.settlementBatch.updateMany({ where: { id: batchId, status: 'PREVIEW' }, data: { status: 'CONFIRMING', operatorId }, }); if (claimed.count !== 1) throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED'); const currentBatch = await tx.settlementBatch.findUnique({ where: { id: batchId }, include: { match: true }, }); if (!currentBatch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND'); const scoreInput: ScoreInput = { htHome: currentBatch.htHomeScore ?? 0, htAway: currentBatch.htAwayScore ?? 0, ftHome: currentBatch.ftHomeScore ?? 0, ftAway: currentBatch.ftAwayScore ?? 0, }; const statsInput = this.statsInputFromSource(currentBatch); const existingScore = await tx.matchScore.findUnique({ where: { matchId: currentBatch.matchId }, }); const winnerTeamCode = await this.resolveWinnerTeamCode( existingScore?.winnerTeamId ?? null, tx, ); const settledBets = await tx.bet.findMany({ where: { status: { in: ['WON', 'LOST', 'PUSH', 'VOID'] }, selections: { some: { matchId: currentBatch.matchId } }, }, include: { selections: { orderBy: { sortOrder: 'asc' } } }, }); const selectionCodes = await this.loadSelectionCodes(settledBets, tx); await this.assertRequiredStatsAvailable( currentBatch.matchId, statsInput, settledBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)), tx, ); let affectedCount = 0; await this.upsertMatchScoreRecord( currentBatch.matchId, { htHome: scoreInput.htHome, htAway: scoreInput.htAway, ftHome: scoreInput.ftHome, ftAway: scoreInput.ftAway, ...statsInput, winnerTeamId: existingScore?.winnerTeamId ?? null, }, operatorId, tx, ); for (const bet of settledBets) { const oldPayout = new Decimal(bet.actualReturn); const outcome = await this.computeBetOutcome( bet, currentBatch.matchId, scoreInput, statsInput, winnerTeamCode, selectionCodes, ); const delta = outcome.payout.sub(oldPayout); if (delta.eq(0) && outcome.betStatus === bet.status) continue; for (const [legId, result] of outcome.legUpdates) { const leg = bet.selections.find((s) => s.id.toString() === legId); await tx.betSelection.update({ where: { id: BigInt(legId) }, data: { resultStatus: result, effectiveOdds: leg?.odds ?? undefined, }, }); } await tx.bet.update({ where: { id: bet.id }, data: { status: outcome.betStatus, actualReturn: outcome.payout, settlementStatus: 'RESETTLED', }, }); await this.funds.applyResettleDelta({ userId: bet.userId, delta, betNo: bet.betNo, batchNo: currentBatch.batchNo, tx, }); if (bet.agentId) agentIds.add(bet.agentId); affectedCount += 1; await tx.settlementItem.create({ data: { batchId, betId: bet.id, userId: bet.userId, result: outcome.betStatus, payout: outcome.payout, }, }); } await tx.settlementBatch.update({ where: { id: batchId }, data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId, totalBets: affectedCount, }, }); }); for (const agentId of agentIds) { await this.agents.recalculateUsedCredit(agentId); } return { success: true, batchId: batchId.toString() }; } async voidMatchBets(matchId: bigint) { return this.voidMatchBetsInTransaction(matchId); } async cancelMatchAndVoidBets(matchId: bigint) { return this.voidMatchBetsInTransaction(matchId, { cancelMatch: true }); } private async voidMatchBetsInTransaction( matchId: bigint, options: { cancelMatch?: boolean } = {}, ) { const agentIds = new Set(); const voidedCount = await this.prisma.$transaction(async (tx) => { if (options.cancelMatch) { await tx.match.update({ where: { id: matchId }, data: { status: 'CANCELLED' }, }); } const bets = await tx.bet.findMany({ where: { status: 'PENDING', selections: { some: { matchId } } }, }); let count = 0; for (const bet of bets) { const updated = await tx.bet.updateMany({ where: { id: bet.id, status: 'PENDING' }, data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() }, }); if (updated.count !== 1) continue; await this.funds.voidBet({ userId: bet.userId, stake: bet.stake, betNo: bet.betNo, businessKey: `void:${matchId}:${bet.betNo}`, tx, }); if (bet.agentId) agentIds.add(bet.agentId); count += 1; } return count; }); for (const agentId of agentIds) { await this.agents.recalculateUsedCredit(agentId); } return { voidedCount }; } }