feat(admin+api): 代理停用默认、结算加固与冒烟配置探针
- 代理层级默认授信比例与停用冻结/禁登全局默认 - 结算预览去重、比分校验、串关当场判负与市场类型校验 - 站内信 Banner/公告自动通知开关;开发环境动态 API 端口 - 扩充 RBAC/结算/认证/返现单元测试与 agent skills
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "node ../../scripts/ensure-port-free.mjs 3000 && nest start --watch",
|
||||
"dev": "node ../../scripts/ensure-port-free.mjs && nest start --watch",
|
||||
"start": "node dist/main",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
|
||||
@@ -85,6 +85,28 @@ describe('Admin RBAC (SEC010–SEC012)', () => {
|
||||
expect(guardAllows(user, 'settlement.resettle')).toBe(false);
|
||||
});
|
||||
|
||||
it('FINANCE_ADMIN has deposit and cashback permissions but not reset database', () => {
|
||||
const user = userWithRole('FINANCE_ADMIN');
|
||||
expect(guardAllows(user, 'deposit.manage')).toBe(true);
|
||||
expect(guardAllows(user, 'deposit.review')).toBe(true);
|
||||
expect(guardAllows(user, 'cashback.confirm')).toBe(true);
|
||||
expect(guardAllows(user, 'settings.reset_database')).toBe(false);
|
||||
});
|
||||
|
||||
it('MATCH_ADMIN cannot manage deposits or confirm cashback', () => {
|
||||
const user = userWithRole('MATCH_ADMIN');
|
||||
expect(guardAllows(user, 'deposit.manage')).toBe(false);
|
||||
expect(guardAllows(user, 'deposit.review')).toBe(false);
|
||||
expect(guardAllows(user, 'cashback.confirm')).toBe(false);
|
||||
});
|
||||
|
||||
it('SUPPORT cannot confirm cashback or manage deposits', () => {
|
||||
const user = userWithRole('SUPPORT');
|
||||
expect(guardAllows(user, 'cashback.confirm')).toBe(false);
|
||||
expect(guardAllows(user, 'deposit.manage')).toBe(false);
|
||||
expect(guardAllows(user, 'deposit.review')).toBe(false);
|
||||
});
|
||||
|
||||
it('SUPER_ADMIN bypasses permission checks', () => {
|
||||
const user = userWithRole('SUPER_ADMIN');
|
||||
expect(guardAllows(user, 'wallet.deposit')).toBe(true);
|
||||
|
||||
255
apps/api/src/applications/admin/admin.controller.spec.ts
Normal file
255
apps/api/src/applications/admin/admin.controller.spec.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AgentsService } from '../../domains/agent/agents.service';
|
||||
import { createPrismaMock } from '../../testing/prisma-mock';
|
||||
|
||||
function stubDeps() {
|
||||
return {
|
||||
users: {},
|
||||
agents: { updateAgentAdmin: jest.fn() } as AgentsService | { updateAgentAdmin: jest.Mock },
|
||||
wallet: {},
|
||||
matches: {},
|
||||
catalogArchive: {},
|
||||
outright: {},
|
||||
markets: {},
|
||||
settlement: {},
|
||||
cashback: {},
|
||||
content: { create: jest.fn() },
|
||||
i18n: {},
|
||||
audit: { log: jest.fn().mockResolvedValue(undefined) },
|
||||
bets: {},
|
||||
prisma: {},
|
||||
dashboardService: {},
|
||||
systemConfig: { getInboxNotifySettings: jest.fn() },
|
||||
bettingLimits: {},
|
||||
databaseReset: {},
|
||||
smokeTests: {},
|
||||
depositService: {},
|
||||
playerMessages: {
|
||||
broadcastBannerPromotion: jest.fn(),
|
||||
broadcastAnnouncementPromotion: jest.fn(),
|
||||
},
|
||||
staff: {},
|
||||
presence: {},
|
||||
depositCleanup: {},
|
||||
};
|
||||
}
|
||||
|
||||
function buildController(deps: ReturnType<typeof stubDeps>) {
|
||||
return new AdminController(
|
||||
deps.users as never,
|
||||
deps.agents as never,
|
||||
deps.wallet as never,
|
||||
deps.matches as never,
|
||||
deps.catalogArchive as never,
|
||||
deps.outright as never,
|
||||
deps.markets as never,
|
||||
deps.settlement as never,
|
||||
deps.cashback as never,
|
||||
deps.content as never,
|
||||
deps.i18n as never,
|
||||
deps.audit as never,
|
||||
deps.bets as never,
|
||||
deps.prisma as never,
|
||||
deps.dashboardService as never,
|
||||
deps.systemConfig as never,
|
||||
deps.bettingLimits as never,
|
||||
deps.databaseReset as never,
|
||||
deps.smokeTests as never,
|
||||
deps.depositService as never,
|
||||
deps.playerMessages as never,
|
||||
deps.staff as never,
|
||||
deps.presence as never,
|
||||
deps.depositCleanup as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe('AdminController createContent inbox notify gating', () => {
|
||||
const deps = stubDeps();
|
||||
const controller = buildController(deps);
|
||||
|
||||
const baseDto = {
|
||||
contentType: 'BANNER' as const,
|
||||
status: 'ACTIVE' as const,
|
||||
notifyInbox: true,
|
||||
translations: [{ locale: 'zh-CN', title: 'Promo', body: 'Body' }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
deps.content.create.mockResolvedValue({ id: '88' });
|
||||
deps.playerMessages.broadcastBannerPromotion.mockResolvedValue(3);
|
||||
deps.playerMessages.broadcastAnnouncementPromotion.mockResolvedValue(5);
|
||||
});
|
||||
|
||||
it('does not broadcast banner when inbox.notify.banner is false', async () => {
|
||||
deps.systemConfig.getInboxNotifySettings.mockResolvedValue({
|
||||
inboxEnabled: true,
|
||||
deposit: true,
|
||||
banner: false,
|
||||
announcement: true,
|
||||
});
|
||||
|
||||
const res = await controller.createContent(baseDto);
|
||||
|
||||
expect(deps.playerMessages.broadcastBannerPromotion).not.toHaveBeenCalled();
|
||||
expect((res.data as { notifiedCount?: number }).notifiedCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it('broadcasts banner when inbox.notify.banner is true', async () => {
|
||||
deps.systemConfig.getInboxNotifySettings.mockResolvedValue({
|
||||
inboxEnabled: true,
|
||||
deposit: true,
|
||||
banner: true,
|
||||
announcement: false,
|
||||
});
|
||||
|
||||
const res = await controller.createContent(baseDto);
|
||||
|
||||
expect(deps.playerMessages.broadcastBannerPromotion).toHaveBeenCalledWith({
|
||||
contentId: 88n,
|
||||
translations: [{ locale: 'zh-CN', title: 'Promo', body: 'Body' }],
|
||||
});
|
||||
expect((res.data as { notifiedCount?: number }).notifiedCount).toBe(3);
|
||||
});
|
||||
|
||||
it('does not broadcast announcement when inbox.notify.announcement is false', async () => {
|
||||
deps.systemConfig.getInboxNotifySettings.mockResolvedValue({
|
||||
inboxEnabled: true,
|
||||
deposit: true,
|
||||
banner: true,
|
||||
announcement: false,
|
||||
});
|
||||
|
||||
await controller.createContent({
|
||||
...baseDto,
|
||||
contentType: 'NOTICE',
|
||||
});
|
||||
|
||||
expect(deps.playerMessages.broadcastAnnouncementPromotion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips notify when inbox feature is disabled even if notifyInbox is checked', async () => {
|
||||
deps.systemConfig.getInboxNotifySettings.mockResolvedValue({
|
||||
inboxEnabled: false,
|
||||
deposit: true,
|
||||
banner: true,
|
||||
announcement: true,
|
||||
});
|
||||
|
||||
await controller.createContent(baseDto);
|
||||
|
||||
expect(deps.playerMessages.broadcastBannerPromotion).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController updateAgent suspend wiring', () => {
|
||||
const agentId = 10n;
|
||||
const operatorId = 1n;
|
||||
|
||||
const tx = {
|
||||
user: {
|
||||
create: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
userAuth: { create: jest.fn() },
|
||||
userPreference: { create: jest.fn() },
|
||||
userInvite: { create: jest.fn(), findUnique: jest.fn() },
|
||||
agentProfile: { create: jest.fn(), findUnique: jest.fn() },
|
||||
agentClosure: { create: jest.fn(), findMany: jest.fn() },
|
||||
};
|
||||
|
||||
const prismaBase = {
|
||||
...tx,
|
||||
agentProfile: { ...tx.agentProfile, update: jest.fn() },
|
||||
user: { ...tx.user, updateMany: jest.fn() },
|
||||
};
|
||||
const prisma = createPrismaMock(prismaBase) as typeof prismaBase & { $transaction: jest.Mock };
|
||||
|
||||
const systemConfig = {
|
||||
getAgentHierarchySettings: jest.fn(),
|
||||
getAgentSuspendSettings: jest.fn(),
|
||||
};
|
||||
const audit = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
let agentsService: AgentsService;
|
||||
let controller: AdminController;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
prisma.$transaction.mockImplementation(async (arg: unknown) => {
|
||||
if (Array.isArray(arg)) return Promise.all(arg);
|
||||
return (arg as (client: typeof tx) => Promise<unknown>)(tx);
|
||||
});
|
||||
|
||||
systemConfig.getAgentSuspendSettings.mockResolvedValue({
|
||||
suspendFreezeDirectPlayers: true,
|
||||
suspendBlockPlayerLogin: true,
|
||||
});
|
||||
|
||||
agentsService = new AgentsService(
|
||||
prisma as never,
|
||||
{ hashPassword: jest.fn() } as never,
|
||||
systemConfig as never,
|
||||
{} as never,
|
||||
{ recalculateUsedCredit: jest.fn() } as never,
|
||||
);
|
||||
jest.spyOn(agentsService, 'getAgentAdminDetail').mockResolvedValue({ userId: '10' } as never);
|
||||
|
||||
prisma.agentProfile.findUnique.mockResolvedValue({
|
||||
userId: agentId,
|
||||
user: { username: 'agent-a', locale: 'zh-CN' },
|
||||
parentAgentId: null,
|
||||
});
|
||||
prisma.user.updateMany.mockResolvedValue({ count: 2 });
|
||||
prisma.agentProfile.update.mockResolvedValue({});
|
||||
prisma.user.update.mockResolvedValue({});
|
||||
|
||||
const deps = stubDeps();
|
||||
deps.agents = agentsService as never;
|
||||
deps.audit = audit;
|
||||
controller = buildController(deps);
|
||||
});
|
||||
|
||||
it('forwards bare SUSPENDED body to AgentsService and applies global defaults', async () => {
|
||||
await controller.updateAgent(operatorId, agentId.toString(), { status: 'SUSPENDED' });
|
||||
|
||||
expect(systemConfig.getAgentSuspendSettings).toHaveBeenCalled();
|
||||
expect(prisma.user.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ parentId: agentId, userType: 'PLAYER' }),
|
||||
data: { status: 'SUSPENDED' },
|
||||
}),
|
||||
);
|
||||
expect(prisma.agentProfile.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ blockDirectPlayerLogin: true }),
|
||||
}),
|
||||
);
|
||||
expect(audit.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
operatorId,
|
||||
action: 'UPDATE_AGENT',
|
||||
module: 'AGENTS',
|
||||
targetId: agentId.toString(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not freeze players when global default is false and body omits flag', async () => {
|
||||
systemConfig.getAgentSuspendSettings.mockResolvedValue({
|
||||
suspendFreezeDirectPlayers: false,
|
||||
suspendBlockPlayerLogin: false,
|
||||
});
|
||||
|
||||
await controller.updateAgent(operatorId, agentId.toString(), { status: 'SUSPENDED' });
|
||||
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
expect(prisma.agentProfile.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ blockDirectPlayerLogin: false }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1110,6 +1110,14 @@ class InboxNotifySettingsDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
deposit?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
banner?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
announcement?: boolean;
|
||||
}
|
||||
|
||||
class BroadcastTranslationDto {
|
||||
@@ -3444,21 +3452,21 @@ export class AdminController {
|
||||
const item = await this.content.create(createDto);
|
||||
let notifiedCount: number | undefined;
|
||||
if (notifyInbox && (createDto.status ?? 'DRAFT') === 'ACTIVE') {
|
||||
const inboxEnabled = await this.systemConfig.getInboxFeatureEnabled();
|
||||
if (inboxEnabled) {
|
||||
const inboxNotify = await this.systemConfig.getInboxNotifySettings();
|
||||
if (inboxNotify.inboxEnabled) {
|
||||
const translations = createDto.translations.map((tr) => ({
|
||||
locale: tr.locale,
|
||||
title: tr.title,
|
||||
body: tr.body,
|
||||
}));
|
||||
if (createDto.contentType === 'BANNER') {
|
||||
if (createDto.contentType === 'BANNER' && inboxNotify.banner) {
|
||||
notifiedCount = await this.playerMessages.broadcastBannerPromotion({
|
||||
contentId: BigInt(item.id),
|
||||
translations,
|
||||
});
|
||||
} else if (
|
||||
createDto.contentType === 'NOTICE' ||
|
||||
createDto.contentType === 'TICKER'
|
||||
(createDto.contentType === 'NOTICE' || createDto.contentType === 'TICKER') &&
|
||||
inboxNotify.announcement
|
||||
) {
|
||||
notifiedCount = await this.playerMessages.broadcastAnnouncementPromotion({
|
||||
contentId: BigInt(item.id),
|
||||
|
||||
@@ -32,12 +32,30 @@ describe('AgentsService', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const prisma = createPrismaMock(tx);
|
||||
const prismaBase = {
|
||||
...tx,
|
||||
agentProfile: {
|
||||
...tx.agentProfile,
|
||||
update: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
...tx.user,
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
};
|
||||
const prisma = createPrismaMock(prismaBase) as typeof prismaBase & { $transaction: jest.Mock };
|
||||
prisma.$transaction.mockImplementation(async (arg: unknown) => {
|
||||
if (Array.isArray(arg)) {
|
||||
return Promise.all(arg);
|
||||
}
|
||||
return (arg as (client: typeof tx) => Promise<unknown>)(tx);
|
||||
});
|
||||
const auth = {
|
||||
hashPassword: jest.fn(),
|
||||
};
|
||||
const systemConfig = {
|
||||
getAgentHierarchySettings: jest.fn(),
|
||||
getAgentSuspendSettings: jest.fn(),
|
||||
};
|
||||
const network = {};
|
||||
const credit = {
|
||||
@@ -56,13 +74,20 @@ describe('AgentsService', () => {
|
||||
credit as never,
|
||||
);
|
||||
|
||||
systemConfig.getAgentHierarchySettings.mockResolvedValue({ maxAgentLevel: 3 });
|
||||
systemConfig.getAgentHierarchySettings.mockResolvedValue({
|
||||
maxAgentLevel: 3,
|
||||
defaultSubAgentCreditRatio: 50,
|
||||
});
|
||||
systemConfig.getAgentSuspendSettings.mockResolvedValue({
|
||||
suspendFreezeDirectPlayers: true,
|
||||
suspendBlockPlayerLogin: true,
|
||||
});
|
||||
auth.hashPassword.mockResolvedValue('hashed-password');
|
||||
tx.agentProfile.findUnique.mockResolvedValue({
|
||||
prisma.agentProfile.findUnique.mockResolvedValue({
|
||||
userId: parentAgentId,
|
||||
level: 1,
|
||||
creditLimit: new Decimal(1000),
|
||||
usedCredit: new Decimal(0),
|
||||
creditLimit: new Decimal(10000),
|
||||
usedCredit: new Decimal(2000),
|
||||
cashbackRate: new Decimal(10),
|
||||
maxSingleDeposit: null,
|
||||
maxDailyDeposit: null,
|
||||
@@ -91,10 +116,41 @@ describe('AgentsService', () => {
|
||||
password: 'secret',
|
||||
level: 2,
|
||||
parentAgentId,
|
||||
creditLimit: 300,
|
||||
});
|
||||
|
||||
expect(tx.agentProfile.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ creditLimit: 4000 }),
|
||||
}),
|
||||
);
|
||||
expect(credit.recalculateUsedCredit).toHaveBeenCalledWith(parentAgentId);
|
||||
expect(credit.recalculateUsedCredit).not.toHaveBeenCalledWith(createdAgentId);
|
||||
});
|
||||
|
||||
it('applies global suspend defaults when suspending without explicit flags', async () => {
|
||||
jest.spyOn(service, 'getAgentAdminDetail').mockResolvedValue({ userId: '10' } as never);
|
||||
prisma.agentProfile.findUnique.mockResolvedValue({
|
||||
userId: 10n,
|
||||
user: { username: 'agent-a', locale: 'zh-CN' },
|
||||
parentAgentId: null,
|
||||
});
|
||||
prisma.user.updateMany.mockResolvedValue({ count: 2 });
|
||||
prisma.agentProfile.update.mockResolvedValue({});
|
||||
prisma.user.update.mockResolvedValue({});
|
||||
|
||||
await service.updateAgentAdmin(10n, { status: 'SUSPENDED' });
|
||||
|
||||
expect(systemConfig.getAgentSuspendSettings).toHaveBeenCalled();
|
||||
expect(prisma.user.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({ parentId: 10n, userType: 'PLAYER' }),
|
||||
data: { status: 'SUSPENDED' },
|
||||
}),
|
||||
);
|
||||
expect(prisma.agentProfile.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ blockDirectPlayerLogin: true }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,15 @@ export class AgentsService {
|
||||
return agentLevel < maxLevel;
|
||||
}
|
||||
|
||||
/** 与 Admin 创建下级代理预填逻辑一致:可用授信 × 比例(%),非 100% 时向下取整到百位 */
|
||||
computeDefaultSubAgentCredit(availableCredit: number, ratioPercent: number): number {
|
||||
if (availableCredit <= 0) return 0;
|
||||
const pct = Math.min(100, Math.max(1, ratioPercent)) / 100;
|
||||
const raw = availableCredit * pct;
|
||||
const rounded = pct >= 1 ? availableCredit : Math.floor(raw / 100) * 100;
|
||||
return Math.min(availableCredit, Math.max(0, rounded));
|
||||
}
|
||||
|
||||
private async buildAgentAncestorChainMap(parentAgentIds: (bigint | null | undefined)[]) {
|
||||
return this.network.buildAgentAncestorChainMap(parentAgentIds);
|
||||
}
|
||||
@@ -923,11 +932,17 @@ export class AgentsService {
|
||||
});
|
||||
}
|
||||
|
||||
// Handle status change (per-action cascade freeze / login block)
|
||||
// Handle status change (per-action cascade freeze / login block; 未传参时用全局默认)
|
||||
if (data.status) {
|
||||
const profilePatch: Prisma.AgentProfileUpdateInput = { status: data.status };
|
||||
let freezeDirectPlayers = false;
|
||||
if (data.status === 'SUSPENDED') {
|
||||
profilePatch.blockDirectPlayerLogin = data.blockDirectPlayerLogin === true;
|
||||
const suspendDefaults = await this.systemConfig.getAgentSuspendSettings();
|
||||
const blockDirectPlayerLogin =
|
||||
data.blockDirectPlayerLogin ?? suspendDefaults.suspendBlockPlayerLogin;
|
||||
freezeDirectPlayers =
|
||||
data.freezeDirectPlayers ?? suspendDefaults.suspendFreezeDirectPlayers;
|
||||
profilePatch.blockDirectPlayerLogin = blockDirectPlayerLogin === true;
|
||||
} else if (data.status === 'ACTIVE') {
|
||||
profilePatch.blockDirectPlayerLogin = false;
|
||||
}
|
||||
@@ -943,7 +958,7 @@ export class AgentsService {
|
||||
}),
|
||||
]);
|
||||
|
||||
if (data.status === 'SUSPENDED' && data.freezeDirectPlayers) {
|
||||
if (data.status === 'SUSPENDED' && freezeDirectPlayers) {
|
||||
await this.prisma.user.updateMany({
|
||||
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
|
||||
data: { status: 'SUSPENDED' },
|
||||
@@ -1156,20 +1171,31 @@ export class AgentsService {
|
||||
) {
|
||||
await this.validateAgentLevel(data.level, data.parentAgentId);
|
||||
|
||||
let resolvedCreditLimit = data.creditLimit;
|
||||
let resolvedCashbackRate = data.cashbackRate ?? 0;
|
||||
if (data.parentAgentId) {
|
||||
const parentProfile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: data.parentAgentId },
|
||||
select: { cashbackRate: true },
|
||||
select: { cashbackRate: true, creditLimit: true, usedCredit: true },
|
||||
});
|
||||
resolvedCashbackRate =
|
||||
data.cashbackRate ?? (parentProfile ? Number(parentProfile.cashbackRate) : 0);
|
||||
if (resolvedCreditLimit === undefined && parentProfile) {
|
||||
const hierarchy = await this.systemConfig.getAgentHierarchySettings();
|
||||
const available = new Decimal(parentProfile.creditLimit).sub(parentProfile.usedCredit);
|
||||
resolvedCreditLimit = this.computeDefaultSubAgentCredit(
|
||||
available.toNumber(),
|
||||
hierarchy.defaultSubAgentCreditRatio,
|
||||
);
|
||||
}
|
||||
await this.assertChildAgentWithinParent(data.parentAgentId, {
|
||||
creditLimit: data.creditLimit ?? 0,
|
||||
creditLimit: resolvedCreditLimit ?? 0,
|
||||
cashbackRate: resolvedCashbackRate,
|
||||
maxSingleDeposit: data.maxSingleDeposit,
|
||||
maxDailyDeposit: data.maxDailyDeposit,
|
||||
});
|
||||
} else if (resolvedCreditLimit === undefined) {
|
||||
resolvedCreditLimit = 0;
|
||||
}
|
||||
|
||||
const maxSingleDeposit = this.normalizeOptionalLimit(data.maxSingleDeposit);
|
||||
@@ -1208,7 +1234,7 @@ export class AgentsService {
|
||||
userId: user.id,
|
||||
level: data.level,
|
||||
parentAgentId: data.parentAgentId,
|
||||
creditLimit: data.creditLimit ?? 0,
|
||||
creditLimit: resolvedCreditLimit ?? 0,
|
||||
cashbackRate: resolvedCashbackRate,
|
||||
maxSingleDeposit,
|
||||
maxDailyDeposit,
|
||||
|
||||
@@ -47,6 +47,8 @@ describe('DepositService', () => {
|
||||
getInboxNotifySettings: jest.fn().mockResolvedValue({
|
||||
inboxEnabled: true,
|
||||
deposit: true,
|
||||
banner: true,
|
||||
announcement: true,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
83
apps/api/src/domains/identity/auth.service.spec.ts
Normal file
83
apps/api/src/domains/identity/auth.service.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { AuthService } from './auth.service';
|
||||
import { expectAppError } from '../../testing/prisma-mock';
|
||||
|
||||
describe('AuthService player login', () => {
|
||||
const prisma = {
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
userAuth: {
|
||||
update: jest.fn(),
|
||||
},
|
||||
};
|
||||
const jwt = { sign: jest.fn().mockReturnValue('token') };
|
||||
const config = { get: jest.fn().mockReturnValue('1h') };
|
||||
const systemConfig = {};
|
||||
const invites = {};
|
||||
const sms = {};
|
||||
const audit = { log: jest.fn() };
|
||||
|
||||
let service: AuthService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new AuthService(
|
||||
prisma as never,
|
||||
jwt as never,
|
||||
config as never,
|
||||
systemConfig as never,
|
||||
invites as never,
|
||||
sms as never,
|
||||
audit as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks player login when parent agent is suspended with blockDirectPlayerLogin', async () => {
|
||||
const passwordHash = await bcrypt.hash('Player@123', 4);
|
||||
prisma.user.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
id: 10n,
|
||||
username: 'player1',
|
||||
userType: 'PLAYER',
|
||||
status: 'ACTIVE',
|
||||
parentId: 20n,
|
||||
auth: { passwordHash, loginFailCount: 0, lockedUntil: null },
|
||||
adminRole: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
userType: 'AGENT',
|
||||
status: 'SUSPENDED',
|
||||
agentProfile: { blockDirectPlayerLogin: true },
|
||||
});
|
||||
|
||||
await expect(service.login('player1', 'Player@123', 'player')).rejects.toMatchObject(
|
||||
expectAppError('PARENT_AGENT_SUSPENDED'),
|
||||
);
|
||||
expect(jwt.sign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows player login when parent agent suspended without blockDirectPlayerLogin', async () => {
|
||||
const passwordHash = await bcrypt.hash('Player@123', 4);
|
||||
prisma.user.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
id: 10n,
|
||||
username: 'player1',
|
||||
userType: 'PLAYER',
|
||||
status: 'ACTIVE',
|
||||
parentId: 20n,
|
||||
locale: 'zh-CN',
|
||||
auth: { passwordHash, loginFailCount: 0, lockedUntil: null },
|
||||
adminRole: null,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
userType: 'AGENT',
|
||||
status: 'SUSPENDED',
|
||||
agentProfile: { blockDirectPlayerLogin: false },
|
||||
});
|
||||
prisma.userAuth.update.mockResolvedValue({});
|
||||
|
||||
const result = await service.login('player1', 'Player@123', 'player');
|
||||
expect(result.token).toBe('token');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { CashbackService } from './cashback.service';
|
||||
|
||||
describe('CashbackService previewBatch', () => {
|
||||
const tx = {
|
||||
cashbackBatch: {
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
cashbackItem: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
cashbackBet: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
cashbackBatch: {
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
cashbackBet: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
bet: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
cashbackRule: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
agentProfile: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
wallet: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
|
||||
};
|
||||
const funds = {};
|
||||
const systemConfig = {
|
||||
getPlatformDirectCashbackSettings: jest.fn(),
|
||||
};
|
||||
|
||||
let service: CashbackService;
|
||||
|
||||
const platformPlayerId = 100n;
|
||||
const adminInvitePlayerId = 101n;
|
||||
const adminSponsorId = 200n;
|
||||
const periodStart = new Date('2026-06-01T00:00:00.000Z');
|
||||
const periodEnd = new Date('2026-06-01T23:59:59.999Z');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new CashbackService(prisma as never, funds as never, systemConfig as never);
|
||||
systemConfig.getPlatformDirectCashbackSettings.mockResolvedValue({
|
||||
platformDirectRate: 0.02,
|
||||
adminInviteRate: 0.05,
|
||||
});
|
||||
prisma.cashbackBatch.findFirst.mockResolvedValue(null);
|
||||
prisma.cashbackBet.findMany.mockResolvedValue([]);
|
||||
prisma.cashbackRule.findMany.mockResolvedValue([]);
|
||||
prisma.agentProfile.findMany.mockResolvedValue([]);
|
||||
tx.cashbackBatch.findMany.mockResolvedValue([]);
|
||||
tx.cashbackItem.create.mockResolvedValue({});
|
||||
tx.cashbackBet.create.mockResolvedValue({});
|
||||
tx.cashbackBatch.create.mockImplementation(({ data }: { data: Record<string, unknown> }) =>
|
||||
Promise.resolve({ id: 1n, batchNo: 'CB-TEST', ...data }),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses platformDirectRate for platform-direct players', async () => {
|
||||
prisma.bet.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 1n,
|
||||
userId: platformPlayerId,
|
||||
stake: new Decimal(1000),
|
||||
status: 'WON',
|
||||
settledAt: new Date('2026-06-01T12:00:00.000Z'),
|
||||
user: { id: platformPlayerId, parentId: null, inviteSponsorId: null },
|
||||
selections: [{ marketType: 'FT_1X2' }],
|
||||
},
|
||||
]);
|
||||
prisma.user.findMany.mockResolvedValue([
|
||||
{ id: platformPlayerId, username: 'pd1', parent: null },
|
||||
]);
|
||||
prisma.wallet.findMany.mockResolvedValue([
|
||||
{ userId: platformPlayerId, availableBalance: new Decimal(0) },
|
||||
]);
|
||||
|
||||
const result = await service.previewBatch(periodStart, periodEnd);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].amount.toString()).toBe('20');
|
||||
expect(result.totalAmount.toString()).toBe('20');
|
||||
});
|
||||
|
||||
it('uses adminInviteRate for admin-invited platform-direct players', async () => {
|
||||
prisma.bet.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 2n,
|
||||
userId: adminInvitePlayerId,
|
||||
stake: new Decimal(1000),
|
||||
status: 'LOST',
|
||||
settledAt: new Date('2026-06-01T12:00:00.000Z'),
|
||||
user: {
|
||||
id: adminInvitePlayerId,
|
||||
parentId: null,
|
||||
inviteSponsorId: adminSponsorId,
|
||||
},
|
||||
selections: [{ marketType: 'FT_1X2' }],
|
||||
},
|
||||
]);
|
||||
prisma.user.findMany
|
||||
.mockResolvedValueOnce([{ id: adminSponsorId, userType: 'ADMIN' }])
|
||||
.mockResolvedValueOnce([{ id: adminInvitePlayerId, username: 'inv1', parent: null }]);
|
||||
prisma.wallet.findMany.mockResolvedValue([
|
||||
{ userId: adminInvitePlayerId, availableBalance: new Decimal(0) },
|
||||
]);
|
||||
|
||||
const result = await service.previewBatch(periodStart, periodEnd);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].amount.toString()).toBe('50');
|
||||
expect(result.totalAmount.toString()).toBe('50');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { BET_LIMIT_KEYS } from '../../betting/betting-limits.service';
|
||||
import type { AgentsService } from '../../agent/agents.service';
|
||||
import type { BetsService } from '../../betting/bets.service';
|
||||
import type { SettlementService } from '../../settlement/settlement.service';
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
teardownBetFlowFixture,
|
||||
} from './smoke-test.bet-flow.fixture';
|
||||
|
||||
export const BET_FLOW_PROBE_COUNT = 5;
|
||||
export const BET_FLOW_PROBE_COUNT = 13;
|
||||
|
||||
export type BetFlowProbeDeps = {
|
||||
prisma: PrismaService;
|
||||
@@ -22,6 +23,38 @@ export type BetFlowProbeDeps = {
|
||||
agents: AgentsService;
|
||||
};
|
||||
|
||||
async function upsertSystemConfig(
|
||||
prisma: PrismaService,
|
||||
key: string,
|
||||
value: string,
|
||||
snapshots: Array<{ key: string; value: string | null }>,
|
||||
) {
|
||||
const row = await prisma.systemConfig.findUnique({ where: { configKey: key } });
|
||||
snapshots.push({ key, value: row?.configValue ?? null });
|
||||
await prisma.systemConfig.upsert({
|
||||
where: { configKey: key },
|
||||
create: { configKey: key, configValue: value, description: 'smoke test' },
|
||||
update: { configValue: value },
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreSystemConfigs(
|
||||
prisma: PrismaService,
|
||||
snapshots: Array<{ key: string; value: string | null }>,
|
||||
) {
|
||||
for (const snap of snapshots) {
|
||||
if (snap.value === null) {
|
||||
await prisma.systemConfig.deleteMany({ where: { configKey: snap.key } });
|
||||
} else {
|
||||
await prisma.systemConfig.upsert({
|
||||
where: { configKey: snap.key },
|
||||
create: { configKey: snap.key, configValue: snap.value, description: 'smoke test restore' },
|
||||
update: { configValue: snap.value },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmMatchSettlement(
|
||||
deps: BetFlowProbeDeps,
|
||||
fx: BetFlowFixtureIds,
|
||||
@@ -201,6 +234,401 @@ export function createBetFlowProbes(deps: BetFlowProbeDeps): SmokeTestCaseDef[]
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BF006',
|
||||
suite: 'bet-flow',
|
||||
name: '录分校验:半场比分不能大于全场',
|
||||
description: 'HT>FT 时 recordScore 返回 SETTLEMENT_SCORE_INVALID',
|
||||
run: async () => {
|
||||
const fx = await createBetFlowFixture(deps.prisma, deps.wallet);
|
||||
try {
|
||||
await deps.prisma.match.update({
|
||||
where: { id: fx.matchId },
|
||||
data: { status: 'CLOSED', closeTime: new Date() },
|
||||
});
|
||||
await expectAppErrorThrows(
|
||||
'recordScore invalid HT>FT',
|
||||
async () => {
|
||||
await deps.settlement.recordScore(fx.matchId, 2, 0, 1, 0, fx.operatorId);
|
||||
},
|
||||
'SETTLEMENT_SCORE_INVALID',
|
||||
);
|
||||
} finally {
|
||||
await teardownBetFlowFixture(deps.prisma, fx);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BF007',
|
||||
suite: 'bet-flow',
|
||||
name: '确认结算:拒绝已作废的旧 PREVIEW 批次',
|
||||
description: '新 preview 后旧 batch 变为 CANCELLED,confirm 旧 batchId 失败',
|
||||
run: async () => {
|
||||
const fx = await createBetFlowFixture(deps.prisma, deps.wallet);
|
||||
try {
|
||||
await deps.prisma.match.update({
|
||||
where: { id: fx.matchId },
|
||||
data: { status: 'CLOSED', closeTime: new Date() },
|
||||
});
|
||||
const previewA = await deps.settlement.previewSettlement(fx.matchId, fx.operatorId, {
|
||||
htHome: 0,
|
||||
htAway: 0,
|
||||
ftHome: 1,
|
||||
ftAway: 0,
|
||||
});
|
||||
await deps.settlement.previewSettlement(fx.matchId, fx.operatorId, {
|
||||
htHome: 0,
|
||||
htAway: 0,
|
||||
ftHome: 2,
|
||||
ftAway: 1,
|
||||
});
|
||||
const stale = await deps.prisma.settlementBatch.findUnique({
|
||||
where: { id: previewA.batch.id },
|
||||
});
|
||||
expectEqual('old batch cancelled', stale?.status, 'CANCELLED');
|
||||
await expectAppErrorThrows(
|
||||
'confirm cancelled batch',
|
||||
async () => {
|
||||
await deps.settlement.confirmSettlement(previewA.batch.id, fx.operatorId);
|
||||
},
|
||||
'SETTLEMENT_BATCH_ALREADY_CONFIRMED',
|
||||
);
|
||||
} finally {
|
||||
await teardownBetFlowFixture(deps.prisma, fx);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BF008',
|
||||
suite: 'bet-flow',
|
||||
name: '串动作废:VOID 腿续算,已有 LOSE 腿不退本',
|
||||
description: '跨场串关一场 LOSE 后另一场取消,payout=0 而非退 stake',
|
||||
run: async () => {
|
||||
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 });
|
||||
const matchB = await deps.prisma.match.create({
|
||||
data: {
|
||||
sportType: 'FOOTBALL',
|
||||
leagueId: fx.leagueId,
|
||||
homeTeamId: fx.homeTeamId,
|
||||
awayTeamId: fx.awayTeamId,
|
||||
startTime: new Date(Date.now() + 48 * 60 * 60 * 1000),
|
||||
status: 'PUBLISHED',
|
||||
publishTime: new Date(),
|
||||
},
|
||||
});
|
||||
const marketB = await deps.prisma.market.create({
|
||||
data: {
|
||||
matchId: matchB.id,
|
||||
marketType: 'FT_1X2',
|
||||
period: 'FT',
|
||||
status: 'OPEN',
|
||||
selections: {
|
||||
create: [
|
||||
{
|
||||
selectionCode: 'HOME',
|
||||
selectionName: 'Home B',
|
||||
odds: new Decimal('2.00'),
|
||||
oddsVersion: BigInt(1),
|
||||
status: 'OPEN',
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
selectionCode: 'AWAY',
|
||||
selectionName: 'Away B',
|
||||
odds: new Decimal('2.00'),
|
||||
oddsVersion: BigInt(1),
|
||||
status: 'OPEN',
|
||||
sortOrder: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: { selections: true },
|
||||
});
|
||||
const awayB = marketB.selections.find((s) => s.selectionCode === 'AWAY')!;
|
||||
|
||||
try {
|
||||
const parlay = await deps.bets.placeParlayBet(
|
||||
fx.playerId,
|
||||
null,
|
||||
[
|
||||
{ selectionId: fx.homeSelectionId, oddsVersion: fx.homeOddsVersion },
|
||||
{ selectionId: awayB.id, oddsVersion: awayB.oddsVersion },
|
||||
],
|
||||
100,
|
||||
`smoke-parlay-void-${fx.runId}`,
|
||||
);
|
||||
|
||||
await deps.prisma.match.update({
|
||||
where: { id: matchB.id },
|
||||
data: { status: 'CLOSED', closeTime: new Date() },
|
||||
});
|
||||
const previewB = await deps.settlement.previewSettlement(matchB.id, fx.operatorId, {
|
||||
htHome: 1,
|
||||
htAway: 0,
|
||||
ftHome: 2,
|
||||
ftAway: 1,
|
||||
});
|
||||
await deps.settlement.confirmSettlement(previewB.batch.id, fx.operatorId);
|
||||
|
||||
let w = await deps.wallet.getWallet(fx.playerId);
|
||||
expectEqual('frozen while pending', w.frozenBalance.toString(), '100');
|
||||
|
||||
await deps.settlement.voidMatchBets(fx.matchId);
|
||||
|
||||
const settled = await deps.prisma.bet.findUnique({ where: { id: parlay.id } });
|
||||
expectEqual('settled status', settled?.status, 'LOST');
|
||||
expectEqual('actualReturn', settled?.actualReturn.toString(), '0');
|
||||
|
||||
w = await deps.wallet.getWallet(fx.playerId);
|
||||
expectEqual('available after void parlay', w.availableBalance.toString(), '900');
|
||||
expectEqual('frozen after void parlay', w.frozenBalance.toString(), '0');
|
||||
} finally {
|
||||
const bets = await deps.prisma.bet.findMany({
|
||||
where: { userId: fx.playerId },
|
||||
select: { id: true },
|
||||
});
|
||||
const betIds = bets.map((b) => b.id);
|
||||
if (betIds.length) {
|
||||
await deps.prisma.betSelection.deleteMany({ where: { betId: { in: betIds } } });
|
||||
await deps.prisma.bet.deleteMany({ where: { id: { in: betIds } } });
|
||||
}
|
||||
const batchIds = (
|
||||
await deps.prisma.settlementBatch.findMany({
|
||||
where: { matchId: { in: [fx.matchId, matchB.id] } },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((b) => b.id);
|
||||
if (batchIds.length) {
|
||||
await deps.prisma.settlementItem.deleteMany({ where: { batchId: { in: batchIds } } });
|
||||
await deps.prisma.settlementBatch.deleteMany({ where: { id: { in: batchIds } } });
|
||||
}
|
||||
await deps.prisma.matchScore.deleteMany({
|
||||
where: { matchId: { in: [fx.matchId, matchB.id] } },
|
||||
});
|
||||
const marketIds = (
|
||||
await deps.prisma.market.findMany({
|
||||
where: { matchId: matchB.id },
|
||||
select: { id: true },
|
||||
})
|
||||
).map((m) => m.id);
|
||||
if (marketIds.length) {
|
||||
await deps.prisma.marketSelection.deleteMany({ where: { marketId: { in: marketIds } } });
|
||||
await deps.prisma.market.deleteMany({ where: { id: { in: marketIds } } });
|
||||
}
|
||||
await deps.prisma.match.deleteMany({ where: { id: matchB.id } });
|
||||
await teardownBetFlowFixture(deps.prisma, fx);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BF009',
|
||||
suite: 'bet-flow',
|
||||
name: '赔率版本不一致:BetsService 拒绝下注',
|
||||
uatRef: 'B003',
|
||||
description: '提交旧 oddsVersion 应返回 ODDS_CHANGED',
|
||||
run: async () => {
|
||||
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 500 });
|
||||
try {
|
||||
await deps.prisma.marketSelection.update({
|
||||
where: { id: fx.homeSelectionId },
|
||||
data: { oddsVersion: { increment: 1 } },
|
||||
});
|
||||
await expectAppErrorThrows(
|
||||
'placeSingleBet odds changed',
|
||||
async () => {
|
||||
await deps.bets.placeSingleBet(
|
||||
fx.playerId,
|
||||
null,
|
||||
fx.homeSelectionId,
|
||||
fx.homeOddsVersion,
|
||||
50,
|
||||
`smoke-odds-${fx.runId}`,
|
||||
);
|
||||
},
|
||||
'ODDS_CHANGED',
|
||||
);
|
||||
const count = await deps.prisma.bet.count({ where: { userId: fx.playerId } });
|
||||
expectEqual('bet count', count, 0);
|
||||
} finally {
|
||||
await teardownBetFlowFixture(deps.prisma, fx);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BF010',
|
||||
suite: 'bet-flow',
|
||||
name: '赛前限制:开赛后拒绝下注',
|
||||
description: 'startTime 已过后 placeSingleBet 返回 PRE_MATCH_ONLY',
|
||||
run: async () => {
|
||||
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 500 });
|
||||
try {
|
||||
await deps.prisma.match.update({
|
||||
where: { id: fx.matchId },
|
||||
data: { startTime: new Date(Date.now() - 60_000) },
|
||||
});
|
||||
await expectAppErrorThrows(
|
||||
'placeSingleBet after kickoff',
|
||||
async () => {
|
||||
await deps.bets.placeSingleBet(
|
||||
fx.playerId,
|
||||
null,
|
||||
fx.homeSelectionId,
|
||||
fx.homeOddsVersion,
|
||||
50,
|
||||
`smoke-prematch-${fx.runId}`,
|
||||
);
|
||||
},
|
||||
'PRE_MATCH_ONLY',
|
||||
);
|
||||
} finally {
|
||||
await teardownBetFlowFixture(deps.prisma, fx);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BF011',
|
||||
suite: 'bet-flow',
|
||||
name: '每日投注上限:超额拒单',
|
||||
description: 'dailyStakeLimit=150 时第二笔 100 注单失败',
|
||||
run: async () => {
|
||||
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 });
|
||||
const snapshots: Array<{ key: string; value: string | null }> = [];
|
||||
try {
|
||||
await upsertSystemConfig(deps.prisma, BET_LIMIT_KEYS.dailyStakeLimit, '150', snapshots);
|
||||
await deps.bets.placeSingleBet(
|
||||
fx.playerId,
|
||||
null,
|
||||
fx.homeSelectionId,
|
||||
fx.homeOddsVersion,
|
||||
100,
|
||||
`smoke-daily-1-${fx.runId}`,
|
||||
);
|
||||
await expectAppErrorThrows(
|
||||
'placeSingleBet daily limit',
|
||||
async () => {
|
||||
await deps.bets.placeSingleBet(
|
||||
fx.playerId,
|
||||
null,
|
||||
fx.drawSelectionId,
|
||||
fx.drawOddsVersion,
|
||||
100,
|
||||
`smoke-daily-2-${fx.runId}`,
|
||||
);
|
||||
},
|
||||
'DAILY_STAKE_LIMIT',
|
||||
);
|
||||
} finally {
|
||||
await restoreSystemConfigs(deps.prisma, snapshots);
|
||||
await teardownBetFlowFixture(deps.prisma, fx);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BF012',
|
||||
suite: 'bet-flow',
|
||||
name: '串关限额:超 maxStakeParlay 拒单',
|
||||
description: 'maxStakeParlay=80 时下 100 串关失败',
|
||||
run: async () => {
|
||||
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 });
|
||||
const matchB = await deps.prisma.match.create({
|
||||
data: {
|
||||
sportType: 'FOOTBALL',
|
||||
leagueId: fx.leagueId,
|
||||
homeTeamId: fx.homeTeamId,
|
||||
awayTeamId: fx.awayTeamId,
|
||||
startTime: new Date(Date.now() + 48 * 60 * 60 * 1000),
|
||||
status: 'PUBLISHED',
|
||||
publishTime: new Date(),
|
||||
},
|
||||
});
|
||||
const marketB = await deps.prisma.market.create({
|
||||
data: {
|
||||
matchId: matchB.id,
|
||||
marketType: 'FT_1X2',
|
||||
period: 'FT',
|
||||
status: 'OPEN',
|
||||
selections: {
|
||||
create: [
|
||||
{
|
||||
selectionCode: 'AWAY',
|
||||
selectionName: 'Away B',
|
||||
odds: new Decimal('2.00'),
|
||||
oddsVersion: BigInt(1),
|
||||
status: 'OPEN',
|
||||
sortOrder: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: { selections: true },
|
||||
});
|
||||
const awayB = marketB.selections[0]!;
|
||||
const snapshots: Array<{ key: string; value: string | null }> = [];
|
||||
try {
|
||||
await upsertSystemConfig(deps.prisma, BET_LIMIT_KEYS.maxStakeParlay, '80', snapshots);
|
||||
await expectAppErrorThrows(
|
||||
'placeParlayBet max stake',
|
||||
async () => {
|
||||
await deps.bets.placeParlayBet(
|
||||
fx.playerId,
|
||||
null,
|
||||
[
|
||||
{ selectionId: fx.homeSelectionId, oddsVersion: fx.homeOddsVersion },
|
||||
{ selectionId: awayB.id, oddsVersion: awayB.oddsVersion },
|
||||
],
|
||||
100,
|
||||
`smoke-parlay-max-${fx.runId}`,
|
||||
);
|
||||
},
|
||||
'MAX_STAKE',
|
||||
);
|
||||
} finally {
|
||||
await restoreSystemConfigs(deps.prisma, snapshots);
|
||||
const markets = await deps.prisma.market.findMany({
|
||||
where: { matchId: matchB.id },
|
||||
select: { id: true },
|
||||
});
|
||||
const marketIds = markets.map((m) => m.id);
|
||||
if (marketIds.length) {
|
||||
await deps.prisma.marketSelection.deleteMany({ where: { marketId: { in: marketIds } } });
|
||||
await deps.prisma.market.deleteMany({ where: { id: { in: marketIds } } });
|
||||
}
|
||||
await deps.prisma.match.deleteMany({ where: { id: matchB.id } });
|
||||
await teardownBetFlowFixture(deps.prisma, fx);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BF013',
|
||||
suite: 'bet-flow',
|
||||
name: '改 SystemConfig 后拒单:minStake',
|
||||
description: 'minStake=200 时下 100 单关返回 MIN_STAKE',
|
||||
run: async () => {
|
||||
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 });
|
||||
const snapshots: Array<{ key: string; value: string | null }> = [];
|
||||
try {
|
||||
await upsertSystemConfig(deps.prisma, BET_LIMIT_KEYS.minStake, '200', snapshots);
|
||||
await expectAppErrorThrows(
|
||||
'placeSingleBet min stake',
|
||||
async () => {
|
||||
await deps.bets.placeSingleBet(
|
||||
fx.playerId,
|
||||
null,
|
||||
fx.homeSelectionId,
|
||||
fx.homeOddsVersion,
|
||||
100,
|
||||
`smoke-min-${fx.runId}`,
|
||||
);
|
||||
},
|
||||
'MIN_STAKE',
|
||||
);
|
||||
} finally {
|
||||
await restoreSystemConfigs(deps.prisma, snapshots);
|
||||
await teardownBetFlowFixture(deps.prisma, fx);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'BF005',
|
||||
suite: 'bet-flow',
|
||||
|
||||
@@ -380,6 +380,36 @@ export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [
|
||||
expectEqual('payout', payout.toNumber(), 100, { stake: 100, legs });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S020',
|
||||
suite: 'settlement',
|
||||
name: '串关 VOID 腿 + LOSE 腿:整单全输',
|
||||
description: '比赛取消腿按 odds=1.0 续算,已有 LOSE 腿时 payout=0',
|
||||
run: () => {
|
||||
const legs = [
|
||||
{ odds: 2.0, result: 'VOID' as const },
|
||||
{ odds: 2.0, result: 'LOSE' as const },
|
||||
];
|
||||
const { betResult, payout } = calculateParlayPayout(100, legs);
|
||||
expectEqual('betResult', betResult, 'LOST', { stake: 100, legs });
|
||||
expectEqual('payout', payout.toNumber(), 0, { stake: 100, legs });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'S021',
|
||||
suite: 'settlement',
|
||||
name: '串关 VOID 腿 + WIN 腿:按 WIN 腿赔率结算',
|
||||
description: '作废腿不参与升赔,其余 WIN 腿正常连乘',
|
||||
run: () => {
|
||||
const legs = [
|
||||
{ odds: 2.0, result: 'VOID' as const },
|
||||
{ odds: 1.5, result: 'WIN' as const },
|
||||
];
|
||||
const { betResult, payout } = calculateParlayPayout(100, legs);
|
||||
expectEqual('betResult', betResult, 'WON', { stake: 100, legs });
|
||||
expectEqual('payout', payout.toNumber(), 150, { stake: 100, legs });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'OUT001',
|
||||
suite: 'settlement',
|
||||
@@ -431,17 +461,6 @@ export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [
|
||||
},
|
||||
|
||||
// —— Betting rules ——
|
||||
{
|
||||
id: 'B003',
|
||||
suite: 'betting',
|
||||
name: '赔率版本不一致应拒绝',
|
||||
uatRef: 'B003',
|
||||
run: () => {
|
||||
const submitted = BigInt(1);
|
||||
const current = BigInt(2);
|
||||
expectTrue('version mismatch', submitted !== current, { submitted: '1', current: '2' });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'B006',
|
||||
suite: 'betting',
|
||||
@@ -760,7 +779,10 @@ export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [
|
||||
];
|
||||
|
||||
export const SMOKE_SUITE_META: Record<string, { name: string; description: string }> = {
|
||||
settlement: { name: '结算引擎', description: '独赢、波胆、让球、大小、串关、冠军盘' },
|
||||
settlement: {
|
||||
name: '结算引擎',
|
||||
description: '独赢、波胆、让球、大小、串关、冠军盘、作废腿续算',
|
||||
},
|
||||
settlement_helpers: { name: '结算辅助', description: '选项代码与中文快照名映射' },
|
||||
betting: { name: '下注规则', description: '串关限制、赔率版本、四分之一盘' },
|
||||
betting_limits: { name: '投注限额', description: '最小/最大投注与派彩上限校验' },
|
||||
@@ -771,4 +793,8 @@ export const SMOKE_SUITE_META: Record<string, { name: string; description: strin
|
||||
name: '下注结算链路',
|
||||
description: '真实 DB:下注→冻结→录分→结算→钱包/代理额度(临时数据自动清理)',
|
||||
},
|
||||
config: {
|
||||
name: '系统配置',
|
||||
description: '真实 DB:SystemConfig 接线后 AgentsService 行为(临时数据自动清理)',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import {
|
||||
AGENT_SUSPEND_BLOCK_PLAYER_LOGIN,
|
||||
AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS,
|
||||
} from '../../../shared/config/system-config.service';
|
||||
import type { AgentsService } from '../../agent/agents.service';
|
||||
import type { PrismaService } from '../../../shared/prisma/prisma.service';
|
||||
import { expectEqual, expectTrue } from './smoke-test.helpers';
|
||||
import type { SmokeTestCaseDef } from './smoke-test.cases';
|
||||
|
||||
export const CONFIG_PROBE_COUNT = 2;
|
||||
|
||||
export type ConfigProbeDeps = {
|
||||
prisma: PrismaService;
|
||||
agents: AgentsService;
|
||||
};
|
||||
|
||||
async function upsertBooleanConfig(
|
||||
prisma: PrismaService,
|
||||
key: string,
|
||||
value: boolean,
|
||||
snapshots: Array<{ key: string; value: string | null }>,
|
||||
) {
|
||||
const row = await prisma.systemConfig.findUnique({ where: { configKey: key } });
|
||||
snapshots.push({ key, value: row?.configValue ?? null });
|
||||
await prisma.systemConfig.upsert({
|
||||
where: { configKey: key },
|
||||
create: {
|
||||
configKey: key,
|
||||
configValue: value ? 'true' : 'false',
|
||||
description: 'smoke config probe',
|
||||
},
|
||||
update: { configValue: value ? 'true' : 'false' },
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreConfigs(
|
||||
prisma: PrismaService,
|
||||
snapshots: Array<{ key: string; value: string | null }>,
|
||||
) {
|
||||
for (const snap of snapshots) {
|
||||
if (snap.value === null) {
|
||||
await prisma.systemConfig.deleteMany({ where: { configKey: snap.key } });
|
||||
} else {
|
||||
await prisma.systemConfig.upsert({
|
||||
where: { configKey: snap.key },
|
||||
create: {
|
||||
configKey: snap.key,
|
||||
configValue: snap.value,
|
||||
description: 'smoke config restore',
|
||||
},
|
||||
update: { configValue: snap.value },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createConfigProbes(deps: ConfigProbeDeps): SmokeTestCaseDef[] {
|
||||
return [
|
||||
{
|
||||
id: 'CFG001',
|
||||
suite: 'config',
|
||||
name: '代理停用默认:全局 freeze 级联冻结直属玩家',
|
||||
description: 'AgentsService.updateAgentAdmin 未传 flag 时读取 agent.suspend_freeze_direct_players',
|
||||
run: async () => {
|
||||
const runId = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const snapshots: Array<{ key: string; value: string | null }> = [];
|
||||
let agentId: bigint | undefined;
|
||||
let playerId: bigint | undefined;
|
||||
|
||||
try {
|
||||
await upsertBooleanConfig(
|
||||
deps.prisma,
|
||||
AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS,
|
||||
true,
|
||||
snapshots,
|
||||
);
|
||||
await upsertBooleanConfig(
|
||||
deps.prisma,
|
||||
AGENT_SUSPEND_BLOCK_PLAYER_LOGIN,
|
||||
false,
|
||||
snapshots,
|
||||
);
|
||||
|
||||
const agent = await deps.prisma.user.create({
|
||||
data: {
|
||||
username: `smoke_cfg_ag_${runId}`,
|
||||
userType: 'AGENT',
|
||||
status: 'ACTIVE',
|
||||
auth: { create: { passwordHash: 'smoke' } },
|
||||
agentProfile: {
|
||||
create: { level: 1, creditLimit: new Decimal(10000) },
|
||||
},
|
||||
},
|
||||
});
|
||||
agentId = agent.id;
|
||||
await deps.prisma.agentClosure.create({
|
||||
data: { ancestorId: agent.id, descendantId: agent.id, depth: 0 },
|
||||
});
|
||||
|
||||
const player = await deps.prisma.user.create({
|
||||
data: {
|
||||
username: `smoke_cfg_pl_${runId}`,
|
||||
userType: 'PLAYER',
|
||||
status: 'ACTIVE',
|
||||
parentId: agent.id,
|
||||
auth: { create: { passwordHash: 'smoke' } },
|
||||
},
|
||||
});
|
||||
playerId = player.id;
|
||||
|
||||
await deps.agents.updateAgentAdmin(agent.id, { status: 'SUSPENDED' });
|
||||
|
||||
const frozen = await deps.prisma.user.findUnique({ where: { id: player.id } });
|
||||
expectEqual('player status after suspend', frozen?.status, 'SUSPENDED');
|
||||
} finally {
|
||||
if (playerId) {
|
||||
await deps.prisma.userAuth.deleteMany({ where: { userId: playerId } });
|
||||
await deps.prisma.user.deleteMany({ where: { id: playerId } });
|
||||
}
|
||||
if (agentId) {
|
||||
await deps.prisma.agentClosure.deleteMany({
|
||||
where: { OR: [{ ancestorId: agentId }, { descendantId: agentId }] },
|
||||
});
|
||||
await deps.prisma.agentProfile.deleteMany({ where: { userId: agentId } });
|
||||
await deps.prisma.userAuth.deleteMany({ where: { userId: agentId } });
|
||||
await deps.prisma.user.deleteMany({ where: { id: agentId } });
|
||||
}
|
||||
await restoreConfigs(deps.prisma, snapshots);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'CFG002',
|
||||
suite: 'config',
|
||||
name: '代理停用默认:全局 block 禁止直属玩家登录',
|
||||
description: 'AgentsService.updateAgentAdmin 未传 flag 时写入 blockDirectPlayerLogin',
|
||||
run: async () => {
|
||||
const runId = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const snapshots: Array<{ key: string; value: string | null }> = [];
|
||||
let agentId: bigint | undefined;
|
||||
|
||||
try {
|
||||
await upsertBooleanConfig(
|
||||
deps.prisma,
|
||||
AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS,
|
||||
false,
|
||||
snapshots,
|
||||
);
|
||||
await upsertBooleanConfig(
|
||||
deps.prisma,
|
||||
AGENT_SUSPEND_BLOCK_PLAYER_LOGIN,
|
||||
true,
|
||||
snapshots,
|
||||
);
|
||||
|
||||
const agent = await deps.prisma.user.create({
|
||||
data: {
|
||||
username: `smoke_cfg2_ag_${runId}`,
|
||||
userType: 'AGENT',
|
||||
status: 'ACTIVE',
|
||||
auth: { create: { passwordHash: 'smoke' } },
|
||||
agentProfile: {
|
||||
create: { level: 1, creditLimit: new Decimal(10000) },
|
||||
},
|
||||
},
|
||||
});
|
||||
agentId = agent.id;
|
||||
await deps.prisma.agentClosure.create({
|
||||
data: { ancestorId: agent.id, descendantId: agent.id, depth: 0 },
|
||||
});
|
||||
|
||||
await deps.agents.updateAgentAdmin(agent.id, { status: 'SUSPENDED' });
|
||||
|
||||
const profile = await deps.prisma.agentProfile.findUnique({
|
||||
where: { userId: agent.id },
|
||||
});
|
||||
expectTrue(
|
||||
'blockDirectPlayerLogin persisted',
|
||||
profile?.blockDirectPlayerLogin === true,
|
||||
{ value: profile?.blockDirectPlayerLogin },
|
||||
);
|
||||
} finally {
|
||||
if (agentId) {
|
||||
await deps.prisma.agentClosure.deleteMany({
|
||||
where: { OR: [{ ancestorId: agentId }, { descendantId: agentId }] },
|
||||
});
|
||||
await deps.prisma.agentProfile.deleteMany({ where: { userId: agentId } });
|
||||
await deps.prisma.userAuth.deleteMany({ where: { userId: agentId } });
|
||||
await deps.prisma.user.deleteMany({ where: { id: agentId } });
|
||||
}
|
||||
await restoreConfigs(deps.prisma, snapshots);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { SettlementService } from '../../settlement/settlement.service';
|
||||
import { WalletService } from '../../ledger/wallet.service';
|
||||
import { SMOKE_SUITE_META, SMOKE_TEST_CASES, type SmokeTestCaseDef } from './smoke-test.cases';
|
||||
import { BET_FLOW_PROBE_COUNT, createBetFlowProbes } from './smoke-test.bet-flow-probes';
|
||||
import { CONFIG_PROBE_COUNT, createConfigProbes } from './smoke-test.config-probes';
|
||||
import { createDatabaseProbes, DATABASE_PROBE_COUNT } from './smoke-test.db-probes';
|
||||
import {
|
||||
beginSmokeSteps,
|
||||
@@ -49,6 +50,7 @@ export class SmokeTestService {
|
||||
}
|
||||
counts.set('database', DATABASE_PROBE_COUNT);
|
||||
counts.set('bet-flow', BET_FLOW_PROBE_COUNT);
|
||||
counts.set('config', CONFIG_PROBE_COUNT);
|
||||
|
||||
return [...counts.entries()].map(([id, caseCount]) => ({
|
||||
id,
|
||||
@@ -77,6 +79,7 @@ export class SmokeTestService {
|
||||
const staticCases = SMOKE_TEST_CASES.filter((c) => !allow || allow.has(c.suite));
|
||||
const runDb = !allow || allow.has('database');
|
||||
const runBetFlow = !allow || allow.has('bet-flow');
|
||||
const runConfig = !allow || allow.has('config');
|
||||
|
||||
const results: SmokeTestCaseResult[] = [];
|
||||
|
||||
@@ -102,6 +105,15 @@ export class SmokeTestService {
|
||||
}
|
||||
}
|
||||
|
||||
if (runConfig) {
|
||||
for (const probe of createConfigProbes({
|
||||
prisma: this.prisma,
|
||||
agents: this.agents,
|
||||
})) {
|
||||
results.push(await this.executeCase(probe));
|
||||
}
|
||||
}
|
||||
|
||||
const finished = Date.now();
|
||||
const passed = results.filter((r) => r.status === 'PASS').length;
|
||||
const failed = results.filter((r) => r.status === 'FAIL').length;
|
||||
|
||||
@@ -118,8 +118,8 @@ function settleOverUnder(
|
||||
|
||||
if (winCount === 2) return 'WIN';
|
||||
if (loseCount === 2) return 'LOSE';
|
||||
if (winCount === 1) return 'HALF_WIN';
|
||||
if (loseCount === 1) return 'HALF_LOSE';
|
||||
if (winCount === 1 && loseCount === 0) return 'HALF_WIN';
|
||||
if (loseCount === 1 && winCount === 0) return 'HALF_LOSE';
|
||||
return 'PUSH';
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +148,9 @@ describe('SettlementService outright winner flow', () => {
|
||||
settlementBatch: {
|
||||
create: settlementBatchCreate,
|
||||
findUnique: settlementBatchFindUnique,
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
bet: { findMany: betFindMany },
|
||||
$transaction: transaction,
|
||||
@@ -247,7 +249,7 @@ describe('SettlementService outright winner flow', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a parlay pending until every match in the ticket is settled', async () => {
|
||||
it('shows parlay as lost when this match leg loses even if other legs are pending', async () => {
|
||||
matchFindFirst.mockResolvedValue({
|
||||
id: matchId,
|
||||
isOutright: false,
|
||||
@@ -304,14 +306,13 @@ describe('SettlementService outright winner flow', () => {
|
||||
ftAway: 1,
|
||||
});
|
||||
|
||||
expect(preview.pendingOtherMatches).toBe(1);
|
||||
expect(preview.lostOnThisMatch).toBe(0);
|
||||
expect(preview.pendingOtherMatches).toBe(0);
|
||||
expect(preview.lostOnThisMatch).toBe(1);
|
||||
expect(preview.items.items).toEqual([
|
||||
expect.objectContaining({
|
||||
betNo: 'PARLAY-PENDING',
|
||||
result: 'PENDING_OTHER_MATCHES',
|
||||
result: 'LOST',
|
||||
payout: '0',
|
||||
note: '本场腿已出结果,待其他场次结算后统一结算',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
@@ -404,6 +405,7 @@ describe('SettlementService outright winner flow', () => {
|
||||
findUnique: settlementBatchFindUnique,
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
findFirst: jest.fn().mockResolvedValue({ id: batchId }),
|
||||
},
|
||||
match: { update: txMatchUpdate },
|
||||
});
|
||||
@@ -474,3 +476,442 @@ describe('SettlementService outright winner flow', () => {
|
||||
expect(result).toEqual({ success: true, batchId: batchId.toString() });
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettlementService hardening', () => {
|
||||
const matchId = BigInt(200);
|
||||
const operatorId = BigInt(1);
|
||||
const batchId = BigInt(900);
|
||||
const otherBatchId = BigInt(901);
|
||||
|
||||
const fixtureMatch = {
|
||||
id: matchId,
|
||||
isOutright: false,
|
||||
status: 'PENDING_SETTLEMENT',
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
const pendingSingleBet = {
|
||||
id: BigInt(3001),
|
||||
betNo: 'BET-SINGLE',
|
||||
betType: 'SINGLE',
|
||||
status: 'PENDING',
|
||||
stake: new Decimal(100),
|
||||
agentId: null,
|
||||
userId: BigInt(70),
|
||||
user: { id: BigInt(70) },
|
||||
selections: [
|
||||
{
|
||||
id: BigInt(4001),
|
||||
matchId,
|
||||
marketType: 'FT_1X2',
|
||||
selectionId: BigInt(501),
|
||||
selectionNameSnapshot: 'Home',
|
||||
handicapLine: null,
|
||||
totalLine: null,
|
||||
odds: new Decimal(2),
|
||||
resultStatus: null,
|
||||
sortOrder: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function buildService(overrides: {
|
||||
prisma?: Record<string, unknown>;
|
||||
wallet?: Record<string, jest.Mock>;
|
||||
transactionClient?: Record<string, unknown>;
|
||||
} = {}) {
|
||||
const wallet = {
|
||||
settleBet: jest.fn().mockResolvedValue(undefined),
|
||||
voidBet: jest.fn().mockResolvedValue(undefined),
|
||||
...(overrides.wallet ?? {}),
|
||||
};
|
||||
const agents = { recalculateUsedCredit: jest.fn().mockResolvedValue(undefined) };
|
||||
const transaction = jest.fn(async (fn: (client: unknown) => Promise<void>) => {
|
||||
const defaultClient = {
|
||||
team: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||
market: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
marketSelection: { findMany: jest.fn().mockResolvedValue([{ id: BigInt(501), selectionCode: 'HOME' }]) },
|
||||
matchScore: {
|
||||
upsert: jest.fn().mockResolvedValue({}),
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
matchId,
|
||||
htHomeScore: 0,
|
||||
htAwayScore: 0,
|
||||
ftHomeScore: 1,
|
||||
ftAwayScore: 0,
|
||||
winnerTeamId: null,
|
||||
}),
|
||||
},
|
||||
bet: {
|
||||
findMany: jest.fn().mockResolvedValue([pendingSingleBet]),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
betSelection: { update: jest.fn().mockResolvedValue({}), findMany: jest.fn().mockResolvedValue([]) },
|
||||
settlementItem: { create: jest.fn().mockResolvedValue({}) },
|
||||
settlementBatch: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: batchId,
|
||||
batchNo: 'STL-001',
|
||||
matchId,
|
||||
status: 'PREVIEW',
|
||||
htHomeScore: 0,
|
||||
htAwayScore: 0,
|
||||
ftHomeScore: 1,
|
||||
ftAwayScore: 0,
|
||||
match: fixtureMatch,
|
||||
}),
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
findFirst: jest.fn().mockResolvedValue({ id: batchId }),
|
||||
},
|
||||
match: { update: jest.fn().mockResolvedValue({}) },
|
||||
...(overrides.transactionClient ?? {}),
|
||||
};
|
||||
await fn(defaultClient);
|
||||
});
|
||||
|
||||
const prisma = {
|
||||
match: {
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
findFirst: jest.fn().mockResolvedValue({ ...fixtureMatch, status: 'CLOSED' }),
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
team: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||
market: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
marketSelection: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
findMany: jest.fn().mockResolvedValue([{ id: BigInt(501), selectionCode: 'HOME' }]),
|
||||
},
|
||||
matchScore: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
upsert: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
settlementBatch: {
|
||||
create: jest.fn().mockResolvedValue({ id: batchId, matchId, status: 'PREVIEW' }),
|
||||
findUnique: jest.fn(),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
bet: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
$transaction: transaction,
|
||||
...(overrides.prisma ?? {}),
|
||||
};
|
||||
|
||||
return {
|
||||
service: new SettlementService(prisma as never, wallet as never, agents as never),
|
||||
prisma,
|
||||
wallet,
|
||||
transaction,
|
||||
};
|
||||
}
|
||||
|
||||
it('recordScore rejects half-time scores greater than full-time', async () => {
|
||||
const { service } = buildService();
|
||||
try {
|
||||
await service.recordScore(matchId, 2, 0, 1, 0, operatorId);
|
||||
throw new Error('Expected recordScore to reject');
|
||||
} catch (err) {
|
||||
const response = (err as { getResponse?: () => unknown }).getResponse?.();
|
||||
expect(response).toEqual(
|
||||
expect.objectContaining({ code: 'SETTLEMENT_SCORE_INVALID' }),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('previewSettlement cancels older preview batches before creating a new one', async () => {
|
||||
const updateMany = jest.fn().mockResolvedValue({ count: 1 });
|
||||
const { service } = buildService({
|
||||
prisma: {
|
||||
bet: { findMany: jest.fn().mockResolvedValue([]) },
|
||||
settlementBatch: {
|
||||
create: jest.fn().mockResolvedValue({ id: batchId, matchId, status: 'PREVIEW' }),
|
||||
updateMany,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await service.previewSettlement(matchId, operatorId, {
|
||||
htHome: 0,
|
||||
htAway: 0,
|
||||
ftHome: 1,
|
||||
ftAway: 0,
|
||||
});
|
||||
|
||||
expect(updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { matchId, status: 'PREVIEW', isResettle: false },
|
||||
data: { status: 'CANCELLED' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('confirmSettlement rejects stale preview batches', async () => {
|
||||
const staleBatch = {
|
||||
id: otherBatchId,
|
||||
batchNo: 'STL-OLD',
|
||||
matchId,
|
||||
status: 'PREVIEW',
|
||||
htHomeScore: 0,
|
||||
htAwayScore: 0,
|
||||
ftHomeScore: 1,
|
||||
ftAwayScore: 0,
|
||||
match: fixtureMatch,
|
||||
};
|
||||
const { service } = buildService({
|
||||
prisma: {
|
||||
settlementBatch: {
|
||||
findUnique: jest.fn().mockResolvedValue(staleBatch),
|
||||
},
|
||||
},
|
||||
transactionClient: {
|
||||
settlementBatch: {
|
||||
findUnique: jest.fn().mockResolvedValue(staleBatch),
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
findFirst: jest.fn().mockResolvedValue({ id: batchId }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await service.confirmSettlement(otherBatchId, operatorId);
|
||||
throw new Error('Expected confirmSettlement to reject');
|
||||
} catch (err) {
|
||||
const response = (err as { getResponse?: () => unknown }).getResponse?.();
|
||||
expect(response).toEqual(
|
||||
expect.objectContaining({ code: 'SETTLEMENT_BATCH_STALE' }),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('confirmSettlement throws when bet status update fails', async () => {
|
||||
const { service } = buildService({
|
||||
prisma: {
|
||||
settlementBatch: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: batchId,
|
||||
batchNo: 'STL-001',
|
||||
matchId,
|
||||
status: 'PREVIEW',
|
||||
htHomeScore: 0,
|
||||
htAwayScore: 0,
|
||||
ftHomeScore: 1,
|
||||
ftAwayScore: 0,
|
||||
match: fixtureMatch,
|
||||
}),
|
||||
},
|
||||
},
|
||||
transactionClient: {
|
||||
bet: {
|
||||
findMany: jest.fn().mockResolvedValue([pendingSingleBet]),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await service.confirmSettlement(batchId, operatorId);
|
||||
throw new Error('Expected confirmSettlement to reject');
|
||||
} catch (err) {
|
||||
const response = (err as { getResponse?: () => unknown }).getResponse?.();
|
||||
expect(response).toEqual(
|
||||
expect.objectContaining({
|
||||
code: 'SETTLEMENT_BET_UPDATE_FAILED',
|
||||
params: expect.objectContaining({ betNo: 'BET-SINGLE' }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('voidMatchBets settles cross-match parlay as lost when voided leg joins an existing losing leg', async () => {
|
||||
const parlayBet = {
|
||||
id: BigInt(5001),
|
||||
betNo: 'PARLAY-VOID',
|
||||
betType: 'PARLAY',
|
||||
status: 'PENDING',
|
||||
stake: new Decimal(100),
|
||||
agentId: null,
|
||||
userId: BigInt(80),
|
||||
selections: [
|
||||
{
|
||||
id: BigInt(6001),
|
||||
matchId,
|
||||
marketType: 'FT_1X2',
|
||||
selectionId: BigInt(501),
|
||||
selectionNameSnapshot: 'Home',
|
||||
handicapLine: null,
|
||||
totalLine: null,
|
||||
odds: new Decimal(2),
|
||||
resultStatus: null,
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
id: BigInt(6002),
|
||||
matchId: BigInt(201),
|
||||
marketType: 'FT_1X2',
|
||||
selectionId: BigInt(502),
|
||||
selectionNameSnapshot: 'Away',
|
||||
handicapLine: null,
|
||||
totalLine: null,
|
||||
odds: new Decimal(2),
|
||||
resultStatus: 'LOSE',
|
||||
sortOrder: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const betSelectionUpdate = jest.fn().mockResolvedValue({});
|
||||
const betSelectionFindMany = jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ ...parlayBet.selections[0], resultStatus: 'VOID', odds: new Decimal(2) },
|
||||
{ ...parlayBet.selections[1], resultStatus: 'LOSE', odds: new Decimal(2) },
|
||||
]);
|
||||
const betUpdateMany = jest.fn().mockResolvedValue({ count: 1 });
|
||||
const funds = { settleBet: jest.fn().mockResolvedValue(undefined), voidBet: jest.fn() };
|
||||
|
||||
const transaction = jest.fn(async (fn: (client: unknown) => Promise<unknown>) =>
|
||||
fn({
|
||||
match: { update: jest.fn().mockResolvedValue({}) },
|
||||
bet: {
|
||||
findMany: jest.fn().mockResolvedValue([parlayBet]),
|
||||
updateMany: betUpdateMany,
|
||||
},
|
||||
betSelection: {
|
||||
update: betSelectionUpdate,
|
||||
findMany: betSelectionFindMany,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const service = new SettlementService(
|
||||
{ $transaction: transaction } as never,
|
||||
funds as never,
|
||||
{ recalculateUsedCredit: jest.fn() } as never,
|
||||
);
|
||||
|
||||
const result = await service.voidMatchBets(matchId);
|
||||
|
||||
expect(result.voidedCount).toBe(1);
|
||||
expect(funds.voidBet).not.toHaveBeenCalled();
|
||||
expect(funds.settleBet).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
betNo: 'PARLAY-VOID',
|
||||
result: 'LOSE',
|
||||
batchNo: `void:${matchId}`,
|
||||
}),
|
||||
);
|
||||
expect(funds.settleBet.mock.calls[0][0].payout.toString()).toBe('0');
|
||||
});
|
||||
|
||||
it('previewResettlement treats single multi-leg tickets like parlays', async () => {
|
||||
const multiLegSingle = {
|
||||
id: BigInt(7001),
|
||||
betNo: 'SINGLE-MULTI',
|
||||
betType: 'SINGLE',
|
||||
status: 'WON',
|
||||
stake: new Decimal(100),
|
||||
actualReturn: new Decimal(360),
|
||||
agentId: null,
|
||||
userId: BigInt(90),
|
||||
selections: [
|
||||
{
|
||||
id: BigInt(8001),
|
||||
matchId,
|
||||
marketType: 'FT_1X2',
|
||||
selectionId: BigInt(501),
|
||||
selectionNameSnapshot: 'Home',
|
||||
handicapLine: null,
|
||||
totalLine: null,
|
||||
odds: new Decimal(2),
|
||||
resultStatus: 'WIN',
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
id: BigInt(8002),
|
||||
matchId,
|
||||
marketType: 'FT_1X2',
|
||||
selectionId: BigInt(502),
|
||||
selectionNameSnapshot: 'Away',
|
||||
handicapLine: null,
|
||||
totalLine: null,
|
||||
odds: new Decimal(2),
|
||||
resultStatus: 'WIN',
|
||||
sortOrder: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { service } = buildService({
|
||||
prisma: {
|
||||
match: {
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
findFirst: jest.fn().mockResolvedValue({ ...fixtureMatch, status: 'SETTLED' }),
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
bet: { findMany: jest.fn().mockResolvedValue([multiLegSingle]) },
|
||||
settlementBatch: {
|
||||
create: jest.fn().mockImplementation(({ data }) =>
|
||||
Promise.resolve({ id: batchId, ...data }),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const preview = await service.previewResettlement(
|
||||
matchId,
|
||||
{
|
||||
htHome: 0,
|
||||
htAway: 0,
|
||||
ftHome: 0,
|
||||
ftAway: 1,
|
||||
},
|
||||
operatorId,
|
||||
);
|
||||
|
||||
expect(preview.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
betNo: 'SINGLE-MULTI',
|
||||
newStatus: 'LOST',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(preview.items[0].newPayout.toString()).toBe('0');
|
||||
});
|
||||
|
||||
it('previewSettlement rejects unsupported market types on pending bets', async () => {
|
||||
const { service } = buildService({
|
||||
prisma: {
|
||||
bet: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
...pendingSingleBet,
|
||||
selections: [
|
||||
{
|
||||
...pendingSingleBet.selections[0],
|
||||
marketType: 'UNKNOWN_MARKET',
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await service.previewSettlement(matchId, operatorId, {
|
||||
htHome: 0,
|
||||
htAway: 0,
|
||||
ftHome: 1,
|
||||
ftAway: 0,
|
||||
});
|
||||
throw new Error('Expected previewSettlement to reject');
|
||||
} catch (err) {
|
||||
const response = (err as { getResponse?: () => unknown }).getResponse?.();
|
||||
expect(response).toEqual(
|
||||
expect.objectContaining({ code: 'SETTLEMENT_MARKET_UNSUPPORTED' }),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resolveSelectionCode,
|
||||
templateScoresForMarket,
|
||||
} from './domain/settlement-helpers';
|
||||
import { isSettlementSupportedMarketType } from '@thebet365/shared';
|
||||
|
||||
const SETTLEMENT_ENTRY_STATUSES = new Set(['CLOSED', 'PENDING_SETTLEMENT', 'SETTLED']);
|
||||
const STAT_MARKET_REQUIREMENTS = {
|
||||
@@ -189,6 +190,95 @@ export class SettlementService {
|
||||
}
|
||||
}
|
||||
|
||||
private assertScoreConsistency(score: ScoreInput, isOutright: boolean) {
|
||||
if (isOutright) return;
|
||||
if (
|
||||
score.htHome < 0 ||
|
||||
score.htAway < 0 ||
|
||||
score.ftHome < 0 ||
|
||||
score.ftAway < 0 ||
|
||||
score.htHome > score.ftHome ||
|
||||
score.htAway > score.ftAway
|
||||
) {
|
||||
throw appBadRequest('SETTLEMENT_SCORE_INVALID');
|
||||
}
|
||||
}
|
||||
|
||||
private assertSupportedMarketTypes(
|
||||
bets: Array<{ selections: Array<{ marketType: string }> }>,
|
||||
) {
|
||||
for (const bet of bets) {
|
||||
for (const sel of bet.selections) {
|
||||
if (!isSettlementSupportedMarketType(sel.marketType)) {
|
||||
throw appBadRequest('SETTLEMENT_MARKET_UNSUPPORTED', {
|
||||
marketType: sel.marketType,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private assertOutrightWinnerForBets(
|
||||
bets: Array<{ selections: Array<{ marketType: string }> }>,
|
||||
winnerTeamCode: string | null,
|
||||
isOutright: boolean,
|
||||
) {
|
||||
const hasOutrightLeg =
|
||||
isOutright ||
|
||||
bets.some((bet) =>
|
||||
bet.selections.some((sel) => sel.marketType === 'OUTRIGHT_WINNER'),
|
||||
);
|
||||
if (hasOutrightLeg && !winnerTeamCode) {
|
||||
throw appBadRequest('SETTLEMENT_WINNER_REQUIRED');
|
||||
}
|
||||
}
|
||||
|
||||
private requireBetUpdate(updated: { count: number }, betNo: string) {
|
||||
if (updated.count !== 1) {
|
||||
throw appBadRequest('SETTLEMENT_BET_UPDATE_FAILED', { betNo });
|
||||
}
|
||||
}
|
||||
|
||||
private async assertLatestPreviewBatch(
|
||||
batchId: bigint,
|
||||
matchId: bigint,
|
||||
tx?: TxClient,
|
||||
) {
|
||||
const client: PrismaClientLike = tx ?? this.prisma;
|
||||
const latest = await client.settlementBatch.findFirst({
|
||||
where: { matchId, status: 'PREVIEW', isResettle: false },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true },
|
||||
});
|
||||
if (latest && latest.id !== batchId) {
|
||||
throw appBadRequest('SETTLEMENT_BATCH_STALE', { batchId: batchId.toString() });
|
||||
}
|
||||
}
|
||||
|
||||
private async cancelStalePreviewBatches(matchId: bigint, tx?: TxClient) {
|
||||
const client: PrismaClientLike = tx ?? this.prisma;
|
||||
await client.settlementBatch.updateMany({
|
||||
where: { matchId, status: 'PREVIEW', isResettle: false },
|
||||
data: { status: 'CANCELLED' },
|
||||
});
|
||||
}
|
||||
|
||||
private parlayBetStatusFromResult(
|
||||
parlayResult: ReturnType<typeof calculateParlayPayout>,
|
||||
): 'LOST' | 'PUSH' | 'WON' {
|
||||
if (parlayResult.betResult === 'LOST') return 'LOST';
|
||||
if (parlayResult.betResult === 'PUSH') return 'PUSH';
|
||||
return 'WON';
|
||||
}
|
||||
|
||||
private walletResultFromParlayBetResult(
|
||||
betResult: 'WON' | 'LOST' | 'PUSH',
|
||||
): 'WIN' | 'LOSE' | 'PUSH' {
|
||||
if (betResult === 'LOST') return 'LOSE';
|
||||
if (betResult === 'PUSH') return 'PUSH';
|
||||
return 'WIN';
|
||||
}
|
||||
|
||||
async recordScore(
|
||||
matchId: bigint,
|
||||
htHome: number,
|
||||
@@ -226,6 +316,13 @@ export class SettlementService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!match.isOutright) {
|
||||
this.assertScoreConsistency(
|
||||
{ htHome, htAway, ftHome, ftAway },
|
||||
match.isOutright,
|
||||
);
|
||||
}
|
||||
|
||||
const stats = this.statsInputFromSource(statsInput);
|
||||
await this.prisma.matchScore.upsert({
|
||||
where: { matchId },
|
||||
@@ -312,7 +409,19 @@ export class SettlementService {
|
||||
}
|
||||
|
||||
const scoreSource = await this.resolvePreviewScoreSource(matchId, match.isOutright, opts);
|
||||
if (!match.isOutright) {
|
||||
this.assertScoreConsistency(
|
||||
{
|
||||
htHome: scoreSource.htHome,
|
||||
htAway: scoreSource.htAway,
|
||||
ftHome: scoreSource.ftHome,
|
||||
ftAway: scoreSource.ftAway,
|
||||
},
|
||||
match.isOutright,
|
||||
);
|
||||
}
|
||||
const computation = await this.computePreviewComputation(matchId, scoreSource);
|
||||
await this.cancelStalePreviewBatches(matchId);
|
||||
const batch = await this.prisma.settlementBatch.create({
|
||||
data: {
|
||||
matchId,
|
||||
@@ -733,6 +842,8 @@ export class SettlementService {
|
||||
statsInput,
|
||||
pendingBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
|
||||
);
|
||||
this.assertSupportedMarketTypes(pendingBets);
|
||||
this.assertOutrightWinnerForBets(pendingBets, winnerTeamCode, false);
|
||||
|
||||
let totalPayout = new Decimal(0);
|
||||
let totalRefund = new Decimal(0);
|
||||
@@ -758,6 +869,7 @@ export class SettlementService {
|
||||
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
|
||||
const payout = calculatePayout(bet.stake, sel.odds, result);
|
||||
if (result === 'WIN' || result === 'HALF_WIN') wonLegsOnMatch += 1;
|
||||
if (result === 'LOSE') lostOnThisMatch += 1;
|
||||
items.push({ betId: bet.id, betNo: bet.betNo, betType: 'SINGLE', result, payout });
|
||||
if (result === 'PUSH' || result === 'VOID') {
|
||||
totalRefund = totalRefund.add(bet.stake);
|
||||
@@ -776,6 +888,7 @@ export class SettlementService {
|
||||
};
|
||||
});
|
||||
const parlay = calculateParlayPayout(bet.stake, legResults);
|
||||
if (parlay.betResult === 'LOST') lostOnThisMatch += 1;
|
||||
items.push({
|
||||
betId: bet.id,
|
||||
betNo: bet.betNo,
|
||||
@@ -809,6 +922,7 @@ export class SettlementService {
|
||||
selectionCodes,
|
||||
);
|
||||
if (preview.kind === 'SETTLED') {
|
||||
if (preview.betResult === 'LOST') lostOnThisMatch += 1;
|
||||
items.push({
|
||||
betId: bet.id,
|
||||
betNo: bet.betNo,
|
||||
@@ -865,11 +979,18 @@ export class SettlementService {
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const legResult = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
|
||||
if (legResult === 'LOSE') {
|
||||
return { kind: 'SETTLED', betResult: 'LOST', payout: new Decimal(0) };
|
||||
}
|
||||
legResults.push({
|
||||
odds: sel.odds,
|
||||
result: this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode),
|
||||
result: legResult,
|
||||
});
|
||||
} else if (sel.resultStatus) {
|
||||
if (sel.resultStatus === 'LOSE') {
|
||||
return { kind: 'SETTLED', betResult: 'LOST', payout: new Decimal(0) };
|
||||
}
|
||||
legResults.push({
|
||||
odds: sel.odds,
|
||||
result: sel.resultStatus as SelectionResult,
|
||||
@@ -966,6 +1087,13 @@ export class SettlementService {
|
||||
pendingBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
|
||||
tx,
|
||||
);
|
||||
this.assertSupportedMarketTypes(pendingBets);
|
||||
this.assertOutrightWinnerForBets(
|
||||
pendingBets,
|
||||
winnerTeamCode,
|
||||
currentBatch.match.isOutright,
|
||||
);
|
||||
await this.assertLatestPreviewBatch(batchId, currentBatch.matchId, tx);
|
||||
let settledCount = 0;
|
||||
|
||||
await this.upsertMatchScoreRecord(
|
||||
@@ -1001,7 +1129,7 @@ export class SettlementService {
|
||||
settledAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (updatedBet.count !== 1) continue;
|
||||
this.requireBetUpdate(updatedBet, bet.betNo);
|
||||
|
||||
await tx.betSelection.update({
|
||||
where: { id: sel.id },
|
||||
@@ -1045,12 +1173,7 @@ export class SettlementService {
|
||||
});
|
||||
}
|
||||
const parlayResult = calculateParlayPayout(bet.stake, legResults);
|
||||
const betStatus =
|
||||
parlayResult.betResult === 'LOST'
|
||||
? 'LOST'
|
||||
: parlayResult.betResult === 'PUSH'
|
||||
? 'PUSH'
|
||||
: 'WON';
|
||||
const betStatus = this.parlayBetStatusFromResult(parlayResult);
|
||||
|
||||
const updatedBet = await tx.bet.updateMany({
|
||||
where: { id: bet.id, status: 'PENDING' },
|
||||
@@ -1060,7 +1183,7 @@ export class SettlementService {
|
||||
settledAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (updatedBet.count !== 1) continue;
|
||||
this.requireBetUpdate(updatedBet, bet.betNo);
|
||||
|
||||
await this.funds.settleBet({
|
||||
userId: bet.userId,
|
||||
@@ -1068,12 +1191,7 @@ export class SettlementService {
|
||||
payout: parlayResult.payout,
|
||||
betNo: bet.betNo,
|
||||
batchNo: batch.batchNo,
|
||||
result:
|
||||
parlayResult.betResult === 'LOST'
|
||||
? 'LOSE'
|
||||
: parlayResult.betResult === 'PUSH'
|
||||
? 'PUSH'
|
||||
: 'WIN',
|
||||
result: this.walletResultFromParlayBetResult(parlayResult.betResult),
|
||||
tx,
|
||||
});
|
||||
|
||||
@@ -1116,12 +1234,7 @@ export class SettlementService {
|
||||
result: s.resultStatus as SelectionResult,
|
||||
}));
|
||||
const parlayResult = calculateParlayPayout(bet.stake, legResults);
|
||||
const betStatus =
|
||||
parlayResult.betResult === 'LOST'
|
||||
? 'LOST'
|
||||
: parlayResult.betResult === 'PUSH'
|
||||
? 'PUSH'
|
||||
: 'WON';
|
||||
const betStatus = this.parlayBetStatusFromResult(parlayResult);
|
||||
|
||||
const updatedBet = await tx.bet.updateMany({
|
||||
where: { id: bet.id, status: 'PENDING' },
|
||||
@@ -1131,7 +1244,7 @@ export class SettlementService {
|
||||
settledAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (updatedBet.count !== 1) continue;
|
||||
this.requireBetUpdate(updatedBet, bet.betNo);
|
||||
|
||||
await this.funds.settleBet({
|
||||
userId: bet.userId,
|
||||
@@ -1139,12 +1252,7 @@ export class SettlementService {
|
||||
payout: parlayResult.payout,
|
||||
betNo: bet.betNo,
|
||||
batchNo: batch.batchNo,
|
||||
result:
|
||||
parlayResult.betResult === 'LOST'
|
||||
? 'LOSE'
|
||||
: parlayResult.betResult === 'PUSH'
|
||||
? 'PUSH'
|
||||
: 'WIN',
|
||||
result: this.walletResultFromParlayBetResult(parlayResult.betResult),
|
||||
tx,
|
||||
});
|
||||
|
||||
@@ -1386,7 +1494,7 @@ export class SettlementService {
|
||||
winnerTeamCode: string | null,
|
||||
selectionCodes: Map<string, string | null>,
|
||||
) {
|
||||
if (bet.betType === 'SINGLE') {
|
||||
if (bet.betType === 'SINGLE' && bet.selections.length === 1) {
|
||||
const sel = bet.selections[0];
|
||||
const code = resolveSelectionCode(
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
@@ -1401,6 +1509,26 @@ export class SettlementService {
|
||||
};
|
||||
}
|
||||
|
||||
if (bet.betType === 'SINGLE' && bet.selections.length > 1) {
|
||||
const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
|
||||
const legUpdates = new Map<string, SelectionResult>();
|
||||
for (const sel of bet.selections) {
|
||||
const code = resolveSelectionCode(
|
||||
selectionCodes.get(sel.selectionId.toString()),
|
||||
sel.selectionNameSnapshot,
|
||||
);
|
||||
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
|
||||
legResults.push({ odds: sel.odds, result });
|
||||
legUpdates.set(sel.id.toString(), result);
|
||||
}
|
||||
const parlayResult = calculateParlayPayout(bet.stake, legResults);
|
||||
return {
|
||||
payout: parlayResult.payout,
|
||||
betStatus: this.parlayBetStatusFromResult(parlayResult),
|
||||
legUpdates,
|
||||
};
|
||||
}
|
||||
|
||||
const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
|
||||
const legUpdates = new Map<string, SelectionResult>();
|
||||
|
||||
@@ -1423,14 +1551,11 @@ export class SettlementService {
|
||||
}
|
||||
|
||||
const parlayResult = calculateParlayPayout(bet.stake, legResults);
|
||||
const betStatus =
|
||||
parlayResult.betResult === 'LOST'
|
||||
? 'LOST'
|
||||
: parlayResult.betResult === 'PUSH'
|
||||
? 'PUSH'
|
||||
: 'WON';
|
||||
|
||||
return { payout: parlayResult.payout, betStatus, legUpdates };
|
||||
return {
|
||||
payout: parlayResult.payout,
|
||||
betStatus: this.parlayBetStatusFromResult(parlayResult),
|
||||
legUpdates,
|
||||
};
|
||||
}
|
||||
|
||||
async previewResettlement(
|
||||
@@ -1448,6 +1573,18 @@ export class SettlementService {
|
||||
throw appBadRequest('RESETTLE_SETTLED_ONLY');
|
||||
}
|
||||
|
||||
if (!match.isOutright) {
|
||||
this.assertScoreConsistency(
|
||||
{
|
||||
htHome: scoreInput.htHome,
|
||||
htAway: scoreInput.htAway,
|
||||
ftHome: scoreInput.ftHome,
|
||||
ftAway: scoreInput.ftAway,
|
||||
},
|
||||
match.isOutright,
|
||||
);
|
||||
}
|
||||
|
||||
const winnerTeamCode = winnerTeamId
|
||||
? await this.resolveWinnerTeamCode(winnerTeamId)
|
||||
: null;
|
||||
@@ -1467,6 +1604,8 @@ export class SettlementService {
|
||||
statsInput,
|
||||
settledBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
|
||||
);
|
||||
this.assertSupportedMarketTypes(settledBets);
|
||||
this.assertOutrightWinnerForBets(settledBets, winnerTeamCode, match.isOutright);
|
||||
const items: Array<{
|
||||
betId: bigint;
|
||||
betNo: string;
|
||||
@@ -1612,6 +1751,12 @@ export class SettlementService {
|
||||
settledBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
|
||||
tx,
|
||||
);
|
||||
this.assertSupportedMarketTypes(settledBets);
|
||||
this.assertOutrightWinnerForBets(
|
||||
settledBets,
|
||||
winnerTeamCode,
|
||||
currentBatch.match.isOutright,
|
||||
);
|
||||
let affectedCount = 0;
|
||||
|
||||
await this.upsertMatchScoreRecord(
|
||||
@@ -1715,6 +1860,7 @@ export class SettlementService {
|
||||
options: { cancelMatch?: boolean } = {},
|
||||
) {
|
||||
const agentIds = new Set<bigint>();
|
||||
const voidBatchNo = `void:${matchId}`;
|
||||
|
||||
const voidedCount = await this.prisma.$transaction(async (tx) => {
|
||||
if (options.cancelMatch) {
|
||||
@@ -1726,21 +1872,82 @@ export class SettlementService {
|
||||
|
||||
const bets = await tx.bet.findMany({
|
||||
where: { status: 'PENDING', selections: { some: { matchId } } },
|
||||
include: { selections: { orderBy: { sortOrder: 'asc' } } },
|
||||
});
|
||||
|
||||
let count = 0;
|
||||
for (const bet of bets) {
|
||||
const updated = await tx.bet.updateMany({
|
||||
where: { id: bet.id, status: 'PENDING' },
|
||||
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
|
||||
});
|
||||
if (updated.count !== 1) continue;
|
||||
const isSingleOneLeg = bet.betType === 'SINGLE' && bet.selections.length === 1;
|
||||
|
||||
await this.funds.voidBet({
|
||||
if (isSingleOneLeg) {
|
||||
const sel = bet.selections[0];
|
||||
const updated = await tx.bet.updateMany({
|
||||
where: { id: bet.id, status: 'PENDING' },
|
||||
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
|
||||
});
|
||||
if (updated.count !== 1) continue;
|
||||
|
||||
await tx.betSelection.update({
|
||||
where: { id: sel.id },
|
||||
data: { resultStatus: 'VOID' },
|
||||
});
|
||||
|
||||
await this.funds.voidBet({
|
||||
userId: bet.userId,
|
||||
stake: bet.stake,
|
||||
betNo: bet.betNo,
|
||||
businessKey: `void:${matchId}:${bet.betNo}`,
|
||||
tx,
|
||||
});
|
||||
if (bet.agentId) agentIds.add(bet.agentId);
|
||||
count += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const legsOnMatch = bet.selections.filter(
|
||||
(sel) => sel.matchId?.toString() === matchId.toString(),
|
||||
);
|
||||
if (!legsOnMatch.length) continue;
|
||||
|
||||
for (const sel of legsOnMatch) {
|
||||
await tx.betSelection.update({
|
||||
where: { id: sel.id },
|
||||
data: { resultStatus: 'VOID' },
|
||||
});
|
||||
}
|
||||
|
||||
const updatedLegs = await tx.betSelection.findMany({
|
||||
where: { betId: bet.id },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
if (!updatedLegs.every((sel) => sel.resultStatus != null)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const legResults = updatedLegs.map((sel) => ({
|
||||
odds: sel.odds,
|
||||
result: sel.resultStatus as SelectionResult,
|
||||
}));
|
||||
const parlayResult = calculateParlayPayout(bet.stake, legResults);
|
||||
const betStatus = this.parlayBetStatusFromResult(parlayResult);
|
||||
|
||||
const updatedBet = await tx.bet.updateMany({
|
||||
where: { id: bet.id, status: 'PENDING' },
|
||||
data: {
|
||||
status: betStatus,
|
||||
actualReturn: parlayResult.payout,
|
||||
settledAt: new Date(),
|
||||
},
|
||||
});
|
||||
if (updatedBet.count !== 1) continue;
|
||||
|
||||
await this.funds.settleBet({
|
||||
userId: bet.userId,
|
||||
stake: bet.stake,
|
||||
payout: parlayResult.payout,
|
||||
betNo: bet.betNo,
|
||||
businessKey: `void:${matchId}:${bet.betNo}`,
|
||||
batchNo: voidBatchNo,
|
||||
result: this.walletResultFromParlayBetResult(parlayResult.betResult),
|
||||
tx,
|
||||
});
|
||||
if (bet.agentId) agentIds.add(bet.agentId);
|
||||
|
||||
@@ -19,6 +19,10 @@ export type InboxNotifySettings = {
|
||||
inboxEnabled: boolean;
|
||||
/** 充值审核通过/拒绝时发送站内信 */
|
||||
deposit: boolean;
|
||||
/** 发布 Banner 内容时发送站内信推广 */
|
||||
banner: boolean;
|
||||
/** 发布公告/滚动条内容时发送站内信推广 */
|
||||
announcement: boolean;
|
||||
};
|
||||
|
||||
export type PlatformDirectCashbackSettings = {
|
||||
@@ -41,9 +45,9 @@ export type PlayerAccountSettings = {
|
||||
};
|
||||
|
||||
export type AgentSuspendSettings = {
|
||||
/** 停用代理时是否允许级联冻结其直属玩家(需管理员显式勾选) */
|
||||
/** 停用代理时默认级联冻结直属玩家(单次操作仍可覆盖) */
|
||||
suspendFreezeDirectPlayers: boolean;
|
||||
/** 上级代理停用时是否禁止其直属玩家登录 */
|
||||
/** 停用代理时默认禁止直属玩家登录(单次操作仍可覆盖) */
|
||||
suspendBlockPlayerLogin: boolean;
|
||||
};
|
||||
|
||||
@@ -239,11 +243,13 @@ export class SystemConfigService {
|
||||
}
|
||||
|
||||
async getInboxNotifySettings(): Promise<InboxNotifySettings> {
|
||||
const [inboxEnabled, deposit] = await Promise.all([
|
||||
const [inboxEnabled, deposit, banner, announcement] = await Promise.all([
|
||||
this.getBoolean(INBOX_FEATURE_ENABLED, true),
|
||||
this.getBoolean(INBOX_NOTIFY_DEPOSIT, true),
|
||||
this.getBoolean(INBOX_NOTIFY_BANNER, true),
|
||||
this.getBoolean(INBOX_NOTIFY_ANNOUNCEMENT, true),
|
||||
]);
|
||||
return { inboxEnabled, deposit };
|
||||
return { inboxEnabled, deposit, banner, announcement };
|
||||
}
|
||||
|
||||
async updateInboxNotifySettings(data: Partial<InboxNotifySettings>) {
|
||||
@@ -261,6 +267,20 @@ export class SystemConfigService {
|
||||
'充值审核结果是否通过站内邮箱通知玩家',
|
||||
);
|
||||
}
|
||||
if (data.banner !== undefined) {
|
||||
await this.setBoolean(
|
||||
INBOX_NOTIFY_BANNER,
|
||||
data.banner,
|
||||
'发布 Banner 内容时是否发送站内信推广',
|
||||
);
|
||||
}
|
||||
if (data.announcement !== undefined) {
|
||||
await this.setBoolean(
|
||||
INBOX_NOTIFY_ANNOUNCEMENT,
|
||||
data.announcement,
|
||||
'发布公告/滚动条内容时是否发送站内信推广',
|
||||
);
|
||||
}
|
||||
return this.getInboxNotifySettings();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user