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

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

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

View File

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

View File

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

View File

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

View File

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