This commit is contained in:
wchino
2026-06-13 17:38:25 +08:00
parent e7e938f261
commit 7b33d9f9fa
190 changed files with 23222 additions and 4336 deletions

View File

@@ -169,6 +169,97 @@ describe('SettlementCalculator', () => {
});
});
describe('Additional score-derived markets', () => {
it('settles home and away team total goals', () => {
const s = { htHome: 1, htAway: 0, ftHome: 2, ftAway: 1 };
expect(
settleSelection({
marketType: 'FT_TEAM_TOTAL_HOME',
selectionCode: 'OVER',
totalLine: 1.5,
score: s,
}),
).toBe('WIN');
expect(
settleSelection({
marketType: 'FT_TEAM_TOTAL_AWAY',
selectionCode: 'UNDER',
totalLine: 1.5,
score: s,
}),
).toBe('WIN');
});
it('settles half time / full time combinations', () => {
expect(
settleSelection({
marketType: 'HT_FT',
selectionCode: 'HOME_HOME',
score,
}),
).toBe('WIN');
expect(
settleSelection({
marketType: 'HT_FT',
selectionCode: 'DRAW_HOME',
score,
}),
).toBe('LOSE');
});
it('settles total goals ranges', () => {
expect(
settleSelection({
marketType: 'FT_TOTAL_GOALS',
selectionCode: 'TG_2_3',
score,
}),
).toBe('WIN');
expect(
settleSelection({
marketType: 'FT_TOTAL_GOALS',
selectionCode: 'TG_7_PLUS',
score,
}),
).toBe('LOSE');
});
it('settles corners handicap and totals from match stats', () => {
const s = { htHome: 0, htAway: 0, ftHome: 1, ftAway: 1 };
const stats = { homeCorners: 7, awayCorners: 4 };
expect(
settleSelection({
marketType: 'FT_CORNERS_HANDICAP',
selectionCode: 'HOME',
handicapLine: -2.5,
score: s,
stats,
}),
).toBe('WIN');
expect(
settleSelection({
marketType: 'FT_CORNERS_OVER_UNDER',
selectionCode: 'OVER',
totalLine: 10.5,
score: s,
stats,
}),
).toBe('WIN');
});
it('settles cards total from match stats', () => {
expect(
settleSelection({
marketType: 'FT_CARDS_OVER_UNDER',
selectionCode: 'UNDER',
totalLine: 5.5,
score,
stats: { homeCards: 2, awayCards: 3 },
}),
).toBe('WIN');
});
});
describe('Parlay', () => {
it('S016: all win', () => {
const result = calculateParlayPayout(100, [

View File

@@ -9,12 +9,24 @@ export interface ScoreInput {
ftAway: number;
}
export interface MatchStatsInput {
homeCorners?: number | null;
awayCorners?: number | null;
homeYellowCards?: number | null;
awayYellowCards?: number | null;
homeRedCards?: number | null;
awayRedCards?: number | null;
homeCards?: number | null;
awayCards?: number | null;
}
export interface SettlementInput {
marketType: string;
selectionCode: string;
handicapLine?: number | null;
totalLine?: number | null;
score: ScoreInput;
stats?: MatchStatsInput | null;
templateScores?: string[];
/** 冠军盘:获胜球队 code如 FRA、BRA */
winnerTeamCode?: string | null;
@@ -143,8 +155,41 @@ function settleCorrectScore(
return 'LOSE';
}
function resultSide(home: number, away: number): 'HOME' | 'DRAW' | 'AWAY' {
if (home > away) return 'HOME';
if (home < away) return 'AWAY';
return 'DRAW';
}
function settleHtFt(score: ScoreInput, selectionCode: string): SelectionResult {
const expected = `${resultSide(score.htHome, score.htAway)}_${resultSide(score.ftHome, score.ftAway)}`;
return selectionCode === expected ? 'WIN' : 'LOSE';
}
function settleTotalGoalsRange(totalGoals: number, selectionCode: string): SelectionResult {
if (selectionCode === 'TG_0_1') return totalGoals <= 1 ? 'WIN' : 'LOSE';
if (selectionCode === 'TG_2_3') return totalGoals >= 2 && totalGoals <= 3 ? 'WIN' : 'LOSE';
if (selectionCode === 'TG_4_6') return totalGoals >= 4 && totalGoals <= 6 ? 'WIN' : 'LOSE';
if (selectionCode === 'TG_7_PLUS') return totalGoals >= 7 ? 'WIN' : 'LOSE';
const exact = selectionCode.match(/^GOALS_(\d+)$/);
if (exact) return totalGoals === Number(exact[1]) ? 'WIN' : 'LOSE';
const exactPlus = selectionCode.match(/^GOALS_(\d+)_PLUS$/);
if (exactPlus) return totalGoals >= Number(exactPlus[1]) ? 'WIN' : 'LOSE';
return 'LOSE';
}
function requireStat(value: number | null | undefined, field: keyof MatchStatsInput): number {
if (value == null || Number.isNaN(value)) {
throw new Error(`SETTLEMENT_STAT_MISSING:${field}`);
}
return value;
}
export function settleSelection(input: SettlementInput): SelectionResult {
const { marketType, selectionCode, handicapLine, totalLine, score } = input;
const stats = input.stats ?? {};
const templates = input.templateScores ?? [];
switch (marketType) {
@@ -189,6 +234,35 @@ export function settleSelection(input: SettlementInput): SelectionResult {
const total = score.htHome + score.htAway;
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
}
case 'FT_TEAM_TOTAL_HOME':
return settleOverUnder(score.ftHome, totalLine ?? 0, selectionCode === 'OVER');
case 'FT_TEAM_TOTAL_AWAY':
return settleOverUnder(score.ftAway, totalLine ?? 0, selectionCode === 'OVER');
case 'HT_FT':
return settleHtFt(score, selectionCode);
case 'FT_TOTAL_GOALS':
return settleTotalGoalsRange(score.ftHome + score.ftAway, selectionCode);
case 'FT_CORNERS_HANDICAP': {
const homeCorners = requireStat(stats.homeCorners, 'homeCorners');
const awayCorners = requireStat(stats.awayCorners, 'awayCorners');
const line = handicapLine ?? 0;
const isHome = selectionCode === 'HOME';
const corners = isHome ? homeCorners : awayCorners;
const opp = isHome ? awayCorners : homeCorners;
return settleHandicap(corners, opp, isHome ? line : -line, isHome);
}
case 'FT_CORNERS_OVER_UNDER': {
const total =
requireStat(stats.homeCorners, 'homeCorners') +
requireStat(stats.awayCorners, 'awayCorners');
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
}
case 'FT_CARDS_OVER_UNDER': {
const total =
requireStat(stats.homeCards, 'homeCards') +
requireStat(stats.awayCards, 'awayCards');
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
}
case 'FT_CORRECT_SCORE':
return settleCorrectScore(score.ftHome, score.ftAway, selectionCode, templates);
case 'HT_CORRECT_SCORE':
@@ -262,18 +336,8 @@ export function calculateParlayPayout(
return { betResult: 'WON', payout: s.mul(combinedOdds), effectiveOdds: combinedOdds };
}
export { isQuarterHandicapOrTotal } from '@thebet365/shared';
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',
];
export {
FT_CORRECT_SCORE_TEMPLATE,
HT_CORRECT_SCORE_TEMPLATE,
isQuarterHandicapOrTotal,
} from '@thebet365/shared';

View File

@@ -1,7 +1,7 @@
import {
FT_CORRECT_SCORE_TEMPLATE,
HT_CORRECT_SCORE_TEMPLATE,
} from './settlement-calculator';
} from '@thebet365/shared';
const SNAPSHOT_1X2: Record<string, string> = {
: 'HOME',
@@ -31,7 +31,7 @@ export function resolveSelectionCode(
}
export function templateScoresForMarket(marketType: string): string[] {
if (marketType === 'FT_CORRECT_SCORE') return FT_CORRECT_SCORE_TEMPLATE;
if (marketType.includes('CORRECT_SCORE')) return HT_CORRECT_SCORE_TEMPLATE;
if (marketType === 'FT_CORRECT_SCORE') return [...FT_CORRECT_SCORE_TEMPLATE];
if (marketType.includes('CORRECT_SCORE')) return [...HT_CORRECT_SCORE_TEMPLATE];
return [];
}

View File

@@ -74,6 +74,7 @@ describe('SettlementService outright winner flow', () => {
let matchFindFirst: jest.Mock;
let matchUpdate: jest.Mock;
let teamFindUnique: jest.Mock;
let marketFindMany: jest.Mock;
let marketSelectionFindFirst: jest.Mock;
let marketSelectionFindMany: jest.Mock;
let matchScoreFindUnique: jest.Mock;
@@ -90,6 +91,7 @@ describe('SettlementService outright winner flow', () => {
matchFindFirst = jest.fn().mockResolvedValue(outrightMatch);
matchUpdate = jest.fn().mockResolvedValue({});
teamFindUnique = jest.fn().mockResolvedValue(winnerTeam);
marketFindMany = jest.fn().mockResolvedValue([]);
marketSelectionFindFirst = jest
.fn()
.mockResolvedValue({ id: winningSelId, selectionCode: 'BRA' });
@@ -111,18 +113,30 @@ describe('SettlementService outright winner flow', () => {
betFindMany = jest.fn();
transaction = jest.fn(async (fn: (client: unknown) => Promise<void>) =>
fn({
matchScore: { upsert: matchScoreUpsert },
bet: { update: jest.fn().mockResolvedValue({}) },
team: { findUnique: teamFindUnique },
market: { findMany: marketFindMany },
marketSelection: { findMany: marketSelectionFindMany },
matchScore: { upsert: matchScoreUpsert, findUnique: matchScoreFindUnique },
bet: {
findMany: betFindMany,
update: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
betSelection: { update: jest.fn().mockResolvedValue({}) },
settlementItem: { create: jest.fn().mockResolvedValue({}) },
settlementBatch: { update: jest.fn().mockResolvedValue({}) },
settlementBatch: {
findUnique: settlementBatchFindUnique,
update: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
match: { update: jest.fn().mockResolvedValue({}) },
}),
);
const prisma = {
match: { findFirst: matchFindFirst, update: matchUpdate },
match: { count: jest.fn().mockResolvedValue(0), findFirst: matchFindFirst, update: matchUpdate },
team: { findUnique: teamFindUnique },
market: { findMany: marketFindMany },
marketSelection: {
findFirst: marketSelectionFindFirst,
findMany: marketSelectionFindMany,
@@ -169,20 +183,233 @@ describe('SettlementService outright winner flow', () => {
);
});
it('previewSettlement requires stats for visible stats markets', async () => {
matchFindFirst.mockResolvedValue({
id: matchId,
isOutright: false,
status: 'CLOSED',
deletedAt: null,
});
marketFindMany.mockResolvedValue([{ marketType: 'FT_CORNERS_OVER_UNDER' }]);
betFindMany.mockResolvedValue([]);
try {
await service.previewSettlement(matchId, operatorId, {
htHome: 0,
htAway: 0,
ftHome: 1,
ftAway: 1,
});
throw new Error('Expected previewSettlement to reject');
} catch (err) {
const response = (err as { getResponse?: () => unknown }).getResponse?.();
expect(response).toEqual(
expect.objectContaining({
code: 'SETTLEMENT_FACTS_REQUIRED',
params: expect.objectContaining({ fields: 'homeCorners,awayCorners' }),
}),
);
}
});
it('derives total cards from yellow and red cards for settlement previews', async () => {
matchFindFirst.mockResolvedValue({
id: matchId,
isOutright: false,
status: 'CLOSED',
deletedAt: null,
});
marketFindMany.mockResolvedValue([{ marketType: 'FT_CARDS_OVER_UNDER' }]);
betFindMany.mockResolvedValue([]);
await service.previewSettlement(matchId, operatorId, {
htHome: 0,
htAway: 0,
ftHome: 2,
ftAway: 1,
homeYellowCards: 2,
homeRedCards: 1,
awayYellowCards: 4,
awayRedCards: 0,
});
expect(settlementBatchCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
homeYellowCards: 2,
homeRedCards: 1,
awayYellowCards: 4,
awayRedCards: 0,
homeCards: 3,
awayCards: 4,
}),
}),
);
});
it('keeps a parlay pending until every match in the ticket is settled', async () => {
matchFindFirst.mockResolvedValue({
id: matchId,
isOutright: false,
status: 'CLOSED',
deletedAt: null,
});
marketSelectionFindMany.mockResolvedValue([
{ id: BigInt(501), selectionCode: 'HOME' },
{ id: BigInt(502), selectionCode: 'AWAY' },
]);
betFindMany.mockResolvedValue([
{
id: BigInt(2001),
betNo: 'PARLAY-PENDING',
betType: 'PARLAY',
status: 'PENDING',
stake: new Decimal(100),
agentId: null,
userId: BigInt(60),
user: { id: BigInt(60) },
selections: [
{
id: BigInt(3001),
matchId,
marketType: 'FT_1X2',
selectionId: BigInt(501),
selectionNameSnapshot: 'Home',
handicapLine: null,
totalLine: null,
odds: new Decimal(2),
resultStatus: null,
sortOrder: 0,
},
{
id: BigInt(3002),
matchId: BigInt(101),
marketType: 'FT_1X2',
selectionId: BigInt(502),
selectionNameSnapshot: 'Away',
handicapLine: null,
totalLine: null,
odds: new Decimal(2),
resultStatus: null,
sortOrder: 1,
},
],
},
]);
const preview = await service.previewSettlement(matchId, operatorId, {
htHome: 0,
htAway: 0,
ftHome: 0,
ftAway: 1,
});
expect(preview.pendingOtherMatches).toBe(1);
expect(preview.lostOnThisMatch).toBe(0);
expect(preview.items.items).toEqual([
expect.objectContaining({
betNo: 'PARLAY-PENDING',
result: 'PENDING_OTHER_MATCHES',
payout: '0',
note: '本场腿已出结果,待其他场次结算后统一结算',
}),
]);
});
it('previews a parlay result only after the other legs already have results', async () => {
matchFindFirst.mockResolvedValue({
id: matchId,
isOutright: false,
status: 'CLOSED',
deletedAt: null,
});
marketSelectionFindMany.mockResolvedValue([
{ id: BigInt(501), selectionCode: 'HOME' },
{ id: BigInt(502), selectionCode: 'AWAY' },
]);
betFindMany.mockResolvedValue([
{
id: BigInt(2002),
betNo: 'PARLAY-READY',
betType: 'PARLAY',
status: 'PENDING',
stake: new Decimal(100),
agentId: null,
userId: BigInt(61),
user: { id: BigInt(61) },
selections: [
{
id: BigInt(3003),
matchId,
marketType: 'FT_1X2',
selectionId: BigInt(501),
selectionNameSnapshot: 'Home',
handicapLine: null,
totalLine: null,
odds: new Decimal(2),
resultStatus: null,
sortOrder: 0,
},
{
id: BigInt(3004),
matchId: BigInt(101),
marketType: 'FT_1X2',
selectionId: BigInt(502),
selectionNameSnapshot: 'Away',
handicapLine: null,
totalLine: null,
odds: new Decimal(2),
resultStatus: 'WIN',
sortOrder: 1,
},
],
},
]);
const preview = await service.previewSettlement(matchId, operatorId, {
htHome: 0,
htAway: 0,
ftHome: 0,
ftAway: 1,
});
expect(preview.pendingOtherMatches).toBe(0);
expect(preview.items.items).toEqual([
expect.objectContaining({
betNo: 'PARLAY-READY',
result: 'LOST',
payout: '0',
}),
]);
});
it('confirmSettlement settles outright bets as WON/LOST using stored winnerTeamId', async () => {
const txBetUpdate = jest.fn().mockResolvedValue({});
const txBetUpdateMany = jest.fn().mockResolvedValue({ count: 1 });
transaction.mockImplementation(async (fn: (client: unknown) => Promise<void>) => {
await fn({
matchScore: { upsert: matchScoreUpsert },
bet: { update: txBetUpdate },
team: { findUnique: teamFindUnique },
market: { findMany: marketFindMany },
marketSelection: { findMany: marketSelectionFindMany },
matchScore: { upsert: matchScoreUpsert, findUnique: matchScoreFindUnique },
bet: {
findMany: betFindMany,
update: txBetUpdate,
updateMany: txBetUpdateMany,
},
betSelection: { update: jest.fn().mockResolvedValue({}) },
settlementItem: { create: jest.fn().mockResolvedValue({}) },
settlementBatch: { update: jest.fn().mockResolvedValue({}) },
settlementBatch: {
findUnique: settlementBatchFindUnique,
update: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
match: { update: jest.fn().mockResolvedValue({}) },
});
});
settlementBatchFindUnique.mockResolvedValue({
id: batchId,
batchNo: 'SETTLE-001',
matchId,
status: 'PREVIEW',
htHomeScore: 0,
@@ -209,31 +436,31 @@ describe('SettlementService outright winner flow', () => {
}),
);
expect(wallet.settleBet).toHaveBeenCalledTimes(2);
expect(wallet.settleBet.mock.calls[0]).toEqual([
winningBet.userId,
expect.anything(),
expect.anything(),
'BET-WIN',
'WIN',
expect.anything(),
]);
expect(wallet.settleBet.mock.calls[1]).toEqual([
losingBet.userId,
expect.anything(),
expect.anything(),
'BET-LOSE',
'LOSE',
expect.anything(),
]);
expect(txBetUpdate).toHaveBeenCalledWith(
expect(wallet.settleBet.mock.calls[0][0]).toEqual(
expect.objectContaining({
where: { id: winningBetId },
userId: winningBet.userId,
betNo: 'BET-WIN',
batchNo: 'SETTLE-001',
result: 'WIN',
}),
);
expect(wallet.settleBet.mock.calls[1][0]).toEqual(
expect.objectContaining({
userId: losingBet.userId,
betNo: 'BET-LOSE',
batchNo: 'SETTLE-001',
result: 'LOSE',
}),
);
expect(txBetUpdateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: winningBetId, status: 'PENDING' },
data: expect.objectContaining({ status: 'WON' }),
}),
);
expect(txBetUpdate).toHaveBeenCalledWith(
expect(txBetUpdateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: losingBetId },
where: { id: losingBetId, status: 'PENDING' },
data: expect.objectContaining({ status: 'LOST' }),
}),
);

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { WalletService } from '../ledger/wallet.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';
@@ -10,6 +11,7 @@ import {
calculatePayout,
calculateParlayPayout,
ScoreInput,
MatchStatsInput,
SelectionResult,
} from './domain/settlement-calculator';
import {
@@ -18,6 +20,15 @@ import {
} 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;
@@ -36,13 +47,17 @@ type BetSelectionLeg = {
export class SettlementService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
private funds: FundsPostingService,
private agents: AgentsService,
) {}
private async resolveWinnerTeamCode(winnerTeamId: bigint | null | undefined): Promise<string | null> {
private async resolveWinnerTeamCode(
winnerTeamId: bigint | null | undefined,
tx?: TxClient,
): Promise<string | null> {
if (!winnerTeamId) return null;
const team = await this.prisma.team.findUnique({ where: { id: winnerTeamId } });
const client: PrismaClientLike = tx ?? this.prisma;
const team = await client.team.findUnique({ where: { id: winnerTeamId } });
return team?.code ?? null;
}
@@ -68,6 +83,7 @@ export class SettlementService {
sel: BetSelectionLeg,
selectionCode: string,
scoreInput: ScoreInput,
statsInput: MatchStatsInput,
winnerTeamCode: string | null,
) {
return {
@@ -76,6 +92,7 @@ export class SettlementService {
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,
};
@@ -85,9 +102,71 @@ export class SettlementService {
sel: BetSelectionLeg,
selectionCode: string,
scoreInput: ScoreInput,
statsInput: MatchStatsInput,
winnerTeamCode: string | null,
): SelectionResult {
return settleSelection(this.buildSettleInput(sel, selectionCode, scoreInput, winnerTeamCode));
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(
@@ -118,6 +197,7 @@ export class SettlementService {
ftAway: number,
operatorId: bigint,
winnerTeamId?: bigint,
statsInput: MatchStatsInput = {},
) {
const match = await this.prisma.match.findFirst({
where: { id: matchId, deletedAt: null },
@@ -146,6 +226,7 @@ export class SettlementService {
}
}
const stats = this.statsInputFromSource(statsInput);
await this.prisma.matchScore.upsert({
where: { matchId },
create: {
@@ -154,6 +235,14 @@ export class SettlementService {
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,
},
@@ -162,6 +251,14 @@ export class SettlementService {
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,
},
@@ -172,7 +269,15 @@ export class SettlementService {
data: { status: 'PENDING_SETTLEMENT' },
});
return { matchId, htHome, htAway, ftHome, ftAway, winnerTeamId: winnerTeamId?.toString() ?? null };
return {
matchId,
htHome,
htAway,
ftHome,
ftAway,
...stats,
winnerTeamId: winnerTeamId?.toString() ?? null,
};
}
async previewSettlement(
@@ -185,6 +290,14 @@ export class SettlementService {
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;
},
) {
@@ -208,6 +321,14 @@ export class SettlementService {
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,
@@ -248,6 +369,14 @@ export class SettlementService {
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);
@@ -343,12 +472,21 @@ export class SettlementService {
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: {
@@ -357,6 +495,14 @@ export class SettlementService {
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,
},
@@ -365,6 +511,14 @@ export class SettlementService {
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,
},
@@ -379,6 +533,14 @@ export class SettlementService {
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;
},
) {
@@ -387,6 +549,14 @@ export class SettlementService {
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) {
@@ -402,11 +572,22 @@ export class SettlementService {
});
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,
};
}
@@ -419,21 +600,24 @@ export class SettlementService {
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?: {
htHome: number;
htAway: number;
ftHome: number;
ftAway: number;
winnerTeamId?: bigint | null;
},
scoreSource?: SettlementScoreSource,
) {
let scoreInput: ScoreInput;
let statsInput: MatchStatsInput;
let winnerTeamCode: string | null;
if (scoreSource) {
@@ -443,6 +627,7 @@ export class SettlementService {
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 } });
@@ -453,6 +638,7 @@ export class SettlementService {
ftHome: score.ftHomeScore ?? 0,
ftAway: score.ftAwayScore ?? 0,
};
statsInput = this.statsInputFromSource(score);
winnerTeamCode = await this.resolveWinnerTeamCode(score.winnerTeamId);
}
@@ -465,6 +651,11 @@ export class SettlementService {
});
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);
@@ -487,7 +678,7 @@ export class SettlementService {
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
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 });
@@ -504,7 +695,7 @@ export class SettlementService {
);
return {
odds: sel.odds,
result: this.settleLegResult(sel, code, scoreInput, winnerTeamCode),
result: this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode),
};
});
const parlay = calculateParlayPayout(bet.stake, legResults);
@@ -528,7 +719,7 @@ export class SettlementService {
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const legResult = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
const legResult = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
if (legResult === 'WIN' || legResult === 'HALF_WIN') wonLegsOnMatch += 1;
}
@@ -536,6 +727,7 @@ export class SettlementService {
bet,
matchId,
scoreInput,
statsInput,
winnerTeamCode,
selectionCodes,
);
@@ -552,16 +744,6 @@ export class SettlementService {
} else if (preview.betResult === 'WON') {
totalPayout = totalPayout.add(preview.payout);
}
} else if (preview.kind === 'LOST_ON_THIS_MATCH') {
lostOnThisMatch += 1;
items.push({
betId: bet.id,
betNo: bet.betNo,
betType: 'PARLAY',
result: 'LOST',
payout: preview.payout,
note: '本场已有输腿,串关整单作废',
});
} else {
pendingOtherMatches += 1;
items.push({
@@ -570,7 +752,7 @@ export class SettlementService {
betType: 'PARLAY',
result: 'PENDING_OTHER_MATCHES',
payout: new Decimal(0),
note: '本场腿已出结果,待其他场次结算后派彩',
note: '本场腿已出结果,待其他场次结算后统一结算',
});
}
}
@@ -593,24 +775,12 @@ export class SettlementService {
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: 'LOST_ON_THIS_MATCH'; payout: Decimal }
| { kind: 'PENDING_OTHER_MATCHES' } {
for (const sel of bet.selections) {
if (sel.matchId?.toString() !== matchId.toString()) continue;
const code = resolveSelectionCode(
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
if (result === 'LOSE' || result === 'HALF_LOSE') {
return { kind: 'LOST_ON_THIS_MATCH', payout: new Decimal(0) };
}
}
const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
for (const sel of bet.selections) {
if (sel.matchId?.toString() === matchId.toString()) {
@@ -620,7 +790,7 @@ export class SettlementService {
);
legResults.push({
odds: sel.odds,
result: this.settleLegResult(sel, code, scoreInput, winnerTeamCode),
result: this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode),
});
} else if (sel.resultStatus) {
legResults.push({
@@ -641,6 +811,7 @@ export class SettlementService {
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) {
@@ -648,7 +819,8 @@ export class SettlementService {
}
if (!ids.size) return new Map();
const rows = await this.prisma.marketSelection.findMany({
const client: PrismaClientLike = tx ?? this.prisma;
const rows = await client.marketSelection.findMany({
where: { id: { in: [...ids] } },
select: { id: true, selectionCode: true },
});
@@ -670,36 +842,63 @@ export class SettlementService {
await this.assertOutrightLeagueFixturesSettled(batch.match);
}
const scoreInput: ScoreInput = {
htHome: batch.htHomeScore ?? 0,
htAway: batch.htAwayScore ?? 0,
ftHome: batch.ftHomeScore ?? 0,
ftAway: batch.ftAwayScore ?? 0,
};
const existingScore = await this.prisma.matchScore.findUnique({
where: { matchId: batch.matchId },
});
const winnerTeamCode = await this.resolveWinnerTeamCode(existingScore?.winnerTeamId ?? null);
const pendingBets = await this.prisma.bet.findMany({
where: {
status: 'PENDING',
selections: { some: { matchId: batch.matchId } },
},
include: { selections: { orderBy: { sortOrder: 'asc' } }, user: true },
});
const selectionCodes = await this.loadSelectionCodes(pendingBets);
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(
batch.matchId,
currentBatch.matchId,
{
htHome: scoreInput.htHome,
htAway: scoreInput.htAway,
ftHome: scoreInput.ftHome,
ftAway: scoreInput.ftAway,
...statsInput,
winnerTeamId: existingScore?.winnerTeamId ?? null,
},
operatorId,
@@ -713,34 +912,37 @@ export class SettlementService {
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
const payout = calculatePayout(bet.stake, sel.odds, result);
const betStatus = this.betStatusFromSelection(result);
await tx.bet.update({
where: { id: bet.id },
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.wallet.settleBet(
bet.userId,
bet.stake,
await this.funds.settleBet({
userId: bet.userId,
stake: bet.stake,
payout,
bet.betNo,
this.walletResultFromSelection(result),
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: {
@@ -758,7 +960,7 @@ export class SettlementService {
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
legResults.push({ odds: sel.odds, result });
await tx.betSelection.update({
where: { id: sel.id },
@@ -773,29 +975,33 @@ export class SettlementService {
? 'PUSH'
: 'WON';
await tx.bet.update({
where: { id: bet.id },
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.wallet.settleBet(
bet.userId,
bet.stake,
parlayResult.payout,
bet.betNo,
parlayResult.betResult === 'LOST'
? 'LOSE'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WIN',
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: {
@@ -813,7 +1019,7 @@ export class SettlementService {
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
await tx.betSelection.update({
where: { id: sel.id },
data: { resultStatus: result },
@@ -840,29 +1046,33 @@ export class SettlementService {
? 'PUSH'
: 'WON';
await tx.bet.update({
where: { id: bet.id },
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.wallet.settleBet(
bet.userId,
bet.stake,
parlayResult.payout,
bet.betNo,
parlayResult.betResult === 'LOST'
? 'LOSE'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WIN',
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: {
@@ -879,11 +1089,16 @@ export class SettlementService {
await tx.settlementBatch.update({
where: { id: batchId },
data: { status: 'CONFIRMED', confirmedAt: new Date() },
data: {
status: 'CONFIRMED',
confirmedAt: new Date(),
operatorId,
totalBets: settledCount,
},
});
await tx.match.update({
where: { id: batch.matchId },
where: { id: currentBatch.matchId },
data: { status: 'SETTLED' },
});
});
@@ -1090,6 +1305,7 @@ export class SettlementService {
},
matchId: bigint,
scoreInput: ScoreInput,
statsInput: MatchStatsInput,
winnerTeamCode: string | null,
selectionCodes: Map<string, string | null>,
) {
@@ -1099,7 +1315,7 @@ export class SettlementService {
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
const payout = calculatePayout(bet.stake, sel.odds, result);
return {
payout,
@@ -1118,7 +1334,7 @@ export class SettlementService {
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
legUpdates.set(sel.id.toString(), result);
} else {
if (!sel.resultStatus) {
@@ -1142,7 +1358,7 @@ export class SettlementService {
async previewResettlement(
matchId: bigint,
scoreInput: ScoreInput,
scoreInput: ScoreInput & MatchStatsInput,
operatorId: bigint,
reason?: string,
winnerTeamId?: bigint,
@@ -1158,6 +1374,7 @@ export class SettlementService {
const winnerTeamCode = winnerTeamId
? await this.resolveWinnerTeamCode(winnerTeamId)
: null;
const statsInput = this.statsInputFromSource(scoreInput);
const settledBets = await this.prisma.bet.findMany({
where: {
@@ -1168,6 +1385,11 @@ export class SettlementService {
});
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;
@@ -1187,6 +1409,7 @@ export class SettlementService {
bet,
matchId,
scoreInput,
statsInput,
winnerTeamCode,
selectionCodes,
);
@@ -1215,6 +1438,14 @@ export class SettlementService {
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,
@@ -1233,6 +1464,7 @@ export class SettlementService {
htAway: scoreInput.htAway,
ftHome: scoreInput.ftHome,
ftAway: scoreInput.ftAway,
...statsInput,
winnerTeamId,
},
operatorId,
@@ -1259,36 +1491,60 @@ export class SettlementService {
if (!batch.isResettle) throw appBadRequest('RESETTLE_BATCH_ONLY');
if (batch.status !== 'PREVIEW') throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED');
const scoreInput: ScoreInput = {
htHome: batch.htHomeScore ?? 0,
htAway: batch.htAwayScore ?? 0,
ftHome: batch.ftHomeScore ?? 0,
ftAway: batch.ftAwayScore ?? 0,
};
const existingScore = await this.prisma.matchScore.findUnique({
where: { matchId: batch.matchId },
});
const winnerTeamCode = await this.resolveWinnerTeamCode(existingScore?.winnerTeamId ?? null);
const settledBets = await this.prisma.bet.findMany({
where: {
status: { in: ['WON', 'LOST', 'PUSH', 'VOID'] },
selections: { some: { matchId: batch.matchId } },
},
include: { selections: { orderBy: { sortOrder: 'asc' } } },
});
const selectionCodes = await this.loadSelectionCodes(settledBets);
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(
batch.matchId,
currentBatch.matchId,
{
htHome: scoreInput.htHome,
htAway: scoreInput.htAway,
ftHome: scoreInput.ftHome,
ftAway: scoreInput.ftAway,
...statsInput,
winnerTeamId: existingScore?.winnerTeamId ?? null,
},
operatorId,
@@ -1299,8 +1555,9 @@ export class SettlementService {
const oldPayout = new Decimal(bet.actualReturn);
const outcome = await this.computeBetOutcome(
bet,
batch.matchId,
currentBatch.matchId,
scoreInput,
statsInput,
winnerTeamCode,
selectionCodes,
);
@@ -1328,9 +1585,16 @@ export class SettlementService {
},
});
await this.wallet.applyResettleDelta(bet.userId, delta, bet.betNo, tx);
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: {
@@ -1345,7 +1609,12 @@ export class SettlementService {
await tx.settlementBatch.update({
where: { id: batchId },
data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId },
data: {
status: 'CONFIRMED',
confirmedAt: new Date(),
operatorId,
totalBets: affectedCount,
},
});
});
@@ -1357,18 +1626,57 @@ export class SettlementService {
}
async voidMatchBets(matchId: bigint) {
const bets = await this.prisma.bet.findMany({
where: { status: 'PENDING', selections: { some: { matchId } } },
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 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() },
});
for (const agentId of agentIds) {
await this.agents.recalculateUsedCredit(agentId);
}
return { voidedCount: bets.length };
return { voidedCount };
}
}

View File

@@ -2,10 +2,12 @@ import Decimal from 'decimal.js';
import {
calculatePayout,
settleSelection,
FT_CORRECT_SCORE_TEMPLATE,
HT_CORRECT_SCORE_TEMPLATE,
type ScoreInput,
} from './domain/settlement-calculator';
import {
FT_CORRECT_SCORE_TEMPLATE,
HT_CORRECT_SCORE_TEMPLATE,
} from '@thebet365/shared';
export type SmartScoreStrategy =
| 'MIN_PAYOUT'
@@ -39,8 +41,8 @@ export type ScoreEvaluation = {
};
function templateForMarket(marketType: string): string[] {
if (marketType === 'FT_CORRECT_SCORE') return FT_CORRECT_SCORE_TEMPLATE;
if (marketType.includes('CORRECT_SCORE')) return HT_CORRECT_SCORE_TEMPLATE;
if (marketType === 'FT_CORRECT_SCORE') return [...FT_CORRECT_SCORE_TEMPLATE];
if (marketType.includes('CORRECT_SCORE')) return [...HT_CORRECT_SCORE_TEMPLATE];
return [];
}