feat(admin+api): 代理停用默认、结算加固与冒烟配置探针
- 代理层级默认授信比例与停用冻结/禁登全局默认 - 结算预览去重、比分校验、串关当场判负与市场类型校验 - 站内信 Banner/公告自动通知开关;开发环境动态 API 端口 - 扩充 RBAC/结算/认证/返现单元测试与 agent skills
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user