重构 API 为 8 领域 + 应用层架构
将后端模块拆分为 domains、applications、shared 三层,结算计算器移入 domain 纯函数目录,API 路径与测试保持不变。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
312
apps/api/src/domains/settlement/settlement.service.ts
Normal file
312
apps/api/src/domains/settlement/settlement.service.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||||
import { WalletService } from '../ledger/wallet.service';
|
||||
import { AgentsService } from '../agent/agents.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { generateBatchNo } from '../../shared/common/decorators';
|
||||
import {
|
||||
settleSelection,
|
||||
calculatePayout,
|
||||
calculateParlayPayout,
|
||||
ScoreInput,
|
||||
FT_CORRECT_SCORE_TEMPLATE,
|
||||
HT_CORRECT_SCORE_TEMPLATE,
|
||||
} from './domain/settlement-calculator';
|
||||
|
||||
@Injectable()
|
||||
export class SettlementService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
private agents: AgentsService,
|
||||
) {}
|
||||
|
||||
async recordScore(
|
||||
matchId: bigint,
|
||||
htHome: number,
|
||||
htAway: number,
|
||||
ftHome: number,
|
||||
ftAway: number,
|
||||
operatorId: bigint,
|
||||
) {
|
||||
await this.prisma.matchScore.upsert({
|
||||
where: { matchId },
|
||||
create: { matchId, htHomeScore: htHome, htAwayScore: htAway, ftHomeScore: ftHome, ftAwayScore: ftAway, recordedBy: operatorId },
|
||||
update: { htHomeScore: htHome, htAwayScore: htAway, ftHomeScore: ftHome, ftAwayScore: ftAway, recordedBy: operatorId },
|
||||
});
|
||||
|
||||
await this.prisma.match.update({
|
||||
where: { id: matchId },
|
||||
data: { status: 'PENDING_SETTLEMENT' },
|
||||
});
|
||||
|
||||
return { matchId, htHome, htAway, ftHome, ftAway };
|
||||
}
|
||||
|
||||
async previewSettlement(matchId: bigint, operatorId: bigint) {
|
||||
const score = await this.prisma.matchScore.findUnique({ where: { matchId } });
|
||||
if (!score) throw new BadRequestException('Score not recorded');
|
||||
|
||||
const scoreInput: ScoreInput = {
|
||||
htHome: score.htHomeScore ?? 0,
|
||||
htAway: score.htAwayScore ?? 0,
|
||||
ftHome: score.ftHomeScore ?? 0,
|
||||
ftAway: score.ftAwayScore ?? 0,
|
||||
};
|
||||
|
||||
const pendingBets = await this.prisma.bet.findMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
selections: { some: { matchId } },
|
||||
},
|
||||
include: { selections: true },
|
||||
});
|
||||
|
||||
const parlayBets = await this.prisma.bet.findMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
betType: 'PARLAY',
|
||||
selections: { some: { matchId } },
|
||||
},
|
||||
include: { selections: true },
|
||||
});
|
||||
|
||||
let totalPayout = new Decimal(0);
|
||||
let totalRefund = new Decimal(0);
|
||||
const items: Array<{ betId: bigint; betNo: string; result: string; payout: Decimal }> = [];
|
||||
|
||||
for (const bet of pendingBets) {
|
||||
if (bet.betType === 'SINGLE') {
|
||||
const sel = bet.selections[0];
|
||||
const template =
|
||||
sel.marketType === 'FT_CORRECT_SCORE'
|
||||
? FT_CORRECT_SCORE_TEMPLATE
|
||||
: sel.marketType.includes('CORRECT_SCORE')
|
||||
? HT_CORRECT_SCORE_TEMPLATE
|
||||
: [];
|
||||
|
||||
const result = settleSelection({
|
||||
marketType: sel.marketType,
|
||||
selectionCode: sel.selectionNameSnapshot.includes('-')
|
||||
? `SCORE_${sel.selectionNameSnapshot.replace('-', '_')}`
|
||||
: sel.selectionNameSnapshot,
|
||||
handicapLine: sel.handicapLine ? Number(sel.handicapLine) : null,
|
||||
totalLine: sel.totalLine ? Number(sel.totalLine) : null,
|
||||
score: scoreInput,
|
||||
templateScores: template,
|
||||
});
|
||||
|
||||
const payout = calculatePayout(bet.stake, sel.odds, result);
|
||||
items.push({ betId: bet.id, betNo: bet.betNo, result, payout });
|
||||
|
||||
if (result === 'LOSE') {
|
||||
// no payout
|
||||
} else if (result === 'PUSH' || result === 'VOID') {
|
||||
totalRefund = totalRefund.add(bet.stake);
|
||||
} else {
|
||||
totalPayout = totalPayout.add(payout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const batch = await this.prisma.settlementBatch.create({
|
||||
data: {
|
||||
matchId,
|
||||
batchNo: generateBatchNo('STL'),
|
||||
htHomeScore: score.htHomeScore,
|
||||
htAwayScore: score.htAwayScore,
|
||||
ftHomeScore: score.ftHomeScore,
|
||||
ftAwayScore: score.ftAwayScore,
|
||||
status: 'PREVIEW',
|
||||
totalBets: pendingBets.length,
|
||||
totalPayout,
|
||||
totalRefund,
|
||||
operatorId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
batch,
|
||||
score: scoreInput,
|
||||
singleBetCount: pendingBets.filter((b) => b.betType === 'SINGLE').length,
|
||||
parlayBetCount: parlayBets.length,
|
||||
items,
|
||||
totalPayout,
|
||||
totalRefund,
|
||||
};
|
||||
}
|
||||
|
||||
async confirmSettlement(batchId: bigint, operatorId: bigint) {
|
||||
const batch = await this.prisma.settlementBatch.findUnique({
|
||||
where: { id: batchId },
|
||||
include: { match: true },
|
||||
});
|
||||
if (!batch) throw new NotFoundException('Batch not found');
|
||||
if (batch.status !== 'PREVIEW') throw new BadRequestException('Batch already confirmed');
|
||||
|
||||
const score = await this.prisma.matchScore.findUnique({
|
||||
where: { matchId: batch.matchId },
|
||||
});
|
||||
if (!score) throw new BadRequestException('Score not found');
|
||||
|
||||
const scoreInput: ScoreInput = {
|
||||
htHome: score.htHomeScore ?? 0,
|
||||
htAway: score.htAwayScore ?? 0,
|
||||
ftHome: score.ftHomeScore ?? 0,
|
||||
ftAway: score.ftAwayScore ?? 0,
|
||||
};
|
||||
|
||||
const pendingBets = await this.prisma.bet.findMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
selections: { some: { matchId: batch.matchId } },
|
||||
},
|
||||
include: { selections: true, user: true },
|
||||
});
|
||||
|
||||
const agentIds = new Set<bigint>();
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const bet of pendingBets) {
|
||||
if (bet.betType === 'SINGLE') {
|
||||
const sel = bet.selections[0];
|
||||
const selection = await tx.marketSelection.findUnique({
|
||||
where: { id: sel.selectionId },
|
||||
});
|
||||
|
||||
const result = settleSelection({
|
||||
marketType: sel.marketType,
|
||||
selectionCode: selection?.selectionCode ?? sel.selectionNameSnapshot,
|
||||
handicapLine: sel.handicapLine ? Number(sel.handicapLine) : null,
|
||||
totalLine: sel.totalLine ? Number(sel.totalLine) : null,
|
||||
score: scoreInput,
|
||||
templateScores:
|
||||
sel.marketType === 'FT_CORRECT_SCORE'
|
||||
? FT_CORRECT_SCORE_TEMPLATE
|
||||
: HT_CORRECT_SCORE_TEMPLATE,
|
||||
});
|
||||
|
||||
const payout = calculatePayout(bet.stake, sel.odds, result);
|
||||
const betStatus =
|
||||
result === 'LOSE' ? 'LOST' : result === 'PUSH' || result === 'VOID' ? 'PUSH' : 'WON';
|
||||
|
||||
await tx.bet.update({
|
||||
where: { id: bet.id },
|
||||
data: {
|
||||
status: betStatus,
|
||||
actualReturn: payout,
|
||||
settledAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.betSelection.update({
|
||||
where: { id: sel.id },
|
||||
data: { resultStatus: result, effectiveOdds: sel.odds },
|
||||
});
|
||||
|
||||
await this.wallet.settleBet(
|
||||
bet.userId,
|
||||
bet.stake,
|
||||
payout,
|
||||
bet.betNo,
|
||||
result === 'HALF_WIN' ? 'HALF_WIN' : result === 'HALF_LOSE' ? 'HALF_LOSE' : result as 'WIN' | 'LOSE' | 'PUSH' | 'VOID',
|
||||
);
|
||||
|
||||
if (bet.agentId) agentIds.add(bet.agentId);
|
||||
|
||||
await tx.settlementItem.create({
|
||||
data: {
|
||||
batchId,
|
||||
betId: bet.id,
|
||||
userId: bet.userId,
|
||||
result: betStatus,
|
||||
payout,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Parlay: update this leg's result, check if all legs settled
|
||||
for (const sel of bet.selections) {
|
||||
if (sel.matchId?.toString() === batch.matchId.toString()) {
|
||||
const selection = await tx.marketSelection.findUnique({
|
||||
where: { id: sel.selectionId },
|
||||
});
|
||||
const result = settleSelection({
|
||||
marketType: sel.marketType,
|
||||
selectionCode: selection?.selectionCode ?? '',
|
||||
handicapLine: sel.handicapLine ? Number(sel.handicapLine) : null,
|
||||
totalLine: sel.totalLine ? Number(sel.totalLine) : null,
|
||||
score: scoreInput,
|
||||
});
|
||||
await tx.betSelection.update({
|
||||
where: { id: sel.id },
|
||||
data: { resultStatus: result },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await tx.betSelection.findMany({ where: { betId: bet.id } });
|
||||
const allHaveResult = updated.every((s) => s.resultStatus != null);
|
||||
|
||||
if (allHaveResult) {
|
||||
const legResults = updated.map((s) => ({
|
||||
odds: s.odds,
|
||||
result: s.resultStatus as 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE',
|
||||
}));
|
||||
const parlayResult = calculateParlayPayout(bet.stake, legResults);
|
||||
|
||||
await tx.bet.update({
|
||||
where: { id: bet.id },
|
||||
data: {
|
||||
status: parlayResult.betResult === 'LOST' ? 'LOST' : parlayResult.betResult === 'PUSH' ? 'PUSH' : 'WON',
|
||||
actualReturn: parlayResult.payout,
|
||||
settledAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await this.wallet.settleBet(
|
||||
bet.userId,
|
||||
bet.stake,
|
||||
parlayResult.payout,
|
||||
bet.betNo,
|
||||
parlayResult.betResult === 'LOST' ? 'LOSE' : parlayResult.betResult === 'PUSH' ? 'PUSH' : 'WIN',
|
||||
);
|
||||
|
||||
if (bet.agentId) agentIds.add(bet.agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await tx.settlementBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date() },
|
||||
});
|
||||
|
||||
await tx.match.update({
|
||||
where: { id: batch.matchId },
|
||||
data: { status: 'SETTLED' },
|
||||
});
|
||||
});
|
||||
|
||||
for (const agentId of agentIds) {
|
||||
await this.agents.recalculateUsedCredit(agentId);
|
||||
}
|
||||
|
||||
return { success: true, batchId: batchId.toString() };
|
||||
}
|
||||
|
||||
async voidMatchBets(matchId: bigint) {
|
||||
const bets = await this.prisma.bet.findMany({
|
||||
where: { status: 'PENDING', selections: { some: { matchId } } },
|
||||
});
|
||||
|
||||
for (const bet of bets) {
|
||||
await this.wallet.settleBet(bet.userId, bet.stake, bet.stake, bet.betNo, 'VOID');
|
||||
await this.prisma.bet.update({
|
||||
where: { id: bet.id },
|
||||
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
return { voidedCount: bets.length };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user