重构
This commit is contained in:
153
apps/api/src/domains/betting/bets.service.spec.ts
Normal file
153
apps/api/src/domains/betting/bets.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user