从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-3 样式。
1760 lines
56 KiB
TypeScript
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 };
|
|
}
|
|
}
|