Files
thebet365/apps/api/src/domains/settlement/settlement.service.ts
Mars d8e08aaed0 sync(theme-3): 同步 main 最新 API/Admin 与玩家端逻辑
从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-3 样式。
2026-06-22 14:11:47 +08:00

1760 lines
56 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { FundsPostingService } from '../ledger/funds-posting.service';
import { 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<string, readonly (keyof MatchStatsInput)[]>;
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<string | null> {
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<string>) {
const fields = new Set<keyof MatchStatsInput>();
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<string>,
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<T>(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<Parameters<PrismaService['$transaction']>[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<string, string | null>,
):
| { 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<Map<string, string | null>> {
const ids = new Set<bigint>();
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<bigint>();
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<string, number> = {};
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<string, SelAgg>();
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<string, string | null>,
) {
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<string, SelectionResult>();
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<bigint>();
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<bigint>();
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 };
}
}