feat: WC2026 赛事 seed、生产上线初始化脚本与目录归档

重构 seed 为 WC2026 72 场小组赛与 48 强优胜盘;新增 production 模式仅保留 admin 与赛事示例;提供 prod-init-db 全量重置脚本;管理端 i18n 分包与赛事归档能力。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-12 18:17:00 +08:00
parent 8f14e85ebd
commit e7e938f261
94 changed files with 12332 additions and 976 deletions

View File

@@ -806,6 +806,34 @@ export class AgentsService {
return this.getDirectPlayerDetail(agentId, playerId);
}
async deleteDirectPlayer(agentId: bigint, playerId: bigint) {
await this.requireDirectPlayer(agentId, playerId);
const betCount = await this.prisma.bet.count({
where: {
userId: playerId,
status: 'PENDING',
},
});
if (betCount > 0) {
throw appBadRequest('PLAYER_HAS_PENDING_BETS');
}
const wallet = await this.prisma.wallet.findUnique({ where: { userId: playerId } });
if (wallet) {
const available = new Decimal(wallet.availableBalance);
const frozen = new Decimal(wallet.frozenBalance);
if (available.gt(0) || frozen.gt(0)) {
throw appBadRequest('PLAYER_HAS_BALANCE');
}
}
return this.prisma.user.update({
where: { id: playerId },
data: { deletedAt: new Date(), status: 'SUSPENDED' },
});
}
async listAgentsAdmin(params?: {
page?: number;
pageSize?: number;
@@ -1696,9 +1724,18 @@ export class AgentsService {
return user;
}
async getPortalAgentDirectPlayers(rootAgentId: bigint, targetAgentId: bigint) {
async getPortalAgentDirectPlayers(
rootAgentId: bigint,
targetAgentId: bigint,
opts?: { page?: number; pageSize?: number },
) {
await this.assertDescendantAgent(rootAgentId, targetAgentId);
const players = await this.getDirectPlayers(targetAgentId);
const page = Math.max(1, opts?.page ?? 1);
const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 20));
const { items: players, total } = await this.getDirectPlayers(targetAgentId, {
page,
pageSize,
});
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: targetAgentId },
select: {
@@ -1714,7 +1751,7 @@ export class AgentsService {
players.map((p) => ({ id: BigInt(p.id), parentId: targetAgentId })),
parentCashbackMap,
);
return players.map((p) => ({
const mapped = players.map((p) => ({
...p,
parentAgentId: targetKey,
parentAgentUsername,
@@ -1722,18 +1759,30 @@ export class AgentsService {
inChain: true,
isDirect: targetKey === rootKey,
}));
return { items: mapped, total, page, pageSize };
}
async getDirectPlayers(agentId: bigint) {
const rows = await this.prisma.user.findMany({
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
include: {
wallet: true,
usedInvite: { select: { code: true } },
},
orderBy: { createdAt: 'desc' },
});
return rows.map((u) => ({
async getDirectPlayers(
agentId: bigint,
opts?: { page?: number; pageSize?: number },
) {
const where = { parentId: agentId, userType: 'PLAYER' as const, deletedAt: null };
const page = Math.max(1, opts?.page ?? 1);
const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 20));
const [total, rows] = await Promise.all([
this.prisma.user.count({ where }),
this.prisma.user.findMany({
where,
include: {
wallet: true,
usedInvite: { select: { code: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
const items = rows.map((u) => ({
id: u.id.toString(),
username: u.username,
status: u.status,
@@ -1746,6 +1795,7 @@ export class AgentsService {
}
: undefined,
}));
return { items, total, page, pageSize };
}
async getChildAgents(agentId: bigint) {

View File

@@ -68,6 +68,14 @@ 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');
}

View File

@@ -0,0 +1,174 @@
import { Decimal } from '@prisma/client/runtime/library';
import { CatalogArchiveService } from './catalog-archive.service';
describe('CatalogArchiveService', () => {
const matchId = BigInt(10);
const leagueId = BigInt(1);
let prisma: {
match: { findFirst: jest.Mock; update: jest.Mock; findMany: jest.Mock; updateMany: jest.Mock };
league: { findFirst: jest.Mock; update: jest.Mock };
bet: { findMany: jest.Mock };
settlementBatch: { findFirst: jest.Mock; findMany: jest.Mock };
market: { updateMany: jest.Mock };
marketSelection: { updateMany: jest.Mock };
entityTranslation: { findFirst: jest.Mock };
$transaction: jest.Mock;
};
let matches: { betStatsForMatches: jest.Mock };
let service: CatalogArchiveService;
beforeEach(() => {
prisma = {
match: {
findFirst: jest.fn(),
update: jest.fn(),
findMany: jest.fn(),
updateMany: jest.fn(),
},
league: { findFirst: jest.fn(), update: jest.fn() },
bet: { findMany: jest.fn().mockResolvedValue([]) },
settlementBatch: { findFirst: jest.fn().mockResolvedValue(null), findMany: jest.fn().mockResolvedValue([]) },
market: { updateMany: jest.fn() },
marketSelection: { updateMany: jest.fn() },
entityTranslation: { findFirst: jest.fn().mockResolvedValue(null) },
$transaction: jest.fn(async (fn: (tx: typeof prisma) => Promise<void>) => fn(prisma)),
};
matches = { betStatsForMatches: jest.fn().mockResolvedValue(new Map()) };
service = new CatalogArchiveService(prisma as never, matches as never);
});
const baseMatch = {
id: matchId,
status: 'PUBLISHED',
isOutright: false,
matchName: null,
homeTeamId: BigInt(2),
awayTeamId: BigInt(3),
homeTeam: { code: 'A' },
awayTeam: { code: 'B' },
league: { id: leagueId },
};
it('preview flags pending bets and unsettled match', async () => {
prisma.match.findFirst.mockResolvedValue(baseMatch);
prisma.bet.findMany.mockResolvedValue([{ stake: new Decimal(50) }, { stake: new Decimal(25) }]);
const preview = await service.getMatchArchivePreview(matchId);
expect(preview.pendingBetCount).toBe(2);
expect(preview.pendingStake).toBe('75');
expect(preview.requiresForce).toBe(true);
expect(preview.warnings).toEqual(expect.arrayContaining(['PENDING_BETS', 'UNSETTLED_MATCH']));
});
it('archive without force throws ARCHIVE_BLOCKED when warnings exist', async () => {
prisma.match.findFirst.mockResolvedValue(baseMatch);
prisma.bet.findMany.mockResolvedValue([{ stake: new Decimal(10) }]);
await expect(service.archiveMatch(matchId, { force: false })).rejects.toMatchObject({
response: expect.objectContaining({ code: 'ARCHIVE_BLOCKED' }),
});
});
it('archive with force soft-deletes and cancels match', async () => {
prisma.match.findFirst.mockResolvedValue({ ...baseMatch, status: 'DRAFT' });
prisma.match.update.mockResolvedValue({});
const result = await service.archiveMatch(matchId, { force: true });
expect(result.matchId).toBe(matchId.toString());
expect(prisma.marketSelection.updateMany).toHaveBeenCalled();
expect(prisma.market.updateMany).toHaveBeenCalled();
expect(prisma.match.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: matchId },
data: expect.objectContaining({ status: 'CANCELLED', deletedAt: expect.any(Date) }),
}),
);
});
it('league preview blocks when child match is not terminal', async () => {
prisma.league.findFirst.mockResolvedValue({ id: leagueId });
prisma.match.findMany.mockResolvedValue([
{
id: matchId,
status: 'CLOSED',
isOutright: false,
matchName: null,
homeTeamId: BigInt(2),
awayTeamId: BigInt(3),
homeTeam: { code: 'A' },
awayTeam: { code: 'B' },
},
]);
matches.betStatsForMatches.mockResolvedValue(
new Map([[matchId.toString(), { betCount: 0, totalStake: '0', pendingCount: 0 }]]),
);
const preview = await service.getLeagueArchivePreview(leagueId);
expect(preview.canArchive).toBe(false);
expect(preview.blockingMatches).toHaveLength(1);
expect(preview.blockingMatches[0].status).toBe('CLOSED');
});
it('league archive cascades when all children are settled', async () => {
prisma.league.findFirst.mockResolvedValue({ id: leagueId });
prisma.match.findMany
.mockResolvedValueOnce([
{
id: matchId,
status: 'SETTLED',
isOutright: false,
matchName: null,
homeTeamId: BigInt(2),
awayTeamId: BigInt(3),
homeTeam: { code: 'A' },
awayTeam: { code: 'B' },
},
])
.mockResolvedValueOnce([{ id: matchId, status: 'SETTLED' }]);
matches.betStatsForMatches.mockResolvedValue(
new Map([[matchId.toString(), { betCount: 1, totalStake: '100', pendingCount: 0 }]]),
);
const result = await service.archiveLeague(leagueId);
expect(result.leagueId).toBe(leagueId.toString());
expect(prisma.match.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: { in: [matchId] } },
data: { deletedAt: expect.any(Date) },
}),
);
expect(prisma.league.update).toHaveBeenCalledWith(
expect.objectContaining({
data: { deletedAt: expect.any(Date), isActive: false },
}),
);
});
it('league archive throws LEAGUE_ARCHIVE_NOT_READY when blocked', async () => {
prisma.league.findFirst.mockResolvedValue({ id: leagueId });
prisma.match.findMany.mockResolvedValue([
{
id: matchId,
status: 'PUBLISHED',
isOutright: false,
matchName: null,
homeTeamId: BigInt(2),
awayTeamId: BigInt(3),
homeTeam: { code: 'A' },
awayTeam: { code: 'B' },
},
]);
matches.betStatsForMatches.mockResolvedValue(
new Map([[matchId.toString(), { betCount: 0, totalStake: '0', pendingCount: 0 }]]),
);
await expect(service.archiveLeague(leagueId)).rejects.toMatchObject({
response: expect.objectContaining({ code: 'LEAGUE_ARCHIVE_NOT_READY' }),
});
});
});

View File

@@ -0,0 +1,302 @@
import { Injectable } from '@nestjs/common';
import { Decimal } from '@prisma/client/runtime/library';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { appBadRequest, appConflict, appNotFound } from '../../shared/common/app-error';
import { MatchesService } from './matches.service';
const TERMINAL_MATCH_STATUSES = new Set(['SETTLED', 'CANCELLED', 'VOID']);
export type MatchArchiveWarning = 'PENDING_BETS' | 'UNSETTLED_MATCH' | 'PREVIEW_BATCH';
export type MatchArchivePreview = {
matchId: string;
matchStatus: string;
isOutright: boolean;
title: string;
pendingBetCount: number;
pendingStake: string;
hasPreviewSettlementBatch: boolean;
requiresForce: boolean;
warnings: MatchArchiveWarning[];
};
export type LeagueBlockingMatch = {
id: string;
status: string;
isOutright: boolean;
title: string;
pendingCount: number;
};
export type LeagueArchivePreview = {
leagueId: string;
canArchive: boolean;
blockingMatches: LeagueBlockingMatch[];
totalPendingBets: number;
};
@Injectable()
export class CatalogArchiveService {
constructor(
private prisma: PrismaService,
private matches: MatchesService,
) {}
async getMatchArchivePreview(matchId: bigint): Promise<MatchArchivePreview> {
const match = await this.requireActiveMatch(matchId);
const [pending, hasPreviewBatch] = await Promise.all([
this.pendingBetSummary(matchId),
this.hasPreviewBatch(matchId),
]);
const warnings = this.buildMatchWarnings(match.status, pending.pendingBetCount, hasPreviewBatch);
const requiresForce = warnings.length > 0;
const title = await this.matchTitle(match);
return {
matchId: match.id.toString(),
matchStatus: match.status,
isOutright: match.isOutright,
title,
pendingBetCount: pending.pendingBetCount,
pendingStake: pending.pendingStake,
hasPreviewSettlementBatch: hasPreviewBatch,
requiresForce,
warnings,
};
}
async archiveMatch(matchId: bigint, opts: { force: boolean }) {
const match = await this.requireActiveMatch(matchId);
if (match.status === 'DRAFT') {
throw appBadRequest('MATCH_DELETE_DRAFT_ONLY');
}
if (match.status === 'SETTLED') {
throw appBadRequest('ARCHIVE_BLOCKED');
}
const preview = await this.getMatchArchivePreview(matchId);
if (preview.requiresForce && !opts.force) {
throw appConflict('ARCHIVE_BLOCKED', preview);
}
const now = new Date();
await this.prisma.$transaction(async (tx) => {
await tx.marketSelection.updateMany({
where: { market: { matchId } },
data: { status: 'CLOSED' },
});
await tx.market.updateMany({
where: { matchId },
data: { status: 'CLOSED' },
});
await tx.match.update({
where: { id: matchId },
data: {
deletedAt: now,
status:
match.status === 'CANCELLED' || match.status === 'VOID' ? match.status : 'CANCELLED',
},
});
});
return { matchId: matchId.toString(), archivedAt: now.toISOString() };
}
async getLeagueArchivePreview(leagueId: bigint): Promise<LeagueArchivePreview> {
const league = await this.prisma.league.findFirst({
where: { id: leagueId, deletedAt: null },
});
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
const matches = await this.prisma.match.findMany({
where: { leagueId, deletedAt: null },
include: { homeTeam: true, awayTeam: true },
orderBy: [{ isOutright: 'desc' }, { id: 'asc' }],
});
const matchIds = matches.map((m) => m.id);
const stats = await this.matches.betStatsForMatches(matchIds);
const previewBatches = matchIds.length
? await this.prisma.settlementBatch.findMany({
where: { matchId: { in: matchIds }, status: 'PREVIEW' },
select: { matchId: true },
})
: [];
const previewBatchMatchIds = new Set(previewBatches.map((b) => b.matchId?.toString()));
const blockingMatches: LeagueBlockingMatch[] = [];
let totalPendingBets = 0;
for (const match of matches) {
const mid = match.id.toString();
const stat = stats.get(mid) ?? { betCount: 0, totalStake: '0', pendingCount: 0 };
totalPendingBets += stat.pendingCount;
const hasPreview = previewBatchMatchIds.has(mid);
const blocks = this.isLeagueMatchBlocking(match.status, stat.betCount, stat.pendingCount, hasPreview);
if (blocks) {
blockingMatches.push({
id: mid,
status: match.status,
isOutright: match.isOutright,
title: await this.matchTitle(match),
pendingCount: stat.pendingCount,
});
}
}
if (totalPendingBets > 0 && !blockingMatches.length) {
// Pending bets exist but each match might be terminal — still block league archive
for (const match of matches) {
const mid = match.id.toString();
const stat = stats.get(mid)!;
if (stat.pendingCount > 0) {
blockingMatches.push({
id: mid,
status: match.status,
isOutright: match.isOutright,
title: await this.matchTitle(match),
pendingCount: stat.pendingCount,
});
}
}
}
const canArchive = blockingMatches.length === 0 && totalPendingBets === 0;
return {
leagueId: leagueId.toString(),
canArchive,
blockingMatches,
totalPendingBets,
};
}
async archiveLeague(leagueId: bigint) {
const league = await this.prisma.league.findFirst({
where: { id: leagueId, deletedAt: null },
});
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
const preview = await this.getLeagueArchivePreview(leagueId);
if (!preview.canArchive) {
throw appConflict('LEAGUE_ARCHIVE_NOT_READY', preview);
}
const now = new Date();
await this.prisma.$transaction(async (tx) => {
const matches = await tx.match.findMany({
where: { leagueId, deletedAt: null },
select: { id: true, status: true },
});
const matchIds = matches.map((m) => m.id);
if (matchIds.length) {
await tx.marketSelection.updateMany({
where: { market: { matchId: { in: matchIds } } },
data: { status: 'CLOSED' },
});
await tx.market.updateMany({
where: { matchId: { in: matchIds } },
data: { status: 'CLOSED' },
});
await tx.match.updateMany({
where: { id: { in: matchIds } },
data: { deletedAt: now },
});
}
await tx.league.update({
where: { id: leagueId },
data: { deletedAt: now, isActive: false },
});
});
return { leagueId: leagueId.toString(), archivedAt: now.toISOString() };
}
private async requireActiveMatch(matchId: bigint) {
const match = await this.prisma.match.findFirst({
where: { id: matchId, deletedAt: null },
include: { homeTeam: true, awayTeam: true, league: true },
});
if (!match) throw appNotFound('MATCH_NOT_FOUND');
return match;
}
private async pendingBetSummary(matchId: bigint) {
const bets = await this.prisma.bet.findMany({
where: { status: 'PENDING', selections: { some: { matchId } } },
select: { stake: true },
});
let pendingStake = new Decimal(0);
for (const bet of bets) {
pendingStake = pendingStake.add(bet.stake);
}
return {
pendingBetCount: bets.length,
pendingStake: pendingStake.toString(),
};
}
private async hasPreviewBatch(matchId: bigint) {
const batch = await this.prisma.settlementBatch.findFirst({
where: { matchId, status: 'PREVIEW' },
select: { id: true },
});
return batch != null;
}
private buildMatchWarnings(
status: string,
pendingBetCount: number,
hasPreviewBatch: boolean,
): MatchArchiveWarning[] {
const warnings: MatchArchiveWarning[] = [];
if (pendingBetCount > 0) warnings.push('PENDING_BETS');
if (!TERMINAL_MATCH_STATUSES.has(status) && status !== 'DRAFT') {
warnings.push('UNSETTLED_MATCH');
}
if (hasPreviewBatch) warnings.push('PREVIEW_BATCH');
return warnings;
}
private isLeagueMatchBlocking(
status: string,
betCount: number,
pendingCount: number,
hasPreviewBatch: boolean,
): boolean {
if (pendingCount > 0) return true;
if (hasPreviewBatch) return true;
if (TERMINAL_MATCH_STATUSES.has(status)) return false;
if (status === 'DRAFT' && betCount === 0) return false;
return true;
}
private async matchTitle(match: {
id: bigint;
isOutright: boolean;
matchName: string | null;
homeTeamId: bigint;
awayTeamId: bigint;
homeTeam?: { code: string } | null;
awayTeam?: { code: string } | null;
}) {
if (match.isOutright) {
const name = match.matchName?.trim();
if (name) return name;
return `Outright #${match.id}`;
}
const [home, away] = await Promise.all([
this.getTranslationExact('TEAM', match.homeTeamId, 'zh-CN'),
this.getTranslationExact('TEAM', match.awayTeamId, 'zh-CN'),
]);
if (home && away) return `${home} vs ${away}`;
return `${match.homeTeam?.code ?? '?'} vs ${match.awayTeam?.code ?? '?'}`;
}
private async getTranslationExact(entityType: string, entityId: bigint, locale: string) {
const row = await this.prisma.entityTranslation.findFirst({
where: { entityType, entityId, locale, fieldName: 'name' },
});
return row?.value ?? '';
}
}

View File

@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { MarketsModule } from '../odds/markets.module';
import { CatalogArchiveService } from './catalog-archive.service';
import { MatchesService } from './matches.service';
import { OutrightService } from './outright.service';
@Module({
imports: [MarketsModule],
providers: [MatchesService, OutrightService],
exports: [MatchesService, OutrightService],
providers: [MatchesService, OutrightService, CatalogArchiveService],
exports: [MatchesService, OutrightService, CatalogArchiveService],
})
export class MatchesModule {}

View File

@@ -0,0 +1,115 @@
import { MatchesService } from './matches.service';
describe('MatchesService publish/unpublish', () => {
const leagueId = BigInt(1);
const matchId = BigInt(10);
let prisma: {
league: { findFirst: jest.Mock; findUniqueOrThrow: jest.Mock; update: jest.Mock };
match: { findFirst: jest.Mock; update: jest.Mock };
entityTranslation: { findFirst: jest.Mock; upsert: jest.Mock };
settlementBatch: { deleteMany: jest.Mock };
};
let outright: { syncWithLeaguePublished: jest.Mock };
let service: MatchesService;
beforeEach(() => {
prisma = {
league: {
findFirst: jest.fn(),
findUniqueOrThrow: jest.fn(),
update: jest.fn(),
},
match: {
findFirst: jest.fn(),
update: jest.fn(),
},
entityTranslation: { findFirst: jest.fn().mockResolvedValue(null), upsert: jest.fn().mockResolvedValue({}) },
settlementBatch: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) },
};
outright = { syncWithLeaguePublished: jest.fn().mockResolvedValue(undefined) };
service = new MatchesService(prisma as never, outright as never);
});
describe('updatePlatformLeague unpublish', () => {
const baseLeague = { id: leagueId, code: 'EPL', isActive: true, logoUrl: null, displayOrder: 0 };
beforeEach(() => {
prisma.league.findFirst.mockResolvedValue(baseLeague);
prisma.league.update.mockResolvedValue({});
prisma.league.findUniqueOrThrow.mockResolvedValue({ ...baseLeague, isActive: false });
});
it('rejects unpublish when outright is settled', async () => {
prisma.match.findFirst.mockResolvedValue({ status: 'SETTLED' });
await expect(
service.updatePlatformLeague(leagueId, {
leagueEn: 'EPL',
leagueZh: '英超',
isActive: false,
}),
).rejects.toMatchObject({
response: expect.objectContaining({ code: 'LEAGUE_UNPUBLISH_SETTLED' }),
});
expect(prisma.league.update).not.toHaveBeenCalled();
});
it('allows unpublish when outright is not settled', async () => {
prisma.match.findFirst.mockResolvedValue({ status: 'PUBLISHED' });
const result = await service.updatePlatformLeague(leagueId, {
leagueEn: 'EPL',
leagueZh: '英超',
isActive: false,
});
expect(prisma.league.update).toHaveBeenCalledWith({
where: { id: leagueId },
data: { isActive: false },
});
expect(result.isPublished).toBe(false);
});
});
describe('unpublishMatch', () => {
const baseMatch = {
id: matchId,
status: 'PUBLISHED',
isOutright: false,
deletedAt: null,
};
beforeEach(() => {
prisma.match.findFirst.mockResolvedValue(baseMatch);
prisma.match.update.mockResolvedValue({ ...baseMatch, status: 'DRAFT' });
});
it('unpublishes published fixture to draft', async () => {
await service.unpublishMatch(matchId);
expect(prisma.match.update).toHaveBeenCalledWith({
where: { id: matchId },
data: { status: 'DRAFT', closeTime: null },
});
});
it('rejects unpublish when settled', async () => {
prisma.match.findFirst.mockResolvedValue({ ...baseMatch, status: 'SETTLED' });
await expect(service.unpublishMatch(matchId)).rejects.toMatchObject({
response: expect.objectContaining({ code: 'MATCH_UNPUBLISH_FORBIDDEN' }),
});
});
it('clears preview settlement batch when pending settlement', async () => {
prisma.match.findFirst.mockResolvedValue({ ...baseMatch, status: 'PENDING_SETTLEMENT' });
await service.unpublishMatch(matchId);
expect(prisma.settlementBatch.deleteMany).toHaveBeenCalledWith({
where: { matchId, status: 'PREVIEW' },
});
});
});
});

View File

@@ -23,6 +23,7 @@ import {
translationsFromZhiboNames,
} from './zhibo-match.mapper';
import { syncWc2026OutrightMarket } from './wc2026-outright.sync';
import { OutrightService } from './outright.service';
const OUTRIGHT_PLACEHOLDER_CODE = 'OUT';
@@ -45,7 +46,10 @@ export type ListPublishedOptions = {
@Injectable()
export class MatchesService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private outright: OutrightService,
) {}
async createLeague(code: string, translations: Record<string, string>) {
const league = await this.prisma.league.create({ data: { code } });
@@ -85,6 +89,7 @@ export class MatchesService {
awayTeamId: bigint;
startTime: Date;
isHot?: boolean;
correctScoreEnabled?: boolean;
displayOrder?: number;
createdBy?: bigint;
status?: string;
@@ -110,6 +115,7 @@ export class MatchesService {
awayTeamId: data.awayTeamId,
startTime: data.startTime,
isHot: data.isHot ?? false,
correctScoreEnabled: data.correctScoreEnabled ?? true,
displayOrder: data.displayOrder ?? 0,
createdBy: data.createdBy,
status,
@@ -307,7 +313,13 @@ export class MatchesService {
if (data.displayOrder != null) updates.displayOrder = data.displayOrder;
if (data.isActive !== undefined) {
if (league.isActive && data.isActive === false) {
throw appBadRequest('LEAGUE_UNPUBLISH_FORBIDDEN');
const outright = await this.prisma.match.findFirst({
where: { leagueId, isOutright: true, deletedAt: null },
select: { status: true },
});
if (outright?.status === 'SETTLED') {
throw appBadRequest('LEAGUE_UNPUBLISH_SETTLED');
}
}
updates.isActive = data.isActive;
}
@@ -315,6 +327,10 @@ export class MatchesService {
await this.prisma.league.update({ where: { id: leagueId }, data: updates });
}
if (data.isActive === true) {
await this.outright.syncWithLeaguePublished(leagueId);
}
const [en, zh, ms] = await Promise.all([
this.getTranslationExact('LEAGUE', leagueId, 'en-US'),
this.getTranslationExact('LEAGUE', leagueId, 'zh-CN'),
@@ -537,7 +553,13 @@ export class MatchesService {
async listAdminLeagueMatches(
leagueId: bigint,
opts: { status?: string; keyword?: string; locale?: string },
opts: {
status?: string;
keyword?: string;
locale?: string;
page?: number;
pageSize?: number;
},
) {
const where: Prisma.MatchWhereInput = {
leagueId,
@@ -553,15 +575,22 @@ export class MatchesService {
{ awayTeam: { code: { contains: kw, mode: 'insensitive' } } },
];
}
const items = await this.prisma.match.findMany({
where,
include: { homeTeam: true, awayTeam: true },
orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }],
});
const page = Math.max(1, opts.page ?? 1);
const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 20));
const [total, rows] = await Promise.all([
this.prisma.match.count({ where }),
this.prisma.match.findMany({
where,
include: { homeTeam: true, awayTeam: true },
orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
const locale = opts.locale ?? 'zh-CN';
const betStatsMap = await this.betStatsForMatches(items.map((m) => m.id));
return Promise.all(
items.map(async (m) => {
const betStatsMap = await this.betStatsForMatches(rows.map((m) => m.id));
const items = await Promise.all(
rows.map(async (m) => {
const [homeTeamName, awayTeamName] = await Promise.all([
this.getTranslation('TEAM', m.homeTeamId, locale),
this.getTranslation('TEAM', m.awayTeamId, locale),
@@ -588,6 +617,7 @@ export class MatchesService {
};
}),
);
return { items, total, page, pageSize };
}
/** 批量汇总多场关联注单(按 bet 去重计注单数) */
@@ -688,6 +718,7 @@ export class MatchesService {
awayTeamMs?: string;
startTime: Date;
isHot?: boolean;
correctScoreEnabled?: boolean;
displayOrder?: number;
matchName?: string;
stage?: string;
@@ -802,6 +833,7 @@ export class MatchesService {
awayTeamId: awayTeam.id,
startTime: data.startTime,
isHot: data.isHot ?? false,
correctScoreEnabled: data.correctScoreEnabled ?? true,
displayOrder: data.displayOrder ?? 0,
createdBy: data.createdBy,
status: 'DRAFT',
@@ -849,6 +881,7 @@ export class MatchesService {
status: match.status,
isOutright: match.isOutright,
isHot: match.isHot,
correctScoreEnabled: match.correctScoreEnabled,
displayOrder: match.displayOrder,
startTime: match.startTime.toISOString(),
leagueId: match.leagueId.toString(),
@@ -876,6 +909,7 @@ export class MatchesService {
htAway: scoreRow.htAwayScore ?? 0,
ftHome: scoreRow.ftHomeScore ?? 0,
ftAway: scoreRow.ftAwayScore ?? 0,
winnerTeamId: scoreRow.winnerTeamId?.toString() ?? null,
}
: null,
markets: markets.map((m) => ({
@@ -915,6 +949,7 @@ export class MatchesService {
groupName?: string;
homeTeamLogoUrl?: string;
awayTeamLogoUrl?: string;
correctScoreEnabled?: boolean;
updatedBy?: bigint;
},
) {
@@ -971,6 +1006,7 @@ export class MatchesService {
matchName,
stage: data.stage !== undefined ? data.stage.trim() || null : match.stage,
groupName: data.groupName !== undefined ? data.groupName.trim() || null : match.groupName,
correctScoreEnabled: data.correctScoreEnabled ?? match.correctScoreEnabled,
updatedBy: data.updatedBy,
},
});
@@ -1111,6 +1147,26 @@ export class MatchesService {
});
}
async unpublishMatch(matchId: bigint) {
const match = await this.requireAdminMatch(matchId);
if (match.isOutright) {
throw appBadRequest('OUTRIGHT_EDIT_VIA_MARKETS');
}
const allowed = ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT'];
if (!allowed.includes(match.status)) {
throw appBadRequest('MATCH_UNPUBLISH_FORBIDDEN');
}
if (match.status === 'PENDING_SETTLEMENT') {
await this.prisma.settlementBatch.deleteMany({
where: { matchId, status: 'PREVIEW' },
});
}
return this.prisma.match.update({
where: { id: matchId },
data: { status: 'DRAFT', closeTime: null },
});
}
async closeMatch(matchId: bigint) {
return this.prisma.match.update({
where: { id: matchId },
@@ -1120,7 +1176,24 @@ export class MatchesService {
async reopenMatch(matchId: bigint, startTime?: Date) {
const match = await this.requireAdminMatch(matchId);
if (match.isOutright) throw appBadRequest('OUTRIGHT_EDIT_VIA_MARKETS');
if (match.isOutright) {
const scoreRow = await this.prisma.matchScore.findUnique({ where: { matchId } });
if (scoreRow?.winnerTeamId) throw appBadRequest('MATCH_NOT_REOPENABLE');
if (match.status === 'SETTLED') throw appBadRequest('MATCH_NOT_REOPENABLE');
const reopenable =
match.status === 'CLOSED' || match.status === 'PENDING_SETTLEMENT';
if (!reopenable) throw appBadRequest('MATCH_NOT_REOPENABLE');
if (match.status === 'PENDING_SETTLEMENT') {
await this.prisma.settlementBatch.deleteMany({
where: { matchId, status: 'PREVIEW' },
});
}
return this.prisma.match.update({
where: { id: matchId },
data: { status: 'PUBLISHED', closeTime: null },
});
}
const scoreRow = await this.prisma.matchScore.findUnique({ where: { matchId } });
if (scoreRow) throw appBadRequest('MATCH_NOT_REOPENABLE');
@@ -1188,6 +1261,7 @@ export class MatchesService {
startTime: Date;
status?: string;
isHot?: boolean;
correctScoreEnabled?: boolean;
displayOrder?: number;
matchName?: string | null;
stage?: string | null;
@@ -1221,6 +1295,7 @@ export class MatchesService {
awayTeamLogoUrl: m.awayTeam?.logoUrl ?? null,
startTime: m.startTime.toISOString(),
isHot: m.isHot ?? false,
correctScoreEnabled: m.correctScoreEnabled ?? true,
displayOrder: m.displayOrder ?? 0,
matchName: m.matchName ?? null,
stage: m.stage ?? null,
@@ -1252,9 +1327,13 @@ export class MatchesService {
}),
};
if (m.markets && !options?.omitMarkets) {
const csEnabled = m.correctScoreEnabled ?? true;
const CORRECT_SCORE_TYPES = ['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE'];
return {
...base,
markets: m.markets.map((market) => ({
markets: m.markets
.filter((market) => csEnabled || !CORRECT_SCORE_TYPES.includes(market.marketType as string))
.map((market) => ({
id: (market.id as bigint).toString(),
marketType: market.marketType as string,
period: market.period as string,

View File

@@ -66,6 +66,7 @@ export class OutrightService {
m.status,
market,
openCount,
league.isActive,
);
const [titleZh, titleEn, titleMs] = await Promise.all([
this.getOutrightTitle(m.id, 'zh-CN'),
@@ -141,6 +142,7 @@ export class OutrightService {
: sel.selectionName;
return {
id: sel.id.toString(),
teamId: team?.id.toString() ?? null,
teamCode: sel.selectionCode,
rank: sel.sortOrder + 1 || index + 1,
teamZh: teamZh || sel.selectionName,
@@ -159,6 +161,11 @@ export class OutrightService {
fullMarket.selections.filter(
(s) => s.selectionCode !== PLACEHOLDER_TEAM_CODE,
),
league.isActive,
);
const unsettledFixtureCount = await this.countUnsettledLeagueFixtures(
match.leagueId,
);
const [titleZh, titleEn, titleMs] = await Promise.all([
@@ -178,6 +185,8 @@ export class OutrightService {
titleEn: titleEn || match.matchName || '',
titleMs,
status: match.status,
leagueIsPublished: league.isActive,
unsettledFixtureCount,
marketId: fullMarket.id.toString(),
marketStatus: fullMarket.status,
canImportCanonical: league.code === WC2026_LEAGUE_CODE,
@@ -191,15 +200,16 @@ export class OutrightService {
/** 按联赛获取或创建冠军盘,并从单场赛程同步参赛队伍 */
async getOrCreateAndSyncForLeague(leagueId: bigint) {
const league = await this.prisma.league.findUnique({
where: { id: leagueId },
});
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
let match = await this.prisma.match.findFirst({
where: { leagueId, isOutright: true, deletedAt: null },
orderBy: { id: 'asc' },
});
if (!match) {
const league = await this.prisma.league.findUnique({
where: { id: leagueId },
});
if (!league) throw appNotFound('LEAGUE_NOT_FOUND');
const [leagueZh, leagueEn, leagueMs] = await Promise.all([
this.getTranslation('LEAGUE', leagueId, 'zh-CN'),
this.getTranslation('LEAGUE', leagueId, 'en-US'),
@@ -210,12 +220,17 @@ export class OutrightService {
titleZh: leagueZh || league.code,
titleEn: leagueEn || league.code,
titleMs: leagueMs || undefined,
status: 'DRAFT',
status: league.isActive ? 'PUBLISHED' : 'DRAFT',
});
match = await this.prisma.match.findFirstOrThrow({
where: { leagueId, isOutright: true, deletedAt: null },
orderBy: { id: 'asc' },
});
} else {
await this.syncOutrightStatusWithLeague(match, league);
match = await this.prisma.match.findFirstOrThrow({
where: { id: match.id },
});
}
const sync = await this.syncSelectionsFromLeagueFixtures(match.id);
const data = await this.getForAdmin(match.id);
@@ -226,6 +241,47 @@ export class OutrightService {
};
}
/** 联赛发布后同步冠军盘状态(随联赛发布,无需单独发布) */
async syncWithLeaguePublished(leagueId: bigint) {
const league = await this.prisma.league.findUnique({
where: { id: leagueId },
});
if (!league?.isActive) return;
const existing = await this.prisma.match.findFirst({
where: { leagueId, isOutright: true, deletedAt: null },
orderBy: { id: 'asc' },
});
if (!existing) {
await this.getOrCreateAndSyncForLeague(leagueId);
return;
}
await this.syncOutrightStatusWithLeague(existing, league);
}
/** 联赛下尚未结算/取消的单场数量(不含冠军盘) */
async countUnsettledLeagueFixtures(leagueId: bigint): Promise<number> {
return this.prisma.match.count({
where: {
leagueId,
isOutright: false,
deletedAt: null,
status: { notIn: ['SETTLED', 'CANCELLED'] },
},
});
}
private async syncOutrightStatusWithLeague(
match: { id: bigint; status: string },
league: { isActive: boolean },
) {
if (!league.isActive || match.status !== 'DRAFT') return;
await this.prisma.match.update({
where: { id: match.id },
data: { status: 'PUBLISHED', publishTime: new Date() },
});
}
/** 若联赛已有冠军盘,则从单场同步球队(不自动创建冠军盘) */
async syncOutrightTeamsForLeagueIfExists(leagueId: bigint) {
const match = await this.prisma.match.findFirst({
@@ -635,6 +691,7 @@ export class OutrightService {
isOutright: true,
sportType: 'FOOTBALL',
deletedAt: null,
league: { isActive: true, deletedAt: null },
},
include: {
markets: {
@@ -804,18 +861,28 @@ export class OutrightService {
matchStatus: string,
market: { status: string } | null | undefined,
selections: Array<{ selectionCode: string; status: string }>,
leagueIsActive = true,
): { playerVisible: boolean; playerHiddenReason: string | null } {
const openCount = selections.filter(
(s) => s.status === 'OPEN' && s.selectionCode !== PLACEHOLDER_TEAM_CODE,
).length;
return this.playerVisibilityByCounts(matchStatus, market, openCount);
return this.playerVisibilityByCounts(
matchStatus,
market,
openCount,
leagueIsActive,
);
}
private playerVisibilityByCounts(
matchStatus: string,
market: { status: string } | null | undefined,
openSelectionCount: number,
leagueIsActive = true,
): { playerVisible: boolean; playerHiddenReason: string | null } {
if (!leagueIsActive) {
return { playerVisible: false, playerHiddenReason: 'LEAGUE_INACTIVE' };
}
if (matchStatus !== 'PUBLISHED') {
return { playerVisible: false, playerHiddenReason: 'NOT_PUBLISHED' };
}

View File

@@ -64,7 +64,7 @@ export async function syncWc2026OutrightMarket(
const forceCanonical = options.forceCanonical ?? false;
const league = await prisma.league.findUnique({ where: { code: WC2026_LEAGUE_CODE } });
if (!league) {
throw new Error(`League ${WC2026_LEAGUE_CODE} not found — run seedSportsDemo first`);
throw new Error(`League ${WC2026_LEAGUE_CODE} not found — run seedCatalog first`);
}
const placeholder = await upsertTeam(prisma, {
@@ -96,10 +96,14 @@ export async function syncWc2026OutrightMarket(
displayOrder: 0,
},
});
} else if (match.status === 'DRAFT') {
} else if (match.status === 'DRAFT' || match.status === 'SETTLED' || match.status === 'CLOSED') {
match = await prisma.match.update({
where: { id: match.id },
data: { status: 'PUBLISHED', publishTime: match.publishTime ?? new Date() },
data: {
status: 'PUBLISHED',
publishTime: match.publishTime ?? new Date(),
closeTime: null,
},
});
}

View File

@@ -5,6 +5,7 @@ import { resolveTranslationFallback } from '@thebet365/shared';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { WalletService } from '../ledger/wallet.service';
import { appBadRequest } from '../../shared/common/app-error';
import { deleteUploadFileByUrl } from '../../shared/uploads/delete-upload-file';
function generateOrderNo(): string {
const ts = Date.now().toString(36).toUpperCase();
@@ -12,6 +13,9 @@ function generateOrderNo(): string {
return `DEP${ts}${rand}`;
}
/** 已通过充值订单允许撤回的时间窗口 */
const DEPOSIT_REVOKE_WINDOW_MS = 5 * 60 * 1000;
@Injectable()
export class DepositService {
constructor(
@@ -21,6 +25,20 @@ export class DepositService {
// ============ Payment Methods (Admin CRUD) ============
/** isActive 与 showOnPlayer 合并为同一开关DB 两列保持同步以兼容旧数据。 */
private normalizePaymentMethodActive(data: {
isActive?: boolean;
showOnPlayer?: boolean;
}): { isActive?: boolean; showOnPlayer?: boolean } {
if (data.isActive !== undefined) {
return { isActive: data.isActive, showOnPlayer: data.isActive };
}
if (data.showOnPlayer !== undefined) {
return { isActive: data.showOnPlayer, showOnPlayer: data.showOnPlayer };
}
return {};
}
async createPaymentMethod(data: {
methodType: string;
bankName?: string;
@@ -38,6 +56,7 @@ export class DepositService {
bankName?: Record<string, string>;
};
}) {
const active = data.isActive ?? data.showOnPlayer ?? true;
const method = await this.prisma.paymentMethod.create({
data: {
methodType: data.methodType,
@@ -48,8 +67,8 @@ export class DepositService {
qrCodeUrl: data.qrCodeUrl,
displayName: data.displayName,
sortOrder: data.sortOrder ?? 0,
isActive: data.isActive ?? true,
showOnPlayer: data.showOnPlayer ?? true,
isActive: active,
showOnPlayer: active,
createdBy: data.createdBy,
},
});
@@ -78,9 +97,10 @@ export class DepositService {
},
) {
const { translations, ...rest } = data;
const activePatch = this.normalizePaymentMethodActive(rest);
const method = await this.prisma.paymentMethod.update({
where: { id },
data: rest,
data: { ...rest, ...activePatch },
});
if (translations) {
await this.upsertPaymentMethodTranslations(id, translations);
@@ -128,7 +148,6 @@ export class DepositService {
async listPlayerPaymentMethods(methodType?: string, locale?: string) {
const where: Prisma.PaymentMethodWhereInput = {
isActive: true,
showOnPlayer: true,
};
if (methodType) {
where.methodType = methodType;
@@ -438,4 +457,124 @@ export class DepositService {
return { success: true };
}
private async reverseApprovedDepositCredit(
order: {
playerId: bigint;
orderNo: string;
approvedAmount: Decimal | null;
amount: Decimal;
},
operatorId: bigint,
remark: string,
) {
const credit = order.approvedAmount ?? order.amount;
await this.wallet.withdraw(
order.playerId,
credit,
operatorId,
remark,
order.orderNo,
'PLAYER_DEPOSIT_REVERSAL',
);
}
/** 已拒绝恢复待审核已通过5 分钟内):作废期间待结算注单并扣回入账 */
async reopenDepositOrderForReview(orderId: bigint, operatorId: bigint) {
const order = await this.prisma.depositOrder.findUnique({ where: { id: orderId } });
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
if (order.status === 'PENDING') throw appBadRequest('ORDER_ALREADY_PENDING');
if (order.status === 'REJECTED') {
await this.prisma.depositOrder.update({
where: { id: orderId },
data: {
status: 'PENDING',
approvedAmount: null,
reviewerId: null,
reviewedAt: null,
rejectReason: null,
remark: null,
},
});
return { success: true };
}
if (order.status !== 'APPROVED') {
throw appBadRequest('ORDER_NOT_APPROVED');
}
if (!order.reviewedAt || Date.now() - order.reviewedAt.getTime() > DEPOSIT_REVOKE_WINDOW_MS) {
throw appBadRequest('DEPOSIT_REVOKE_WINDOW_EXPIRED');
}
const reviewedAt = order.reviewedAt;
return this.prisma.$transaction(async (tx) => {
const betsAfterReview = await tx.bet.findMany({
where: {
userId: order.playerId,
placedAt: { gte: reviewedAt },
status: { not: 'VOID' },
},
});
const settled = betsAfterReview.filter((b) => b.status !== 'PENDING');
if (settled.length > 0) {
throw appBadRequest('DEPOSIT_REVOKE_SETTLED_BETS');
}
for (const bet of betsAfterReview) {
await this.wallet.settleBet(
bet.userId,
bet.stake,
bet.stake,
bet.betNo,
'VOID',
tx,
);
await tx.bet.update({
where: { id: bet.id },
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
});
}
const credit = order.approvedAmount ?? order.amount;
await this.wallet.withdraw(
order.playerId,
credit,
operatorId,
`Revoke approved deposit ${order.orderNo}`,
order.orderNo,
'PLAYER_DEPOSIT_REVERSAL',
tx,
);
await tx.depositOrder.update({
where: { id: orderId },
data: {
status: 'PENDING',
approvedAmount: null,
reviewerId: null,
reviewedAt: null,
rejectReason: null,
remark: null,
},
});
return { success: true, voidedBets: betsAfterReview.length };
});
}
/** 删除充值订单记录及截图(不调整玩家钱包或注单,与撤销无关) */
async deleteDepositOrder(orderId: bigint, _operatorId: bigint) {
const order = await this.prisma.depositOrder.findUnique({ where: { id: orderId } });
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
const screenshotUrl = order.screenshotUrl;
await this.prisma.depositOrder.delete({ where: { id: orderId } });
await deleteUploadFileByUrl(screenshotUrl);
return { success: true };
}
}

View File

@@ -119,6 +119,10 @@ export class AuthService {
throw appForbidden('AGENT_ACCOUNT_SUSPENDED');
}
if (portal === 'player' && user.status === 'SUSPENDED') {
throw appForbidden('ACCOUNT_SUSPENDED');
}
if (portal === 'player' && user.parentId) {
const parentAgent = await this.prisma.user.findUnique({
where: { id: user.parentId },

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { appUnauthorized } from '../../shared/common/app-error';
import { appForbidden, appUnauthorized } from '../../shared/common/app-error';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
@@ -36,9 +36,20 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
},
},
});
if (!user || user.status !== 'ACTIVE') {
if (!user) {
throw appUnauthorized('INVALID_CREDENTIALS');
}
if (user.status === 'DISABLED') {
throw appForbidden('ACCOUNT_DISABLED');
}
if (user.status === 'SUSPENDED') {
throw appForbidden(
user.userType === 'AGENT' ? 'AGENT_ACCOUNT_SUSPENDED' : 'ACCOUNT_SUSPENDED',
);
}
if (user.status !== 'ACTIVE') {
throw appForbidden('ACCOUNT_DISABLED');
}
const permissions =
user.adminRole?.role?.permissions?.map((rp) => rp.permission.code) ?? [];
const roleCode = user.adminRole?.role?.code ?? payload.role;

View File

@@ -8,17 +8,34 @@ import { maskPhoneForLog, shortSessionId } from '../sms-log.util';
@Injectable()
export class ChuanglanClient {
private readonly logger = new Logger(ChuanglanClient.name);
private readonly cfg;
private readonly cfg: ReturnType<typeof loadChuanglanConfig>;
constructor(config: ConfigService) {
this.cfg = loadChuanglanConfig(config);
if (!this.cfg) {
this.logger.warn(
'Chuanglan SMS not configured (missing CHUANGLAN_ACCOUNT or CHUANGLAN_PASSWORD); SMS send will fail until credentials are set',
);
}
}
async sendSms(mobile: string, msg: string, uid?: string): Promise<SmsSendResult> {
const nonce = String(Date.now());
const maskedMobile = maskPhoneForLog(mobile);
const session = uid ? shortSessionId(uid) : 'n/a';
if (!this.cfg) {
this.logger.error(
`Chuanglan not configured mobile=${maskedMobile} session=${session}`,
);
return {
success: false,
code: 'NOT_CONFIGURED',
message: 'Missing CHUANGLAN_ACCOUNT or CHUANGLAN_PASSWORD',
};
}
const nonce = String(Date.now());
this.logger.log(`Chuanglan request mobile=${maskedMobile} session=${session}`);
const body: Record<string, string> = {

View File

@@ -15,11 +15,11 @@ export interface SmsBusinessConfig {
debugLogCode: boolean;
}
export function loadChuanglanConfig(config: ConfigService): ChuanglanConfig {
export function loadChuanglanConfig(config: ConfigService): ChuanglanConfig | null {
const account = config.get<string>('CHUANGLAN_ACCOUNT');
const password = config.get<string>('CHUANGLAN_PASSWORD');
if (!account || !password) {
throw new Error('Missing CHUANGLAN_ACCOUNT or CHUANGLAN_PASSWORD');
return null;
}
return {
account,

View File

@@ -482,4 +482,37 @@ export class UsersService {
});
}
}
async softDeletePlayer(playerId: bigint) {
const user = await this.prisma.user.findFirst({
where: { id: playerId, deletedAt: null },
});
if (!user) throw appNotFound('USER_NOT_FOUND');
if (user.userType !== 'PLAYER') {
throw appBadRequest('NOT_PLAYER');
}
// Block deletion when the player has any unresolved bets
const betCount = await this.prisma.bet.count({
where: {
userId: playerId,
status: 'PENDING',
},
});
if (betCount > 0) {
throw appBadRequest('PLAYER_HAS_PENDING_BETS');
}
// Block deletion when wallet still has balance
const wallet = await this.prisma.wallet.findUnique({ where: { userId: playerId } });
if (wallet) {
const available = new Decimal(wallet.availableBalance);
const frozen = new Decimal(wallet.frozenBalance);
if (available.gt(0) || frozen.gt(0)) {
throw appBadRequest('PLAYER_HAS_BALANCE');
}
}
return this.prisma.user.update({
where: { id: playerId },
data: { deletedAt: new Date(), status: 'SUSPENDED' },
});
}
}

View File

@@ -84,17 +84,18 @@ export class WalletService {
remark?: string,
referenceId?: string,
transactionType = 'MANUAL_WITHDRAW',
tx?: TxClient,
) {
const amt = new Decimal(amount);
if (amt.lte(0)) throw appBadRequest('AMOUNT_MUST_BE_POSITIVE');
return this.prisma.$transaction(async (tx) => {
const w = await this.lockWallet(tx, userId);
const run = async (client: TxClient) => {
const w = await this.lockWallet(client, userId);
const balanceBefore = new Decimal(w.available_balance);
if (balanceBefore.lt(amt)) throw appBadRequest('INSUFFICIENT_BALANCE');
const balanceAfter = balanceBefore.sub(amt);
await tx.wallet.update({
await client.wallet.update({
where: { id: w.id },
data: {
availableBalance: balanceAfter,
@@ -102,7 +103,7 @@ export class WalletService {
},
});
await tx.walletTransaction.create({
await client.walletTransaction.create({
data: {
transactionId: generateTransactionId(),
userId,
@@ -121,7 +122,10 @@ export class WalletService {
});
return { balanceAfter };
});
};
if (tx) return run(tx);
return this.prisma.$transaction(run);
}
async freezeForBet(userId: bigint, stake: Decimal | number, betId: string) {

View File

@@ -229,6 +229,7 @@ export class ContentService {
async listActive(contentType: string, locale: string) {
const now = new Date();
const type = this.assertContentType(contentType);
const items = await this.prisma.content.findMany({
where: {
contentType,
@@ -237,7 +238,10 @@ export class ContentService {
AND: [{ OR: [{ endTime: null }, { endTime: { gte: now } }] }],
},
include: { translations: true },
orderBy: { sortOrder: 'asc' },
orderBy:
type === 'BANNER'
? [{ createdAt: 'desc' }, { id: 'desc' }]
: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
return items
@@ -277,7 +281,7 @@ export class ContentService {
this.prisma.content.findMany({
where,
include: { translations: true },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),

View File

@@ -0,0 +1,242 @@
import { SettlementService } from './settlement.service';
import { Decimal } from '@prisma/client/runtime/library';
describe('SettlementService outright winner flow', () => {
const matchId = BigInt(100);
const operatorId = BigInt(1);
const winnerTeamId = BigInt(10);
const batchId = BigInt(500);
const winningBetId = BigInt(1001);
const losingBetId = BigInt(1002);
const winningSelId = BigInt(201);
const losingSelId = BigInt(202);
const outrightMatch = {
id: matchId,
isOutright: true,
status: 'CLOSED',
deletedAt: null,
};
const winnerTeam = { id: winnerTeamId, code: 'BRA' };
const winningBet = {
id: winningBetId,
betNo: 'BET-WIN',
betType: 'SINGLE',
status: 'PENDING',
stake: new Decimal(100),
agentId: null,
userId: BigInt(50),
user: { id: BigInt(50) },
selections: [
{
id: BigInt(301),
matchId,
marketType: 'OUTRIGHT_WINNER',
selectionId: winningSelId,
selectionNameSnapshot: '巴西',
handicapLine: null,
totalLine: null,
odds: new Decimal(3),
resultStatus: null,
sortOrder: 0,
},
],
};
const losingBet = {
id: losingBetId,
betNo: 'BET-LOSE',
betType: 'SINGLE',
status: 'PENDING',
stake: new Decimal(50),
agentId: null,
userId: BigInt(51),
user: { id: BigInt(51) },
selections: [
{
id: BigInt(302),
matchId,
marketType: 'OUTRIGHT_WINNER',
selectionId: losingSelId,
selectionNameSnapshot: '阿根廷',
handicapLine: null,
totalLine: null,
odds: new Decimal(5),
resultStatus: null,
sortOrder: 0,
},
],
};
let matchScoreUpsert: jest.Mock;
let matchFindFirst: jest.Mock;
let matchUpdate: jest.Mock;
let teamFindUnique: jest.Mock;
let marketSelectionFindFirst: jest.Mock;
let marketSelectionFindMany: jest.Mock;
let matchScoreFindUnique: jest.Mock;
let settlementBatchCreate: jest.Mock;
let settlementBatchFindUnique: jest.Mock;
let betFindMany: jest.Mock;
let transaction: jest.Mock;
let wallet: { settleBet: jest.Mock };
let agents: Record<string, jest.Mock>;
let service: SettlementService;
beforeEach(() => {
matchScoreUpsert = jest.fn().mockResolvedValue({});
matchFindFirst = jest.fn().mockResolvedValue(outrightMatch);
matchUpdate = jest.fn().mockResolvedValue({});
teamFindUnique = jest.fn().mockResolvedValue(winnerTeam);
marketSelectionFindFirst = jest
.fn()
.mockResolvedValue({ id: winningSelId, selectionCode: 'BRA' });
marketSelectionFindMany = jest.fn().mockResolvedValue([
{ id: winningSelId, selectionCode: 'BRA' },
{ id: losingSelId, selectionCode: 'ARG' },
]);
matchScoreFindUnique = jest.fn();
settlementBatchCreate = jest.fn().mockResolvedValue({
id: batchId,
matchId,
htHomeScore: 0,
htAwayScore: 0,
ftHomeScore: 0,
ftAwayScore: 0,
status: 'PREVIEW',
});
settlementBatchFindUnique = jest.fn();
betFindMany = jest.fn();
transaction = jest.fn(async (fn: (client: unknown) => Promise<void>) =>
fn({
matchScore: { upsert: matchScoreUpsert },
bet: { update: jest.fn().mockResolvedValue({}) },
betSelection: { update: jest.fn().mockResolvedValue({}) },
settlementItem: { create: jest.fn().mockResolvedValue({}) },
settlementBatch: { update: jest.fn().mockResolvedValue({}) },
match: { update: jest.fn().mockResolvedValue({}) },
}),
);
const prisma = {
match: { findFirst: matchFindFirst, update: matchUpdate },
team: { findUnique: teamFindUnique },
marketSelection: {
findFirst: marketSelectionFindFirst,
findMany: marketSelectionFindMany,
},
matchScore: {
findUnique: matchScoreFindUnique,
upsert: matchScoreUpsert,
},
settlementBatch: {
create: settlementBatchCreate,
findUnique: settlementBatchFindUnique,
update: jest.fn().mockResolvedValue({}),
},
bet: { findMany: betFindMany },
$transaction: transaction,
};
wallet = { settleBet: jest.fn().mockResolvedValue(undefined) };
agents = { recalculateUsedCredit: jest.fn().mockResolvedValue(undefined) };
service = new SettlementService(prisma as never, wallet as never, agents as never);
});
it('previewSettlement persists winnerTeamId and previews WIN/LOSE', async () => {
betFindMany.mockResolvedValue([winningBet, losingBet]);
const preview = await service.previewSettlement(matchId, operatorId, {
winnerTeamId,
});
expect(matchScoreUpsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { matchId },
create: expect.objectContaining({ winnerTeamId }),
update: expect.objectContaining({ winnerTeamId }),
}),
);
expect(preview.winnerTeamCode).toBe('BRA');
expect(preview.items.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ betNo: 'BET-WIN', result: 'WIN', payout: '300' }),
expect.objectContaining({ betNo: 'BET-LOSE', result: 'LOSE', payout: '0' }),
]),
);
});
it('confirmSettlement settles outright bets as WON/LOST using stored winnerTeamId', async () => {
const txBetUpdate = jest.fn().mockResolvedValue({});
transaction.mockImplementation(async (fn: (client: unknown) => Promise<void>) => {
await fn({
matchScore: { upsert: matchScoreUpsert },
bet: { update: txBetUpdate },
betSelection: { update: jest.fn().mockResolvedValue({}) },
settlementItem: { create: jest.fn().mockResolvedValue({}) },
settlementBatch: { update: jest.fn().mockResolvedValue({}) },
match: { update: jest.fn().mockResolvedValue({}) },
});
});
settlementBatchFindUnique.mockResolvedValue({
id: batchId,
matchId,
status: 'PREVIEW',
htHomeScore: 0,
htAwayScore: 0,
ftHomeScore: 0,
ftAwayScore: 0,
match: { ...outrightMatch, status: 'PENDING_SETTLEMENT' },
});
matchScoreFindUnique.mockResolvedValue({
matchId,
htHomeScore: 0,
htAwayScore: 0,
ftHomeScore: 0,
ftAwayScore: 0,
winnerTeamId,
});
betFindMany.mockResolvedValue([winningBet, losingBet]);
const result = await service.confirmSettlement(batchId, operatorId);
expect(matchScoreUpsert).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({ winnerTeamId }),
}),
);
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.objectContaining({
where: { id: winningBetId },
data: expect.objectContaining({ status: 'WON' }),
}),
);
expect(txBetUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: losingBetId },
data: expect.objectContaining({ status: 'LOST' }),
}),
);
expect(result).toEqual({ success: true, batchId: batchId.toString() });
});
});

View File

@@ -46,6 +46,24 @@ export class SettlementService {
return team?.code ?? null;
}
private async assertOutrightLeagueFixturesSettled(match: {
leagueId: bigint;
isOutright: boolean;
}) {
if (!match.isOutright) return;
const unsettled = await this.prisma.match.count({
where: {
leagueId: match.leagueId,
isOutright: false,
deletedAt: null,
status: { notIn: ['SETTLED', 'CANCELLED'] },
},
});
if (unsettled > 0) {
throw appBadRequest('OUTRIGHT_LEAGUE_FIXTURES_UNSETTLED');
}
}
private buildSettleInput(
sel: BetSelectionLeg,
selectionCode: string,
@@ -107,6 +125,10 @@ export class SettlementService {
if (!match) throw appNotFound('MATCH_NOT_FOUND');
this.assertMatchClosedForSettlement(match.status);
if (match.isOutright) {
await this.assertOutrightLeagueFixturesSettled(match);
}
if (match.isOutright) {
if (!winnerTeamId) {
throw appBadRequest('SETTLEMENT_WINNER_REQUIRED');
@@ -172,6 +194,10 @@ export class SettlementService {
if (!match) throw appNotFound('MATCH_NOT_FOUND');
this.assertMatchClosedForSettlement(match.status);
if (match.isOutright) {
await this.assertOutrightLeagueFixturesSettled(match);
}
const scoreSource = await this.resolvePreviewScoreSource(matchId, match.isOutright, opts);
const computation = await this.computePreviewComputation(matchId, scoreSource);
const batch = await this.prisma.settlementBatch.create({
@@ -190,6 +216,10 @@ export class SettlementService {
},
});
if (match.isOutright && scoreSource.winnerTeamId) {
await this.upsertMatchScoreRecord(matchId, scoreSource, operatorId);
}
if (match.status !== 'PENDING_SETTLEMENT' && match.status !== 'SETTLED') {
await this.prisma.match.update({
where: { id: matchId },
@@ -306,6 +336,41 @@ export class SettlementService {
};
}
private async upsertMatchScoreRecord(
matchId: bigint,
scoreSource: {
htHome: number;
htAway: number;
ftHome: number;
ftAway: number;
winnerTeamId?: bigint | null;
},
operatorId: bigint,
tx?: Parameters<Parameters<PrismaService['$transaction']>[0]>[0],
) {
const client = tx ?? this.prisma;
await client.matchScore.upsert({
where: { matchId },
create: {
matchId,
htHomeScore: scoreSource.htHome,
htAwayScore: scoreSource.htAway,
ftHomeScore: scoreSource.ftHome,
ftAwayScore: scoreSource.ftAway,
winnerTeamId: scoreSource.winnerTeamId ?? null,
recordedBy: operatorId,
},
update: {
htHomeScore: scoreSource.htHome,
htAwayScore: scoreSource.htAway,
ftHomeScore: scoreSource.ftHome,
ftAwayScore: scoreSource.ftAway,
winnerTeamId: scoreSource.winnerTeamId ?? null,
recordedBy: operatorId,
},
});
}
private async resolvePreviewScoreSource(
matchId: bigint,
isOutright: boolean,
@@ -601,6 +666,10 @@ export class SettlementService {
throw appBadRequest('MATCH_NOT_SETTLEABLE');
}
if (batch.match.isOutright) {
await this.assertOutrightLeagueFixturesSettled(batch.match);
}
const scoreInput: ScoreInput = {
htHome: batch.htHomeScore ?? 0,
htAway: batch.htAwayScore ?? 0,
@@ -624,25 +693,18 @@ export class SettlementService {
const agentIds = new Set<bigint>();
await this.prisma.$transaction(async (tx) => {
await tx.matchScore.upsert({
where: { matchId: batch.matchId },
create: {
matchId: batch.matchId,
htHomeScore: scoreInput.htHome,
htAwayScore: scoreInput.htAway,
ftHomeScore: scoreInput.ftHome,
ftAwayScore: scoreInput.ftAway,
await this.upsertMatchScoreRecord(
batch.matchId,
{
htHome: scoreInput.htHome,
htAway: scoreInput.htAway,
ftHome: scoreInput.ftHome,
ftAway: scoreInput.ftAway,
winnerTeamId: existingScore?.winnerTeamId ?? null,
recordedBy: operatorId,
},
update: {
htHomeScore: scoreInput.htHome,
htAwayScore: scoreInput.htAway,
ftHomeScore: scoreInput.ftHome,
ftAwayScore: scoreInput.ftAway,
recordedBy: operatorId,
},
});
operatorId,
tx,
);
for (const bet of pendingBets) {
if (bet.betType === 'SINGLE' && bet.selections.length === 1) {
@@ -833,56 +895,58 @@ export class SettlementService {
return { success: true, batchId: batchId.toString() };
}
async getMatchBetStats(
matchId: bigint,
opts?: { page?: number; pageSize?: number },
) {
private async assertMatchReadyForBetStats(matchId: bigint) {
const match = await this.prisma.match.findFirst({
where: { id: matchId, deletedAt: null },
});
if (!match) throw appNotFound('MATCH_NOT_FOUND');
this.assertMatchClosedForSettlement(match.status);
return match;
}
const legs = await this.prisma.betSelection.findMany({
where: { matchId },
include: {
bet: {
select: {
id: true,
betNo: true,
betType: true,
stake: true,
status: true,
settlementStatus: true,
potentialReturn: true,
actualReturn: true,
placedAt: true,
user: { select: { username: true } },
},
async getMatchBetStatsSummary(matchId: bigint) {
await this.assertMatchReadyForBetStats(matchId);
const betWhere = { selections: { some: { matchId } } };
const [
legCount,
totalBets,
singleBets,
parlayBets,
stakeAgg,
statusGroups,
legsForSelection,
] = await Promise.all([
this.prisma.betSelection.count({ where: { matchId } }),
this.prisma.bet.count({ where: betWhere }),
this.prisma.bet.count({ where: { ...betWhere, betType: 'SINGLE' } }),
this.prisma.bet.count({ where: { ...betWhere, betType: 'PARLAY' } }),
this.prisma.bet.aggregate({
where: betWhere,
_sum: { stake: true, potentialReturn: true },
}),
this.prisma.bet.groupBy({
by: ['status'],
where: betWhere,
_count: { _all: true },
}),
this.prisma.betSelection.findMany({
where: { matchId },
select: {
marketId: true,
selectionId: true,
marketType: true,
period: true,
selectionNameSnapshot: true,
bet: { select: { betType: true, stake: true } },
},
},
orderBy: [{ marketType: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }],
});
orderBy: [{ marketType: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }],
}),
]);
const betById = new Map<string, (typeof legs)[0]['bet']>();
for (const leg of legs) {
betById.set(leg.betId.toString(), leg.bet);
}
let totalStake = new Decimal(0);
let totalPotential = new Decimal(0);
let singleBets = 0;
let parlayBets = 0;
const statusCounts: Record<string, number> = {};
for (const bet of betById.values()) {
totalStake = totalStake.add(bet.stake);
if (bet.potentialReturn) {
totalPotential = totalPotential.add(bet.potentialReturn);
}
if (bet.betType === 'SINGLE') singleBets += 1;
else if (bet.betType === 'PARLAY') parlayBets += 1;
statusCounts[bet.status] = (statusCounts[bet.status] ?? 0) + 1;
for (const row of statusGroups) {
statusCounts[row.status] = row._count._all;
}
type SelAgg = {
@@ -896,7 +960,7 @@ export class SettlementService {
};
const selMap = new Map<string, SelAgg>();
for (const leg of legs) {
for (const leg of legsForSelection) {
const key = `${leg.marketId.toString()}:${leg.selectionId.toString()}`;
let row = selMap.get(key);
if (!row) {
@@ -935,67 +999,84 @@ export class SettlementService {
return a.selectionName.localeCompare(b.selectionName);
});
const betsById = new Map<
string,
{
bet: (typeof legs)[0]['bet'];
matchLegs: (typeof legs);
}
>();
for (const leg of legs) {
const key = leg.betId.toString();
const row = betsById.get(key) ?? { bet: leg.bet, matchLegs: [] };
row.matchLegs.push(leg);
betsById.set(key, row);
}
return {
summary: {
totalBets,
singleBets,
parlayBets,
totalStake: (stakeAgg._sum.stake ?? new Decimal(0)).toString(),
totalPotentialReturn: (stakeAgg._sum.potentialReturn ?? new Decimal(0)).toString(),
statusCounts,
legCount,
},
bySelection,
};
}
const allBets = Array.from(betsById.values())
.map(({ bet, matchLegs }) => ({
id: bet.id.toString(),
betNo: bet.betNo,
username: matchLegs[0].bet.user.username,
betType: bet.betType,
status: bet.status,
settlementStatus: bet.settlementStatus,
stake: bet.stake.toString(),
potentialReturn: bet.potentialReturn?.toString() ?? null,
actualReturn: bet.actualReturn.toString(),
placedAt: bet.placedAt.toISOString(),
legCountOnMatch: matchLegs.length,
selections: matchLegs.map((leg) => ({
marketType: leg.marketType,
period: leg.period,
selectionName: leg.selectionNameSnapshot,
odds: leg.odds.toString(),
})),
}))
.sort(
(a, b) =>
new Date(b.placedAt).getTime() - new Date(a.placedAt).getTime(),
);
async getMatchBetStatsBets(
matchId: bigint,
opts?: { page?: number; pageSize?: number },
) {
await this.assertMatchReadyForBetStats(matchId);
const betWhere = { selections: { some: { matchId } } };
const totalBets = await this.prisma.bet.count({ where: betWhere });
const page = Math.max(1, opts?.page ?? 1);
const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 10));
const total = allBets.length;
const start = (page - 1) * pageSize;
const betRows = await this.prisma.bet.findMany({
where: betWhere,
orderBy: { placedAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
user: { select: { username: true } },
selections: {
where: { matchId },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
},
},
});
const items = betRows.map((bet) => ({
id: bet.id.toString(),
betNo: bet.betNo,
username: bet.user.username,
betType: bet.betType,
status: bet.status,
settlementStatus: bet.settlementStatus,
stake: bet.stake.toString(),
potentialReturn: bet.potentialReturn?.toString() ?? null,
actualReturn: bet.actualReturn.toString(),
placedAt: bet.placedAt.toISOString(),
legCountOnMatch: bet.selections.length,
selections: bet.selections.map((leg) => ({
marketType: leg.marketType,
period: leg.period,
selectionName: leg.selectionNameSnapshot,
odds: leg.odds.toString(),
})),
}));
return {
summary: {
totalBets: betById.size,
singleBets,
parlayBets,
totalStake: totalStake.toString(),
totalPotentialReturn: totalPotential.toString(),
statusCounts,
legCount: legs.length,
},
bySelection,
bets: {
items: allBets.slice(start, start + pageSize),
total,
page,
pageSize,
},
items,
total: totalBets,
page,
pageSize,
};
}
async getMatchBetStats(
matchId: bigint,
opts?: { page?: number; pageSize?: number },
) {
const [summaryPart, bets] = await Promise.all([
this.getMatchBetStatsSummary(matchId),
this.getMatchBetStatsBets(matchId, opts),
]);
return {
...summaryPart,
bets,
};
}
@@ -1144,6 +1225,20 @@ export class SettlementService {
},
});
if (match.isOutright && winnerTeamId) {
await this.upsertMatchScoreRecord(
matchId,
{
htHome: scoreInput.htHome,
htAway: scoreInput.htAway,
ftHome: scoreInput.ftHome,
ftAway: scoreInput.ftAway,
winnerTeamId,
},
operatorId,
);
}
return {
batch,
score: scoreInput,
@@ -1170,11 +1265,10 @@ export class SettlementService {
ftHome: batch.ftHomeScore ?? 0,
ftAway: batch.ftAwayScore ?? 0,
};
const winnerTeamCode = await this.resolveWinnerTeamCode(
(
await this.prisma.matchScore.findUnique({ where: { matchId: batch.matchId } })
)?.winnerTeamId,
);
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: {
@@ -1188,24 +1282,18 @@ export class SettlementService {
const agentIds = new Set<bigint>();
await this.prisma.$transaction(async (tx) => {
await tx.matchScore.upsert({
where: { matchId: batch.matchId },
create: {
matchId: batch.matchId,
htHomeScore: scoreInput.htHome,
htAwayScore: scoreInput.htAway,
ftHomeScore: scoreInput.ftHome,
ftAwayScore: scoreInput.ftAway,
recordedBy: operatorId,
await this.upsertMatchScoreRecord(
batch.matchId,
{
htHome: scoreInput.htHome,
htAway: scoreInput.htAway,
ftHome: scoreInput.ftHome,
ftAway: scoreInput.ftAway,
winnerTeamId: existingScore?.winnerTeamId ?? null,
},
update: {
htHomeScore: scoreInput.htHome,
htAwayScore: scoreInput.htAway,
ftHomeScore: scoreInput.ftHome,
ftAwayScore: scoreInput.ftAway,
recordedBy: operatorId,
},
});
operatorId,
tx,
);
for (const bet of settledBets) {
const oldPayout = new Decimal(bet.actualReturn);