重构
This commit is contained in:
@@ -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, [
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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' }),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user