重构 API 为 8 领域 + 应用层架构
将后端模块拆分为 domains、applications、shared 三层,结算计算器移入 domain 纯函数目录,API 路径与测试保持不变。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
279
apps/api/src/domains/settlement/domain/settlement-calculator.ts
Normal file
279
apps/api/src/domains/settlement/domain/settlement-calculator.ts
Normal 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',
|
||||
];
|
||||
11
apps/api/src/domains/settlement/settlement.module.ts
Normal file
11
apps/api/src/domains/settlement/settlement.module.ts
Normal 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 {}
|
||||
312
apps/api/src/domains/settlement/settlement.service.ts
Normal file
312
apps/api/src/domains/settlement/settlement.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user