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

@@ -0,0 +1,153 @@
import { Decimal } from '@prisma/client/runtime/library';
import { BetsService } from './bets.service';
import { expectAppError } from '../../testing/prisma-mock';
describe('BetsService', () => {
const tx = {
bet: {
findUnique: jest.fn(),
create: jest.fn(),
},
marketSelection: {
findUnique: jest.fn(),
},
};
const prisma = {
...tx,
$transaction: jest.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
};
const funds = {
freezeBet: jest.fn(),
};
const bettingLimits = {
validateBet: jest.fn(),
};
let service: BetsService;
function selection(id: bigint, oddsVersion: bigint, matchId = 88n) {
return {
id,
marketId: id + 100n,
selectionCode: id === 1n ? 'HOME' : 'AWAY',
selectionName: `Selection ${id}`,
nameI18n: null,
odds: new Decimal(id === 1n ? 1.9 : 2.1),
oddsVersion,
status: 'OPEN',
market: {
matchId,
marketType: 'FT_1X2',
period: 'FT',
nameI18n: null,
lineValue: null,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
status: 'OPEN',
match: {
status: 'PUBLISHED',
sportType: 'FOOTBALL',
isOutright: false,
startTime: new Date(Date.now() + 60 * 60 * 1000),
},
},
};
}
beforeEach(() => {
jest.clearAllMocks();
service = new BetsService(prisma as never, funds as never, bettingLimits as never);
tx.bet.findUnique.mockResolvedValue(null);
tx.bet.create.mockResolvedValue({ id: 123n, betNo: 'BET-OK', selections: [] });
bettingLimits.validateBet.mockResolvedValue(undefined);
tx.marketSelection.findUnique.mockImplementation(({ where }: { where: { id: bigint } }) =>
Promise.resolve(selection(where.id, where.id)),
);
});
it('rejects a parlay with multiple legs from the same match', async () => {
await expect(
service.placeParlayBet(
7n,
3n,
[
{ selectionId: 1n, oddsVersion: 1n },
{ selectionId: 2n, oddsVersion: 2n },
],
50,
'REQ-SAME-MATCH',
),
).rejects.toMatchObject(expectAppError('PARLAY_SAME_MATCH_FORBIDDEN'));
expect(tx.bet.create).not.toHaveBeenCalled();
expect(bettingLimits.validateBet).not.toHaveBeenCalled();
expect(funds.freezeBet).not.toHaveBeenCalled();
});
it('allows a parlay only when every leg is from a different match', async () => {
tx.marketSelection.findUnique.mockImplementation(({ where }: { where: { id: bigint } }) =>
Promise.resolve(selection(where.id, where.id, where.id === 1n ? 88n : 99n)),
);
await service.placeParlayBet(
7n,
3n,
[
{ selectionId: 1n, oddsVersion: 1n },
{ selectionId: 2n, oddsVersion: 2n },
],
50,
'REQ-SAME-MATCH',
);
expect(tx.bet.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
betType: 'PARLAY',
selections: expect.objectContaining({
create: expect.arrayContaining([
expect.objectContaining({ matchId: 88n, selectionId: 1n }),
expect.objectContaining({ matchId: 99n, selectionId: 2n }),
]),
}),
}),
}),
);
expect(bettingLimits.validateBet).toHaveBeenCalledWith(
expect.objectContaining({ betType: 'PARLAY', tx }),
);
expect(funds.freezeBet).toHaveBeenCalledWith(
expect.objectContaining({ userId: 7n, stake: expect.any(Decimal), tx }),
);
});
it('rejects a single bet when the market disables single betting', async () => {
tx.marketSelection.findUnique.mockResolvedValue({
...selection(1n, 1n),
market: {
...selection(1n, 1n).market,
allowSingle: false,
},
});
await expect(
service.placeSingleBet(7n, 3n, 1n, 1n, 50, 'REQ-SINGLE-DISABLED'),
).rejects.toMatchObject(expectAppError('SINGLE_MARKET_NOT_ALLOWED'));
expect(tx.bet.create).not.toHaveBeenCalled();
expect(bettingLimits.validateBet).not.toHaveBeenCalled();
expect(funds.freezeBet).not.toHaveBeenCalled();
});
it('revalidates odds version inside the placement transaction', async () => {
tx.marketSelection.findUnique.mockResolvedValue(selection(1n, 2n));
await expect(
service.placeSingleBet(7n, 3n, 1n, 1n, 50, 'REQ-ODDS-CHANGED'),
).rejects.toMatchObject(expectAppError('ODDS_CHANGED'));
expect(tx.bet.create).not.toHaveBeenCalled();
expect(funds.freezeBet).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,7 @@
import { Injectable, ConflictException, BadRequestException } from '@nestjs/common';
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 { BettingLimitsService } from './betting-limits.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateBetNo } from '../../shared/common/decorators';
@@ -10,12 +10,17 @@ import {
PARLAY_MIN_LEGS,
PARLAY_MAX_LEGS,
canSelectForParlay,
hasDuplicateParlayMatch,
defaultMarketName,
defaultSelectionName,
isPreMatchKickoff,
isSupportedSport,
resolveMarketText,
resolveTranslationFallback,
} from '@thebet365/shared';
type TxClient = Prisma.TransactionClient;
type PrismaClientLike = PrismaService | TxClient;
type MatchContext = {
matchLabel: string;
leagueName: string;
@@ -25,6 +30,7 @@ type BetSelectionRow = {
matchId: bigint | null;
marketType: string;
period: string | null;
marketNameSnapshot: string | null;
selectionNameSnapshot: string;
handicapLine: Decimal | null;
totalLine: Decimal | null;
@@ -32,6 +38,16 @@ type BetSelectionRow = {
sortOrder?: number;
};
type SelectionWithMarketLabel = {
selectionCode: string;
selectionName: string;
nameI18n: unknown;
market: {
marketType: string;
nameI18n: unknown;
};
};
interface BetSelectionInput {
selectionId: bigint;
oddsVersion: bigint;
@@ -42,16 +58,17 @@ interface BetSelectionInput {
export class BetsService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
private funds: FundsPostingService,
private bettingLimits: BettingLimitsService,
) {}
private async validateSelection(
selectionId: bigint,
oddsVersion: bigint,
options?: { forParlay?: boolean },
options?: { forParlay?: boolean; tx?: TxClient },
) {
const selection = await this.prisma.marketSelection.findUnique({
const client: PrismaClientLike = options?.tx ?? this.prisma;
const selection = await client.marketSelection.findUnique({
where: { id: selectionId },
include: { market: { include: { match: true } } },
});
@@ -59,6 +76,10 @@ export class BetsService {
if (!selection) throw appBadRequest('SELECTION_NOT_FOUND');
if (selection.status !== 'OPEN') throw appBadRequest('SELECTION_CLOSED');
if (selection.market.status !== 'OPEN') throw appBadRequest('MARKET_CLOSED');
if (selection.market.showOnPlayer === false) throw appBadRequest('MARKET_CLOSED');
if (!options?.forParlay && selection.market.allowSingle === false) {
throw appBadRequest('SINGLE_MARKET_NOT_ALLOWED');
}
if (selection.market.match.status !== 'PUBLISHED') {
throw appBadRequest('MATCH_NOT_BETTING');
}
@@ -68,14 +89,6 @@ export class BetsService {
if (!selection.market.match.isOutright && !isPreMatchKickoff(selection.market.match.startTime)) {
throw appBadRequest('PRE_MATCH_ONLY');
}
// Block correct-score bets when the match has the CS toggle turned off
const CS_MARKET_TYPES = ['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE'];
if (
CS_MARKET_TYPES.includes(selection.market.marketType) &&
!(selection.market.match.correctScoreEnabled ?? true)
) {
throw appBadRequest('CORRECT_SCORE_DISABLED');
}
if (selection.oddsVersion !== oddsVersion) {
throw appBadRequest('ODDS_CHANGED');
}
@@ -112,57 +125,68 @@ export class BetsService {
) {
if (stake <= 0) throw appBadRequest('INVALID_STAKE');
const existing = await this.prisma.bet.findUnique({
where: { userId_requestId: { userId, requestId } },
});
if (existing) return existing;
const selection = await this.validateSelection(selectionId, oddsVersion, { forParlay: false });
const odds = new Decimal(selection.odds.toString());
const stakeDec = new Decimal(stake);
const potentialReturn = stakeDec.mul(odds);
await this.bettingLimits.validateBet({
userId,
betType: 'SINGLE',
stake,
potentialReturn,
});
const betNo = generateBetNo();
const bet = await this.prisma.$transaction(async (tx) => {
const created = await tx.bet.create({
data: {
betNo,
try {
return await this.prisma.$transaction(async (tx) => {
const existing = await tx.bet.findUnique({
where: { userId_requestId: { userId, requestId } },
});
if (existing) return existing;
const selection = await this.validateSelection(selectionId, oddsVersion, {
forParlay: false,
tx,
});
const locale = await this.resolveUserLocale(userId, tx);
const odds = new Decimal(selection.odds.toString());
const potentialReturn = stakeDec.mul(odds);
await this.bettingLimits.validateBet({
userId,
agentId,
betType: 'SINGLE',
stake: stakeDec,
totalOdds: odds,
stake,
potentialReturn,
requestId,
selections: {
create: {
matchId: selection.market.matchId,
marketId: selection.marketId,
selectionId: selection.id,
marketType: selection.market.marketType,
period: selection.market.period,
selectionNameSnapshot: selection.selectionName,
handicapLine: selection.market.lineValue,
totalLine: selection.market.lineValue,
odds,
oddsVersion,
tx,
});
const betNo = generateBetNo();
const created = await tx.bet.create({
data: {
betNo,
userId,
agentId,
betType: 'SINGLE',
stake: stakeDec,
totalOdds: odds,
potentialReturn,
requestId,
selections: {
create: {
matchId: selection.market.matchId,
marketId: selection.marketId,
selectionId: selection.id,
marketType: selection.market.marketType,
period: selection.market.period,
marketNameSnapshot: this.marketNameSnapshot(selection, locale),
selectionNameSnapshot: this.selectionNameSnapshot(selection, locale),
handicapLine: selection.market.lineValue,
totalLine: selection.market.lineValue,
odds,
oddsVersion,
},
},
},
},
include: { selections: true },
include: { selections: true },
});
await this.funds.freezeBet({ userId, stake: stakeDec, betNo, tx });
return created;
});
await this.wallet.freezeForBet(userId, stakeDec, betNo);
return created;
});
return bet;
} catch (error) {
const existing = await this.findExistingRequestBetOnUniqueError(userId, requestId, error);
if (existing) return existing;
throw error;
}
}
async placeParlayBet(
@@ -177,73 +201,100 @@ export class BetsService {
throw appBadRequest('PARLAY_LEG_COUNT_INVALID', { min: PARLAY_MIN_LEGS, max: PARLAY_MAX_LEGS });
}
const existing = await this.prisma.bet.findUnique({
where: { userId_requestId: { userId, requestId } },
});
if (existing) return existing;
const selections: Awaited<ReturnType<typeof this.validateSelection>>[] = [];
for (const leg of legs) {
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion, { forParlay: true });
selections.push(sel);
}
const matchIds = selections.map((s) => s.market.matchId);
if (hasDuplicateParlayMatch(matchIds)) {
throw appBadRequest('PARLAY_SAME_MATCH_FORBIDDEN');
}
let totalOdds = new Decimal(1);
for (const sel of selections) {
totalOdds = totalOdds.mul(sel.odds.toString());
}
const stakeDec = new Decimal(stake);
const potentialReturn = stakeDec.mul(totalOdds);
await this.bettingLimits.validateBet({
userId,
betType: 'PARLAY',
stake,
potentialReturn,
});
const betNo = generateBetNo();
const bet = await this.prisma.$transaction(async (tx) => {
const created = await tx.bet.create({
data: {
betNo,
try {
return await this.prisma.$transaction(async (tx) => {
const existing = await tx.bet.findUnique({
where: { userId_requestId: { userId, requestId } },
});
if (existing) return existing;
const selections: Awaited<ReturnType<typeof this.validateSelection>>[] = [];
for (const leg of legs) {
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion, {
forParlay: true,
tx,
});
selections.push(sel);
}
const matchIds = new Set<string>();
for (const sel of selections) {
const matchKey = sel.market.matchId.toString();
if (matchIds.has(matchKey)) {
throw appBadRequest('PARLAY_SAME_MATCH_FORBIDDEN');
}
matchIds.add(matchKey);
}
const locale = await this.resolveUserLocale(userId, tx);
let totalOdds = new Decimal(1);
for (const sel of selections) {
totalOdds = totalOdds.mul(sel.odds.toString());
}
const potentialReturn = stakeDec.mul(totalOdds);
await this.bettingLimits.validateBet({
userId,
agentId,
betType: 'PARLAY',
stake: stakeDec,
totalOdds,
stake,
potentialReturn,
requestId,
selections: {
create: selections.map((sel, i) => ({
matchId: sel.market.matchId,
marketId: sel.marketId,
selectionId: sel.id,
marketType: sel.market.marketType,
period: sel.market.period,
selectionNameSnapshot: sel.selectionName,
handicapLine: sel.market.lineValue,
totalLine: sel.market.lineValue,
odds: sel.odds,
oddsVersion: legs[i].oddsVersion,
sortOrder: i,
})),
tx,
});
const betNo = generateBetNo();
const created = await tx.bet.create({
data: {
betNo,
userId,
agentId,
betType: 'PARLAY',
stake: stakeDec,
totalOdds,
potentialReturn,
requestId,
selections: {
create: selections.map((sel, i) => ({
matchId: sel.market.matchId,
marketId: sel.marketId,
selectionId: sel.id,
marketType: sel.market.marketType,
period: sel.market.period,
marketNameSnapshot: this.marketNameSnapshot(sel, locale),
selectionNameSnapshot: this.selectionNameSnapshot(sel, locale),
handicapLine: sel.market.lineValue,
totalLine: sel.market.lineValue,
odds: sel.odds,
oddsVersion: legs[i].oddsVersion,
sortOrder: i,
})),
},
},
},
include: { selections: true },
include: { selections: true },
});
await this.funds.freezeBet({ userId, stake: stakeDec, betNo, tx });
return created;
});
} catch (error) {
const existing = await this.findExistingRequestBetOnUniqueError(userId, requestId, error);
if (existing) return existing;
throw error;
}
}
await this.wallet.freezeForBet(userId, stakeDec, betNo);
return created;
});
return bet;
private async findExistingRequestBetOnUniqueError(
userId: bigint,
requestId: string,
error: unknown,
) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
return this.prisma.bet.findUnique({ where: { userId_requestId: { userId, requestId } } });
}
return null;
}
async getUserBets(
@@ -392,6 +443,7 @@ export class BetsService {
matchLabel: ctx?.matchLabel ?? '—',
leagueName: ctx?.leagueName ?? '',
marketType: s.marketType,
marketName: s.marketNameSnapshot ?? null,
period: s.period,
selectionName: s.selectionNameSnapshot,
odds: this.dec(s.odds),
@@ -584,6 +636,7 @@ export class BetsService {
matchLabel: preview?.matchLabel ?? '—',
leagueName: preview?.leagueName ?? '',
marketType: s.marketType,
marketName: s.marketNameSnapshot,
period: s.period,
selectionName: s.selectionNameSnapshot,
handicapLine: s.handicapLine ? this.dec(s.handicapLine) : null,
@@ -596,4 +649,37 @@ export class BetsService {
}),
};
}
private async resolveUserLocale(userId: bigint, client: PrismaClientLike) {
const userDelegate = (client as PrismaClientLike & {
user?: {
findUnique?: PrismaService['user']['findUnique'];
};
}).user;
if (!userDelegate?.findUnique) return 'zh-CN';
const user = await userDelegate.findUnique({
where: { id: userId },
select: { locale: true, preferences: { select: { locale: true } } },
});
return user?.preferences?.locale || user?.locale || 'zh-CN';
}
private marketNameSnapshot(selection: SelectionWithMarketLabel, locale: string) {
const market = selection.market;
return (
resolveMarketText(market.nameI18n, locale, defaultMarketName(market.marketType, locale)) ||
market.marketType
);
}
private selectionNameSnapshot(selection: SelectionWithMarketLabel, locale: string) {
return (
resolveMarketText(
selection.nameI18n,
locale,
defaultSelectionName(selection.market.marketType, selection.selectionCode, locale) ||
selection.selectionName,
) || selection.selectionName
);
}
}

View File

@@ -1,5 +1,6 @@
import { BettingLimitsService } from './betting-limits.service';
import { Decimal } from '@prisma/client/runtime/library';
import { expectAppError } from '../../testing/prisma-mock';
describe('BettingLimitsService', () => {
const prisma = {
@@ -27,7 +28,7 @@ describe('BettingLimitsService', () => {
stake: 0.5,
potentialReturn: new Decimal(1),
}),
).rejects.toThrow('Minimum stake is 1');
).rejects.toMatchObject(expectAppError('MIN_STAKE'));
});
it('rejects stake above single max', async () => {
@@ -38,7 +39,7 @@ describe('BettingLimitsService', () => {
stake: 60000,
potentialReturn: new Decimal(70000),
}),
).rejects.toThrow('Maximum stake is 50000');
).rejects.toMatchObject(expectAppError('MAX_STAKE'));
});
it('rejects potential return above payout cap', async () => {
@@ -49,6 +50,6 @@ describe('BettingLimitsService', () => {
stake: 100,
potentialReturn: new Decimal(600000),
}),
).rejects.toThrow('Potential return exceeds limit');
).rejects.toMatchObject(expectAppError('MAX_PAYOUT'));
});
});

View File

@@ -1,8 +1,12 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Decimal } from '@prisma/client/runtime/library';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { appBadRequest } from '../../shared/common/app-error';
type TxClient = Prisma.TransactionClient;
type PrismaClientLike = PrismaService | TxClient;
export type BettingLimits = {
minStake: number;
maxStakeSingle: number;
@@ -34,8 +38,12 @@ const DEFAULTS: BettingLimits = {
export class BettingLimitsService {
constructor(private prisma: PrismaService) {}
private async getNumber(key: string, fallback: number): Promise<number> {
const row = await this.prisma.systemConfig.findUnique({ where: { configKey: key } });
private async getNumber(
key: string,
fallback: number,
client: PrismaClientLike = this.prisma,
): Promise<number> {
const row = await client.systemConfig.findUnique({ where: { configKey: key } });
if (!row) return fallback;
const n = Number(row.configValue);
return Number.isFinite(n) && n >= 0 ? n : fallback;
@@ -49,14 +57,15 @@ export class BettingLimitsService {
});
}
async getLimits(): Promise<BettingLimits> {
async getLimits(tx?: TxClient): Promise<BettingLimits> {
const client = tx ?? this.prisma;
return {
minStake: await this.getNumber(BET_LIMIT_KEYS.minStake, DEFAULTS.minStake),
maxStakeSingle: await this.getNumber(BET_LIMIT_KEYS.maxStakeSingle, DEFAULTS.maxStakeSingle),
maxStakeParlay: await this.getNumber(BET_LIMIT_KEYS.maxStakeParlay, DEFAULTS.maxStakeParlay),
maxPayoutSingle: await this.getNumber(BET_LIMIT_KEYS.maxPayoutSingle, DEFAULTS.maxPayoutSingle),
maxPayoutParlay: await this.getNumber(BET_LIMIT_KEYS.maxPayoutParlay, DEFAULTS.maxPayoutParlay),
dailyStakeLimit: await this.getNumber(BET_LIMIT_KEYS.dailyStakeLimit, DEFAULTS.dailyStakeLimit),
minStake: await this.getNumber(BET_LIMIT_KEYS.minStake, DEFAULTS.minStake, client),
maxStakeSingle: await this.getNumber(BET_LIMIT_KEYS.maxStakeSingle, DEFAULTS.maxStakeSingle, client),
maxStakeParlay: await this.getNumber(BET_LIMIT_KEYS.maxStakeParlay, DEFAULTS.maxStakeParlay, client),
maxPayoutSingle: await this.getNumber(BET_LIMIT_KEYS.maxPayoutSingle, DEFAULTS.maxPayoutSingle, client),
maxPayoutParlay: await this.getNumber(BET_LIMIT_KEYS.maxPayoutParlay, DEFAULTS.maxPayoutParlay, client),
dailyStakeLimit: await this.getNumber(BET_LIMIT_KEYS.dailyStakeLimit, DEFAULTS.dailyStakeLimit, client),
};
}
@@ -85,8 +94,10 @@ export class BettingLimitsService {
betType: 'SINGLE' | 'PARLAY';
stake: number;
potentialReturn: Decimal;
tx?: TxClient;
}) {
const limits = await this.getLimits();
const client = params.tx ?? this.prisma;
const limits = await this.getLimits(params.tx);
const stake = params.stake;
if (stake < limits.minStake) {
@@ -110,7 +121,7 @@ export class BettingLimitsService {
const endOfDay = new Date();
endOfDay.setHours(23, 59, 59, 999);
const agg = await this.prisma.bet.aggregate({
const agg = await client.bet.aggregate({
where: {
userId: params.userId,
placedAt: { gte: startOfDay, lte: endOfDay },