初始化足球投注平台 MVP Monorepo
包含 NestJS 后端、三端前端、Prisma 数据模型、结算引擎测试与 PRD 文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
14
apps/api/jest.config.js
Normal file
14
apps/api/jest.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
rootDir: 'src',
|
||||
testRegex: '.*\\.spec\\.ts$',
|
||||
transform: {
|
||||
'^.+\\.(t|j)s$': 'ts-jest',
|
||||
},
|
||||
collectCoverageFrom: ['**/*.(t|j)s'],
|
||||
coverageDirectory: '../coverage',
|
||||
testEnvironment: 'node',
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/$1',
|
||||
},
|
||||
};
|
||||
8
apps/api/nest-cli.json
Normal file
8
apps/api/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
59
apps/api/package.json
Normal file
59
apps/api/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@thebet365/api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:migrate:deploy": "prisma migrate deploy",
|
||||
"db:seed": "ts-node prisma/seed.ts",
|
||||
"db:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.6",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.6",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.0.6",
|
||||
"@nestjs/schedule": "^5.0.1",
|
||||
"@nestjs/swagger": "^11.0.3",
|
||||
"@prisma/client": "^6.3.1",
|
||||
"@thebet365/shared": "workspace:*",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"decimal.js": "^10.4.3",
|
||||
"ioredis": "^5.4.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"uuid": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.2",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.6",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"prisma": "^6.3.1",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node prisma/seed.ts"
|
||||
}
|
||||
}
|
||||
5
apps/api/prisma/migrations/README.md
Normal file
5
apps/api/prisma/migrations/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Initial migration for TheBet365 MVP
|
||||
-- Run: pnpm --filter @thebet365/api db:migrate
|
||||
|
||||
-- Generated from prisma/schema.prisma
|
||||
-- Use `pnpm db:migrate` when PostgreSQL is available
|
||||
585
apps/api/prisma/schema.prisma
Normal file
585
apps/api/prisma/schema.prisma
Normal file
@@ -0,0 +1,585 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ============ Users & Auth ============
|
||||
|
||||
model User {
|
||||
id BigInt @id @default(autoincrement())
|
||||
username String @unique @db.VarChar(64)
|
||||
userType String @map("user_type") @db.VarChar(20)
|
||||
status String @default("ACTIVE") @db.VarChar(20)
|
||||
parentId BigInt? @map("parent_id")
|
||||
agentLevel Int? @map("agent_level")
|
||||
locale String @default("en-US") @db.VarChar(10)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
auth UserAuth?
|
||||
wallet Wallet?
|
||||
agentProfile AgentProfile?
|
||||
adminRole AdminUserRole?
|
||||
bets Bet[]
|
||||
preferences UserPreference?
|
||||
|
||||
parent User? @relation("UserHierarchy", fields: [parentId], references: [id])
|
||||
children User[] @relation("UserHierarchy")
|
||||
|
||||
@@index([userType])
|
||||
@@index([parentId])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model UserAuth {
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt @unique @map("user_id")
|
||||
passwordHash String @map("password_hash") @db.VarChar(255)
|
||||
loginFailCount Int @default(0) @map("login_fail_count")
|
||||
lockedUntil DateTime? @map("locked_until")
|
||||
lastLoginAt DateTime? @map("last_login_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@map("user_auth")
|
||||
}
|
||||
|
||||
model UserPreference {
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt @unique @map("user_id")
|
||||
locale String @default("en-US") @db.VarChar(10)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@map("user_preferences")
|
||||
}
|
||||
|
||||
model Role {
|
||||
id BigInt @id @default(autoincrement())
|
||||
code String @unique @db.VarChar(64)
|
||||
name String @db.VarChar(128)
|
||||
description String? @db.VarChar(255)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
permissions RolePermission[]
|
||||
adminUsers AdminUserRole[]
|
||||
|
||||
@@map("roles")
|
||||
}
|
||||
|
||||
model Permission {
|
||||
id BigInt @id @default(autoincrement())
|
||||
code String @unique @db.VarChar(128)
|
||||
name String @db.VarChar(128)
|
||||
module String @db.VarChar(64)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
roles RolePermission[]
|
||||
|
||||
@@map("permissions")
|
||||
}
|
||||
|
||||
model RolePermission {
|
||||
roleId BigInt @map("role_id")
|
||||
permissionId BigInt @map("permission_id")
|
||||
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
permission Permission @relation(fields: [permissionId], references: [id])
|
||||
|
||||
@@id([roleId, permissionId])
|
||||
@@map("role_permissions")
|
||||
}
|
||||
|
||||
model AdminUserRole {
|
||||
userId BigInt @unique @map("user_id")
|
||||
roleId BigInt @map("role_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
|
||||
@@map("admin_user_roles")
|
||||
}
|
||||
|
||||
// ============ Agent ============
|
||||
|
||||
model AgentProfile {
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt @unique @map("user_id")
|
||||
level Int
|
||||
parentAgentId BigInt? @map("parent_agent_id")
|
||||
creditLimit Decimal @default(0) @map("credit_limit") @db.Decimal(18, 4)
|
||||
usedCredit Decimal @default(0) @map("used_credit") @db.Decimal(18, 4)
|
||||
directPlayerLiability Decimal @default(0) @map("direct_player_liability") @db.Decimal(18, 4)
|
||||
childAgentExposure Decimal @default(0) @map("child_agent_exposure") @db.Decimal(18, 4)
|
||||
status String @default("ACTIVE") @db.VarChar(20)
|
||||
maxSingleDeposit Decimal? @map("max_single_deposit") @db.Decimal(18, 4)
|
||||
maxDailyDeposit Decimal? @map("max_daily_deposit") @db.Decimal(18, 4)
|
||||
cashbackRate Decimal @default(0) @map("cashback_rate") @db.Decimal(8, 4)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@index([parentAgentId])
|
||||
@@map("agent_profiles")
|
||||
}
|
||||
|
||||
model AgentClosure {
|
||||
ancestorId BigInt @map("ancestor_id")
|
||||
descendantId BigInt @map("descendant_id")
|
||||
depth Int
|
||||
|
||||
@@id([ancestorId, descendantId])
|
||||
@@index([descendantId])
|
||||
@@map("agent_closure")
|
||||
}
|
||||
|
||||
model AgentCreditTransaction {
|
||||
id BigInt @id @default(autoincrement())
|
||||
agentId BigInt @map("agent_id")
|
||||
transactionType String @map("transaction_type") @db.VarChar(32)
|
||||
amount Decimal @db.Decimal(18, 4)
|
||||
creditBefore Decimal @map("credit_before") @db.Decimal(18, 4)
|
||||
creditAfter Decimal @map("credit_after") @db.Decimal(18, 4)
|
||||
referenceType String? @map("reference_type") @db.VarChar(32)
|
||||
referenceId String? @map("reference_id") @db.VarChar(64)
|
||||
operatorId BigInt? @map("operator_id")
|
||||
requestId String? @map("request_id") @db.VarChar(128)
|
||||
remark String? @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([operatorId, requestId])
|
||||
@@index([agentId])
|
||||
@@map("agent_credit_transactions")
|
||||
}
|
||||
|
||||
// ============ Wallet ============
|
||||
|
||||
model Wallet {
|
||||
id BigInt @id @default(autoincrement())
|
||||
userId BigInt @unique @map("user_id")
|
||||
availableBalance Decimal @default(0) @map("available_balance") @db.Decimal(18, 4)
|
||||
frozenBalance Decimal @default(0) @map("frozen_balance") @db.Decimal(18, 4)
|
||||
currency String @default("USD") @db.VarChar(16)
|
||||
status String @default("ACTIVE") @db.VarChar(20)
|
||||
version Int @default(0)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
transactions WalletTransaction[]
|
||||
|
||||
@@map("wallets")
|
||||
}
|
||||
|
||||
model WalletTransaction {
|
||||
id BigInt @id @default(autoincrement())
|
||||
transactionId String @unique @map("transaction_id") @db.VarChar(64)
|
||||
userId BigInt @map("user_id")
|
||||
walletId BigInt @map("wallet_id")
|
||||
transactionType String @map("transaction_type") @db.VarChar(32)
|
||||
amount Decimal @db.Decimal(18, 4)
|
||||
balanceBefore Decimal @map("balance_before") @db.Decimal(18, 4)
|
||||
balanceAfter Decimal @map("balance_after") @db.Decimal(18, 4)
|
||||
frozenBefore Decimal @map("frozen_before") @db.Decimal(18, 4)
|
||||
frozenAfter Decimal @map("frozen_after") @db.Decimal(18, 4)
|
||||
referenceType String? @map("reference_type") @db.VarChar(32)
|
||||
referenceId String? @map("reference_id") @db.VarChar(64)
|
||||
operatorId BigInt? @map("operator_id")
|
||||
remark String? @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
wallet Wallet @relation(fields: [walletId], references: [id])
|
||||
|
||||
@@index([userId])
|
||||
@@index([walletId])
|
||||
@@index([createdAt])
|
||||
@@map("wallet_transactions")
|
||||
}
|
||||
|
||||
// ============ Sports Data ============
|
||||
|
||||
model League {
|
||||
id BigInt @id @default(autoincrement())
|
||||
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
|
||||
code String @unique @db.VarChar(64)
|
||||
displayOrder Int @default(0) @map("display_order")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
matches Match[]
|
||||
|
||||
@@map("leagues")
|
||||
}
|
||||
|
||||
model Team {
|
||||
id BigInt @id @default(autoincrement())
|
||||
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
|
||||
code String @unique @db.VarChar(64)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
homeMatches Match[] @relation("HomeTeam")
|
||||
awayMatches Match[] @relation("AwayTeam")
|
||||
|
||||
@@map("teams")
|
||||
}
|
||||
|
||||
model EntityTranslation {
|
||||
id BigInt @id @default(autoincrement())
|
||||
entityType String @map("entity_type") @db.VarChar(32)
|
||||
entityId BigInt @map("entity_id")
|
||||
locale String @db.VarChar(10)
|
||||
fieldName String @map("field_name") @db.VarChar(32)
|
||||
value String @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([entityType, entityId, locale, fieldName])
|
||||
@@index([entityType, entityId])
|
||||
@@map("entity_translations")
|
||||
}
|
||||
|
||||
model Match {
|
||||
id BigInt @id @default(autoincrement())
|
||||
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
|
||||
leagueId BigInt @map("league_id")
|
||||
homeTeamId BigInt @map("home_team_id")
|
||||
awayTeamId BigInt @map("away_team_id")
|
||||
startTime DateTime @map("start_time")
|
||||
status String @default("DRAFT") @db.VarChar(32)
|
||||
isHot Boolean @default(false) @map("is_hot")
|
||||
displayOrder Int @default(0) @map("display_order")
|
||||
publishTime DateTime? @map("publish_time")
|
||||
closeTime DateTime? @map("close_time")
|
||||
isOutright Boolean @default(false) @map("is_outright")
|
||||
createdBy BigInt? @map("created_by")
|
||||
updatedBy BigInt? @map("updated_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
league League @relation(fields: [leagueId], references: [id])
|
||||
homeTeam Team @relation("HomeTeam", fields: [homeTeamId], references: [id])
|
||||
awayTeam Team @relation("AwayTeam", fields: [awayTeamId], references: [id])
|
||||
score MatchScore?
|
||||
markets Market[]
|
||||
settlements SettlementBatch[]
|
||||
|
||||
@@index([status])
|
||||
@@index([startTime])
|
||||
@@index([leagueId])
|
||||
@@map("matches")
|
||||
}
|
||||
|
||||
model MatchScore {
|
||||
id BigInt @id @default(autoincrement())
|
||||
matchId BigInt @unique @map("match_id")
|
||||
htHomeScore Int? @map("ht_home_score")
|
||||
htAwayScore Int? @map("ht_away_score")
|
||||
ftHomeScore Int? @map("ft_home_score")
|
||||
ftAwayScore Int? @map("ft_away_score")
|
||||
winnerTeamId BigInt? @map("winner_team_id")
|
||||
recordedBy BigInt? @map("recorded_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
|
||||
@@map("match_scores")
|
||||
}
|
||||
|
||||
model Market {
|
||||
id BigInt @id @default(autoincrement())
|
||||
matchId BigInt @map("match_id")
|
||||
marketType String @map("market_type") @db.VarChar(64)
|
||||
period String @db.VarChar(16)
|
||||
lineValue Decimal? @map("line_value") @db.Decimal(8, 2)
|
||||
status String @default("OPEN") @db.VarChar(20)
|
||||
allowSingle Boolean @default(true) @map("allow_single")
|
||||
allowParlay Boolean @default(true) @map("allow_parlay")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
selections MarketSelection[]
|
||||
|
||||
@@index([matchId])
|
||||
@@index([marketType])
|
||||
@@map("markets")
|
||||
}
|
||||
|
||||
model MarketSelection {
|
||||
id BigInt @id @default(autoincrement())
|
||||
marketId BigInt @map("market_id")
|
||||
selectionCode String @map("selection_code") @db.VarChar(64)
|
||||
selectionName String @map("selection_name") @db.VarChar(255)
|
||||
odds Decimal @db.Decimal(18, 6)
|
||||
oddsVersion BigInt @default(1) @map("odds_version")
|
||||
status String @default("OPEN") @db.VarChar(20)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
market Market @relation(fields: [marketId], references: [id])
|
||||
oddsLogs OddsChangeLog[]
|
||||
|
||||
@@index([marketId])
|
||||
@@map("market_selections")
|
||||
}
|
||||
|
||||
model OddsChangeLog {
|
||||
id BigInt @id @default(autoincrement())
|
||||
selectionId BigInt @map("selection_id")
|
||||
oldOdds Decimal @map("old_odds") @db.Decimal(18, 6)
|
||||
newOdds Decimal @map("new_odds") @db.Decimal(18, 6)
|
||||
oddsVersion BigInt @map("odds_version")
|
||||
changedBy BigInt? @map("changed_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
selection MarketSelection @relation(fields: [selectionId], references: [id])
|
||||
|
||||
@@index([selectionId])
|
||||
@@map("odds_change_logs")
|
||||
}
|
||||
|
||||
// ============ Bets ============
|
||||
|
||||
model Bet {
|
||||
id BigInt @id @default(autoincrement())
|
||||
betNo String @unique @map("bet_no") @db.VarChar(64)
|
||||
userId BigInt @map("user_id")
|
||||
agentId BigInt? @map("agent_id")
|
||||
betType String @map("bet_type") @db.VarChar(20)
|
||||
stake Decimal @db.Decimal(18, 4)
|
||||
totalOdds Decimal? @map("total_odds") @db.Decimal(18, 6)
|
||||
potentialReturn Decimal? @map("potential_return") @db.Decimal(18, 4)
|
||||
actualReturn Decimal @default(0) @map("actual_return") @db.Decimal(18, 4)
|
||||
status String @default("PENDING") @db.VarChar(32)
|
||||
settlementStatus String? @map("settlement_status") @db.VarChar(32)
|
||||
currency String @default("USD") @db.VarChar(16)
|
||||
requestId String @map("request_id") @db.VarChar(128)
|
||||
placedAt DateTime @default(now()) @map("placed_at")
|
||||
settledAt DateTime? @map("settled_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
selections BetSelection[]
|
||||
|
||||
@@unique([userId, requestId])
|
||||
@@index([userId])
|
||||
@@index([agentId])
|
||||
@@index([status])
|
||||
@@index([placedAt])
|
||||
@@map("bets")
|
||||
}
|
||||
|
||||
model BetSelection {
|
||||
id BigInt @id @default(autoincrement())
|
||||
betId BigInt @map("bet_id")
|
||||
matchId BigInt? @map("match_id")
|
||||
marketId BigInt @map("market_id")
|
||||
selectionId BigInt @map("selection_id")
|
||||
marketType String @map("market_type") @db.VarChar(64)
|
||||
period String? @db.VarChar(16)
|
||||
selectionNameSnapshot String @map("selection_name_snapshot") @db.VarChar(255)
|
||||
handicapLine Decimal? @map("handicap_line") @db.Decimal(8, 2)
|
||||
totalLine Decimal? @map("total_line") @db.Decimal(8, 2)
|
||||
odds Decimal @db.Decimal(18, 6)
|
||||
oddsVersion BigInt @map("odds_version")
|
||||
resultStatus String? @map("result_status") @db.VarChar(32)
|
||||
effectiveOdds Decimal? @map("effective_odds") @db.Decimal(18, 6)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
bet Bet @relation(fields: [betId], references: [id])
|
||||
|
||||
@@index([betId])
|
||||
@@index([matchId])
|
||||
@@map("bet_selections")
|
||||
}
|
||||
|
||||
// ============ Settlement ============
|
||||
|
||||
model SettlementBatch {
|
||||
id BigInt @id @default(autoincrement())
|
||||
matchId BigInt @map("match_id")
|
||||
batchNo String @unique @map("batch_no") @db.VarChar(64)
|
||||
htHomeScore Int? @map("ht_home_score")
|
||||
htAwayScore Int? @map("ht_away_score")
|
||||
ftHomeScore Int? @map("ft_home_score")
|
||||
ftAwayScore Int? @map("ft_away_score")
|
||||
status String @default("PREVIEW") @db.VarChar(20)
|
||||
totalBets Int @default(0) @map("total_bets")
|
||||
totalPayout Decimal @default(0) @map("total_payout") @db.Decimal(18, 4)
|
||||
totalRefund Decimal @default(0) @map("total_refund") @db.Decimal(18, 4)
|
||||
operatorId BigInt? @map("operator_id")
|
||||
confirmedAt DateTime? @map("confirmed_at")
|
||||
isResettle Boolean @default(false) @map("is_resettle")
|
||||
reason String? @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
match Match @relation(fields: [matchId], references: [id])
|
||||
items SettlementItem[]
|
||||
|
||||
@@index([matchId])
|
||||
@@map("settlement_batches")
|
||||
}
|
||||
|
||||
model SettlementItem {
|
||||
id BigInt @id @default(autoincrement())
|
||||
batchId BigInt @map("batch_id")
|
||||
betId BigInt @map("bet_id")
|
||||
userId BigInt @map("user_id")
|
||||
result String @db.VarChar(32)
|
||||
payout Decimal @db.Decimal(18, 4)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
batch SettlementBatch @relation(fields: [batchId], references: [id])
|
||||
|
||||
@@index([batchId])
|
||||
@@index([betId])
|
||||
@@map("settlement_items")
|
||||
}
|
||||
|
||||
// ============ Cashback ============
|
||||
|
||||
model CashbackRule {
|
||||
id BigInt @id @default(autoincrement())
|
||||
name String @db.VarChar(128)
|
||||
targetType String @map("target_type") @db.VarChar(32)
|
||||
targetId BigInt? @map("target_id")
|
||||
rate Decimal @db.Decimal(8, 4)
|
||||
marketType String? @map("market_type") @db.VarChar(64)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("cashback_rules")
|
||||
}
|
||||
|
||||
model CashbackBatch {
|
||||
id BigInt @id @default(autoincrement())
|
||||
batchNo String @unique @map("batch_no") @db.VarChar(64)
|
||||
periodStart DateTime @map("period_start")
|
||||
periodEnd DateTime @map("period_end")
|
||||
status String @default("PREVIEW") @db.VarChar(20)
|
||||
totalAmount Decimal @default(0) @map("total_amount") @db.Decimal(18, 4)
|
||||
playerCount Int @default(0) @map("player_count")
|
||||
operatorId BigInt? @map("operator_id")
|
||||
confirmedAt DateTime? @map("confirmed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
items CashbackItem[]
|
||||
|
||||
@@map("cashback_batches")
|
||||
}
|
||||
|
||||
model CashbackItem {
|
||||
id BigInt @id @default(autoincrement())
|
||||
batchId BigInt @map("batch_id")
|
||||
userId BigInt @map("user_id")
|
||||
effectiveStake Decimal @map("effective_stake") @db.Decimal(18, 4)
|
||||
rate Decimal @db.Decimal(8, 4)
|
||||
amount Decimal @db.Decimal(18, 4)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
batch CashbackBatch @relation(fields: [batchId], references: [id])
|
||||
|
||||
@@index([batchId])
|
||||
@@index([userId])
|
||||
@@map("cashback_items")
|
||||
}
|
||||
|
||||
// ============ Content & i18n ============
|
||||
|
||||
model Content {
|
||||
id BigInt @id @default(autoincrement())
|
||||
contentType String @map("content_type") @db.VarChar(32)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
status String @default("DRAFT") @db.VarChar(20)
|
||||
linkType String? @map("link_type") @db.VarChar(32)
|
||||
linkTarget String? @map("link_target") @db.VarChar(500)
|
||||
startTime DateTime? @map("start_time")
|
||||
endTime DateTime? @map("end_time")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
translations ContentTranslation[]
|
||||
|
||||
@@index([contentType, status])
|
||||
@@map("contents")
|
||||
}
|
||||
|
||||
model ContentTranslation {
|
||||
id BigInt @id @default(autoincrement())
|
||||
contentId BigInt @map("content_id")
|
||||
locale String @db.VarChar(10)
|
||||
title String? @db.VarChar(255)
|
||||
body String? @db.Text
|
||||
imageUrl String? @map("image_url") @db.VarChar(500)
|
||||
|
||||
content Content @relation(fields: [contentId], references: [id])
|
||||
|
||||
@@unique([contentId, locale])
|
||||
@@map("content_translations")
|
||||
}
|
||||
|
||||
model I18nMessage {
|
||||
id BigInt @id @default(autoincrement())
|
||||
msgKey String @map("msg_key") @db.VarChar(128)
|
||||
locale String @db.VarChar(10)
|
||||
value String @db.Text
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([msgKey, locale])
|
||||
@@map("i18n_messages")
|
||||
}
|
||||
|
||||
// ============ System Config & Audit ============
|
||||
|
||||
model SystemConfig {
|
||||
id BigInt @id @default(autoincrement())
|
||||
configKey String @unique @map("config_key") @db.VarChar(128)
|
||||
configValue String @map("config_value") @db.Text
|
||||
description String? @db.VarChar(255)
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("system_configs")
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id BigInt @id @default(autoincrement())
|
||||
operatorId BigInt? @map("operator_id")
|
||||
operatorType String @map("operator_type") @db.VarChar(20)
|
||||
action String @db.VarChar(128)
|
||||
module String @db.VarChar(64)
|
||||
targetType String? @map("target_type") @db.VarChar(32)
|
||||
targetId String? @map("target_id") @db.VarChar(64)
|
||||
beforeData String? @map("before_data") @db.Text
|
||||
afterData String? @map("after_data") @db.Text
|
||||
ipAddress String? @map("ip_address") @db.VarChar(45)
|
||||
userAgent String? @map("user_agent") @db.VarChar(500)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([operatorId])
|
||||
@@index([module])
|
||||
@@index([createdAt])
|
||||
@@map("audit_logs")
|
||||
}
|
||||
182
apps/api/prisma/seed.ts
Normal file
182
apps/api/prisma/seed.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('Seeding database...');
|
||||
|
||||
const superAdminRole = await prisma.role.upsert({
|
||||
where: { code: 'SUPER_ADMIN' },
|
||||
create: { code: 'SUPER_ADMIN', name: 'Super Admin', description: 'Full access' },
|
||||
update: {},
|
||||
});
|
||||
|
||||
const permCodes = [
|
||||
'users.create', 'users.view', 'agents.create', 'agents.view',
|
||||
'wallet.deposit', 'wallet.withdraw', 'matches.manage', 'settlement.confirm',
|
||||
'cashback.confirm', 'content.manage', 'reports.view',
|
||||
];
|
||||
|
||||
for (const code of permCodes) {
|
||||
const perm = await prisma.permission.upsert({
|
||||
where: { code },
|
||||
create: { code, name: code, module: code.split('.')[0] },
|
||||
update: {},
|
||||
});
|
||||
await prisma.rolePermission.upsert({
|
||||
where: { roleId_permissionId: { roleId: superAdminRole.id, permissionId: perm.id } },
|
||||
create: { roleId: superAdminRole.id, permissionId: perm.id },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash('Admin@123', 10);
|
||||
const agentHash = await bcrypt.hash('Agent@123', 10);
|
||||
const playerHash = await bcrypt.hash('Player@123', 10);
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { username: 'admin' },
|
||||
create: {
|
||||
username: 'admin',
|
||||
userType: 'ADMIN',
|
||||
auth: { create: { passwordHash: hash } },
|
||||
adminRole: { create: { roleId: superAdminRole.id } },
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const agent1 = await prisma.user.upsert({
|
||||
where: { username: 'agent1' },
|
||||
create: {
|
||||
username: 'agent1',
|
||||
userType: 'AGENT',
|
||||
agentLevel: 1,
|
||||
auth: { create: { passwordHash: agentHash } },
|
||||
agentProfile: { create: { level: 1, creditLimit: 100000 } },
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
await prisma.agentClosure.upsert({
|
||||
where: { ancestorId_descendantId: { ancestorId: agent1.id, descendantId: agent1.id } },
|
||||
create: { ancestorId: agent1.id, descendantId: agent1.id, depth: 0 },
|
||||
update: {},
|
||||
});
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { username: 'agent2' },
|
||||
create: {
|
||||
username: 'agent2',
|
||||
userType: 'AGENT',
|
||||
agentLevel: 2,
|
||||
parentId: agent1.id,
|
||||
auth: { create: { passwordHash: agentHash } },
|
||||
agentProfile: { create: { level: 2, parentAgentId: agent1.id, creditLimit: 30000 } },
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { username: 'player1' },
|
||||
create: {
|
||||
username: 'player1',
|
||||
userType: 'PLAYER',
|
||||
parentId: agent1.id,
|
||||
auth: { create: { passwordHash: playerHash } },
|
||||
wallet: { create: { availableBalance: 1000 } },
|
||||
preferences: { create: { locale: 'zh-CN' } },
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const messages = [
|
||||
{ key: 'nav.home', zh: '首页', ms: 'Laman Utama', en: 'Home' },
|
||||
{ key: 'nav.football', zh: '足球', ms: 'Bola Sepak', en: 'Football' },
|
||||
{ key: 'bet.place_bet', zh: '确认下注', ms: 'Letak Pertaruhan', en: 'Place Bet' },
|
||||
{ key: 'error.insufficient_balance', zh: '余额不足', ms: 'Baki tidak mencukupi', en: 'Insufficient balance' },
|
||||
];
|
||||
|
||||
for (const m of messages) {
|
||||
for (const [locale, value] of [['zh-CN', m.zh], ['ms-MY', m.ms], ['en-US', m.en]] as const) {
|
||||
await prisma.i18nMessage.upsert({
|
||||
where: { msgKey_locale: { msgKey: m.key, locale } },
|
||||
create: { msgKey: m.key, locale, value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const league = await prisma.league.upsert({
|
||||
where: { code: 'EPL' },
|
||||
create: { code: 'EPL' },
|
||||
update: {},
|
||||
});
|
||||
|
||||
await prisma.entityTranslation.upsert({
|
||||
where: { entityType_entityId_locale_fieldName: { entityType: 'LEAGUE', entityId: league.id, locale: 'zh-CN', fieldName: 'name' } },
|
||||
create: { entityType: 'LEAGUE', entityId: league.id, locale: 'zh-CN', fieldName: 'name', value: '英超' },
|
||||
update: {},
|
||||
});
|
||||
|
||||
for (const [code, name] of [['MUN', '曼联'], ['CHE', '切尔西']] as const) {
|
||||
const team = await prisma.team.upsert({ where: { code }, create: { code }, update: {} });
|
||||
await prisma.entityTranslation.upsert({
|
||||
where: { entityType_entityId_locale_fieldName: { entityType: 'TEAM', entityId: team.id, locale: 'zh-CN', fieldName: 'name' } },
|
||||
create: { entityType: 'TEAM', entityId: team.id, locale: 'zh-CN', fieldName: 'name', value: name },
|
||||
update: { value: name },
|
||||
});
|
||||
}
|
||||
|
||||
const mun = await prisma.team.findUnique({ where: { code: 'MUN' } });
|
||||
const che = await prisma.team.findUnique({ where: { code: 'CHE' } });
|
||||
|
||||
if (mun && che) {
|
||||
const existing = await prisma.match.findFirst({ where: { homeTeamId: mun.id, awayTeamId: che.id } });
|
||||
if (!existing) {
|
||||
const match = await prisma.match.create({
|
||||
data: {
|
||||
leagueId: league.id,
|
||||
homeTeamId: mun.id,
|
||||
awayTeamId: che.id,
|
||||
startTime: new Date(Date.now() + 86400000),
|
||||
status: 'PUBLISHED',
|
||||
isHot: true,
|
||||
publishTime: new Date(),
|
||||
},
|
||||
});
|
||||
await prisma.market.create({
|
||||
data: {
|
||||
matchId: match.id,
|
||||
marketType: 'FT_1X2',
|
||||
period: 'FT',
|
||||
selections: {
|
||||
create: [
|
||||
{ selectionCode: 'HOME', selectionName: 'Home', odds: 2.5 },
|
||||
{ selectionCode: 'DRAW', selectionName: 'Draw', odds: 3.2 },
|
||||
{ selectionCode: 'AWAY', selectionName: 'Away', odds: 2.8 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.content.create({
|
||||
data: {
|
||||
contentType: 'BANNER',
|
||||
status: 'ACTIVE',
|
||||
sortOrder: 1,
|
||||
translations: {
|
||||
create: [
|
||||
{ locale: 'zh-CN', title: '欢迎投注', body: '足球赛事火热进行中' },
|
||||
{ locale: 'en-US', title: 'Welcome', body: 'Football matches available' },
|
||||
],
|
||||
},
|
||||
},
|
||||
}).catch(() => {});
|
||||
|
||||
console.log('Seed completed! admin/Admin@123 agent1/Agent@123 player1/Player@123');
|
||||
}
|
||||
|
||||
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||
400
apps/api/src/admin/admin.controller.ts
Normal file
400
apps/api/src/admin/admin.controller.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard, AdminGuard } from '../auth/guards';
|
||||
import { ContentService } from '../content/content.service';
|
||||
import { CurrentUser } from '../common/decorators';
|
||||
import { jsonResponse } from '../common/filters';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { AgentsService } from '../agents/agents.service';
|
||||
import { WalletService } from '../wallet/wallet.service';
|
||||
import { MatchesService } from '../matches/matches.service';
|
||||
import { MarketsService } from '../markets/markets.service';
|
||||
import { SettlementService } from '../settlement/settlement.service';
|
||||
import { CashbackService } from '../cashback/cashback.service';
|
||||
import { I18nService } from '../i18n/i18n.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { BetsService } from '../bets/bets.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { IsString, IsNumber, IsOptional, IsArray, IsBoolean, MinLength } from 'class-validator';
|
||||
|
||||
class CreateUserDto {
|
||||
@IsString()
|
||||
username!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
creditLimit?: number;
|
||||
}
|
||||
|
||||
class DepositDto {
|
||||
@IsNumber()
|
||||
amount!: number;
|
||||
|
||||
@IsString()
|
||||
requestId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
class CreateMatchDto {
|
||||
@IsString()
|
||||
leagueId!: string;
|
||||
|
||||
@IsString()
|
||||
homeTeamId!: string;
|
||||
|
||||
@IsString()
|
||||
awayTeamId!: string;
|
||||
|
||||
@IsString()
|
||||
startTime!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isHot?: boolean;
|
||||
}
|
||||
|
||||
class ScoreDto {
|
||||
@IsNumber()
|
||||
htHome!: number;
|
||||
|
||||
@IsNumber()
|
||||
htAway!: number;
|
||||
|
||||
@IsNumber()
|
||||
ftHome!: number;
|
||||
|
||||
@IsNumber()
|
||||
ftAway!: number;
|
||||
}
|
||||
|
||||
class MarketTemplatesDto {
|
||||
@IsArray()
|
||||
marketTypes!: string[];
|
||||
}
|
||||
|
||||
class UpdateOddsDto {
|
||||
@IsNumber()
|
||||
odds!: number;
|
||||
}
|
||||
|
||||
class CashbackPreviewDto {
|
||||
@IsString()
|
||||
periodStart!: string;
|
||||
|
||||
@IsString()
|
||||
periodEnd!: string;
|
||||
}
|
||||
|
||||
@ApiTags('Admin')
|
||||
@Controller('admin')
|
||||
@UseGuards(JwtAuthGuard, AdminGuard)
|
||||
@ApiBearerAuth()
|
||||
export class AdminController {
|
||||
constructor(
|
||||
private users: UsersService,
|
||||
private agents: AgentsService,
|
||||
private wallet: WalletService,
|
||||
private matches: MatchesService,
|
||||
private markets: MarketsService,
|
||||
private settlement: SettlementService,
|
||||
private cashback: CashbackService,
|
||||
private content: ContentService,
|
||||
private i18n: I18nService,
|
||||
private audit: AuditService,
|
||||
private bets: BetsService,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get('dashboard')
|
||||
async dashboard() {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const [todayBets, pendingMatches, totalPlayers] = await Promise.all([
|
||||
this.prisma.bet.aggregate({
|
||||
where: { placedAt: { gte: today } },
|
||||
_sum: { stake: true, actualReturn: true },
|
||||
_count: true,
|
||||
}),
|
||||
this.prisma.match.count({ where: { status: 'PENDING_SETTLEMENT' } }),
|
||||
this.prisma.user.count({ where: { userType: 'PLAYER' } }),
|
||||
]);
|
||||
|
||||
return jsonResponse({
|
||||
todayBetCount: todayBets._count,
|
||||
todayStake: todayBets._sum.stake,
|
||||
todayPayout: todayBets._sum.actualReturn,
|
||||
pendingSettlement: pendingMatches,
|
||||
totalPlayers,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
async listUsers(@Query('page') page?: string) {
|
||||
const result = await this.users.listPlayers(page ? parseInt(page) : 1);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Post('users')
|
||||
async createPlayer(@CurrentUser('id') operatorId: bigint, @Body() dto: CreateUserDto) {
|
||||
const user = await this.agents.createPlayer(operatorId, {
|
||||
username: dto.username,
|
||||
password: dto.password,
|
||||
parentId: dto.parentId ? BigInt(dto.parentId) : operatorId,
|
||||
});
|
||||
await this.audit.log({
|
||||
operatorId,
|
||||
operatorType: 'ADMIN',
|
||||
action: 'CREATE_PLAYER',
|
||||
module: 'USERS',
|
||||
targetId: user.id.toString(),
|
||||
});
|
||||
return jsonResponse(user);
|
||||
}
|
||||
|
||||
@Get('agents')
|
||||
async listAgents() {
|
||||
const agents = await this.prisma.agentProfile.findMany({
|
||||
include: { user: true },
|
||||
});
|
||||
return jsonResponse(agents);
|
||||
}
|
||||
|
||||
@Post('agents')
|
||||
async createAgent(@CurrentUser('id') operatorId: bigint, @Body() dto: CreateUserDto) {
|
||||
const user = await this.agents.createAgent(operatorId, {
|
||||
username: dto.username,
|
||||
password: dto.password,
|
||||
level: 1,
|
||||
creditLimit: dto.creditLimit,
|
||||
});
|
||||
await this.audit.log({
|
||||
operatorId,
|
||||
operatorType: 'ADMIN',
|
||||
action: 'CREATE_AGENT',
|
||||
module: 'AGENTS',
|
||||
targetId: user.id.toString(),
|
||||
});
|
||||
return jsonResponse(user);
|
||||
}
|
||||
|
||||
@Post('agents/:id/credit')
|
||||
async adjustCredit(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: DepositDto,
|
||||
) {
|
||||
const result = await this.agents.adjustCredit(
|
||||
BigInt(id),
|
||||
dto.amount,
|
||||
operatorId,
|
||||
dto.requestId,
|
||||
dto.remark,
|
||||
);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Post('wallet/deposit')
|
||||
async deposit(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) {
|
||||
const result = await this.wallet.deposit(
|
||||
BigInt(dto.userId),
|
||||
dto.amount,
|
||||
operatorId,
|
||||
dto.remark,
|
||||
dto.requestId,
|
||||
);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Post('wallet/withdraw')
|
||||
async withdraw(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) {
|
||||
const result = await this.wallet.withdraw(
|
||||
BigInt(dto.userId),
|
||||
dto.amount,
|
||||
operatorId,
|
||||
dto.remark,
|
||||
dto.requestId,
|
||||
);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('wallet/transactions')
|
||||
async walletTransactions(@Query('userId') userId: string, @Query('page') page?: string) {
|
||||
const result = await this.wallet.getTransactions(BigInt(userId), page ? parseInt(page) : 1);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Post('leagues')
|
||||
async createLeague(@Body() dto: { code: string; translations: Record<string, string> }) {
|
||||
const league = await this.matches.createLeague(dto.code, dto.translations);
|
||||
return jsonResponse(league);
|
||||
}
|
||||
|
||||
@Post('teams')
|
||||
async createTeam(@Body() dto: { code: string; translations: Record<string, string> }) {
|
||||
const team = await this.matches.createTeam(dto.code, dto.translations);
|
||||
return jsonResponse(team);
|
||||
}
|
||||
|
||||
@Get('matches')
|
||||
async listMatches() {
|
||||
const matches = await this.prisma.match.findMany({
|
||||
include: { markets: { include: { selections: true } } },
|
||||
orderBy: { startTime: 'desc' },
|
||||
});
|
||||
return jsonResponse(matches);
|
||||
}
|
||||
|
||||
@Post('matches')
|
||||
async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreateMatchDto) {
|
||||
const match = await this.matches.createMatch({
|
||||
leagueId: BigInt(dto.leagueId),
|
||||
homeTeamId: BigInt(dto.homeTeamId),
|
||||
awayTeamId: BigInt(dto.awayTeamId),
|
||||
startTime: new Date(dto.startTime),
|
||||
isHot: dto.isHot,
|
||||
createdBy: operatorId,
|
||||
});
|
||||
return jsonResponse(match);
|
||||
}
|
||||
|
||||
@Post('matches/:id/publish')
|
||||
async publishMatch(@Param('id') id: string) {
|
||||
const match = await this.matches.publishMatch(BigInt(id));
|
||||
return jsonResponse(match);
|
||||
}
|
||||
|
||||
@Post('matches/:id/close')
|
||||
async closeMatch(@Param('id') id: string) {
|
||||
const match = await this.matches.closeMatch(BigInt(id));
|
||||
return jsonResponse(match);
|
||||
}
|
||||
|
||||
@Post('matches/:id/cancel')
|
||||
async cancelMatch(@Param('id') id: string) {
|
||||
await this.matches.cancelMatch(BigInt(id));
|
||||
const voided = await this.settlement.voidMatchBets(BigInt(id));
|
||||
return jsonResponse(voided);
|
||||
}
|
||||
|
||||
@Post('matches/:id/markets/templates')
|
||||
async generateTemplates(@Param('id') id: string, @Body() dto: MarketTemplatesDto) {
|
||||
const markets = await this.markets.generateTemplates(BigInt(id), dto.marketTypes);
|
||||
return jsonResponse(markets);
|
||||
}
|
||||
|
||||
@Put('selections/:id/odds')
|
||||
async updateOdds(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateOddsDto,
|
||||
) {
|
||||
const selection = await this.markets.updateOdds(BigInt(id), dto.odds, operatorId);
|
||||
return jsonResponse(selection);
|
||||
}
|
||||
|
||||
@Post('matches/:id/settlement/score')
|
||||
async recordScore(
|
||||
@CurrentUser('id') operatorId: bigint,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: ScoreDto,
|
||||
) {
|
||||
const result = await this.settlement.recordScore(
|
||||
BigInt(id),
|
||||
dto.htHome,
|
||||
dto.htAway,
|
||||
dto.ftHome,
|
||||
dto.ftAway,
|
||||
operatorId,
|
||||
);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Post('matches/:id/settlement/preview')
|
||||
async settlementPreview(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) {
|
||||
const preview = await this.settlement.previewSettlement(BigInt(id), operatorId);
|
||||
return jsonResponse(preview);
|
||||
}
|
||||
|
||||
@Post('settlement/:batchId/confirm')
|
||||
async confirmSettlement(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
|
||||
const result = await this.settlement.confirmSettlement(BigInt(batchId), operatorId);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('bets')
|
||||
async listBets(@Query('status') status?: string, @Query('page') page?: string) {
|
||||
const skip = ((page ? parseInt(page) : 1) - 1) * 20;
|
||||
const where = status ? { status } : {};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.bet.findMany({
|
||||
where,
|
||||
include: { selections: true, user: true },
|
||||
orderBy: { placedAt: 'desc' },
|
||||
skip,
|
||||
take: 20,
|
||||
}),
|
||||
this.prisma.bet.count({ where }),
|
||||
]);
|
||||
return jsonResponse({ items, total });
|
||||
}
|
||||
|
||||
@Post('cashbacks/preview')
|
||||
async cashbackPreview(@Body() dto: CashbackPreviewDto) {
|
||||
const preview = await this.cashback.previewBatch(
|
||||
new Date(dto.periodStart),
|
||||
new Date(dto.periodEnd),
|
||||
);
|
||||
return jsonResponse(preview);
|
||||
}
|
||||
|
||||
@Post('cashbacks/:batchId/confirm')
|
||||
async cashbackConfirm(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
|
||||
const result = await this.cashback.confirmBatch(BigInt(batchId), operatorId);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('contents')
|
||||
async listContents(@Query('type') type?: string) {
|
||||
const items = await this.content.listAll(type);
|
||||
return jsonResponse(items);
|
||||
}
|
||||
|
||||
@Post('contents')
|
||||
async createContent(@Body() dto: Parameters<ContentService['create']>[0]) {
|
||||
const item = await this.content.create(dto);
|
||||
return jsonResponse(item);
|
||||
}
|
||||
|
||||
@Get('i18n/messages')
|
||||
async getMessages(@Query('locale') locale = 'en-US') {
|
||||
const messages = await this.i18n.getMessages(locale);
|
||||
return jsonResponse(messages);
|
||||
}
|
||||
|
||||
@Get('audit-logs')
|
||||
async auditLogs(@Query('page') page?: string, @Query('module') module?: string) {
|
||||
const result = await this.audit.list(page ? parseInt(page) : 1, 50, module);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
}
|
||||
29
apps/api/src/admin/admin.module.ts
Normal file
29
apps/api/src/admin/admin.module.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AgentsModule } from '../agents/agents.module';
|
||||
import { WalletModule } from '../wallet/wallet.module';
|
||||
import { MatchesModule } from '../matches/matches.module';
|
||||
import { MarketsModule } from '../markets/markets.module';
|
||||
import { SettlementModule } from '../settlement/settlement.module';
|
||||
import { CashbackModule } from '../cashback/cashback.module';
|
||||
import { ContentModule } from '../content/content.module';
|
||||
import { I18nModule } from '../i18n/i18n.module';
|
||||
import { BetsModule } from '../bets/bets.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
UsersModule,
|
||||
AgentsModule,
|
||||
WalletModule,
|
||||
MatchesModule,
|
||||
MarketsModule,
|
||||
SettlementModule,
|
||||
CashbackModule,
|
||||
ContentModule,
|
||||
I18nModule,
|
||||
BetsModule,
|
||||
],
|
||||
controllers: [AdminController],
|
||||
})
|
||||
export class AdminModule {}
|
||||
189
apps/api/src/agent-portal/agent-portal.controller.ts
Normal file
189
apps/api/src/agent-portal/agent-portal.controller.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard, AgentGuard } from '../auth/guards';
|
||||
import { CurrentUser } from '../common/decorators';
|
||||
import { jsonResponse } from '../common/filters';
|
||||
import { AgentsService } from '../agents/agents.service';
|
||||
import { WalletService } from '../wallet/wallet.service';
|
||||
import { BetsService } from '../bets/bets.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { IsString, IsNumber, MinLength, IsOptional } from 'class-validator';
|
||||
|
||||
class CreatePlayerDto {
|
||||
@IsString()
|
||||
username!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
}
|
||||
|
||||
class CreateSubAgentDto extends CreatePlayerDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
creditLimit?: number;
|
||||
}
|
||||
|
||||
class TransferDto {
|
||||
@IsNumber()
|
||||
amount!: number;
|
||||
|
||||
@IsString()
|
||||
requestId!: string;
|
||||
}
|
||||
|
||||
class CreditDto extends TransferDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
@ApiTags('Agent Portal')
|
||||
@Controller('agent')
|
||||
@UseGuards(JwtAuthGuard, AgentGuard)
|
||||
@ApiBearerAuth()
|
||||
export class AgentPortalController {
|
||||
constructor(
|
||||
private agents: AgentsService,
|
||||
private wallet: WalletService,
|
||||
private bets: BetsService,
|
||||
private prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get('profile')
|
||||
async profile(@CurrentUser('id') agentId: bigint) {
|
||||
const profile = await this.agents.getProfile(agentId);
|
||||
return jsonResponse(profile);
|
||||
}
|
||||
|
||||
@Get('players')
|
||||
async listPlayers(@CurrentUser('id') agentId: bigint) {
|
||||
const players = await this.agents.getDirectPlayers(agentId);
|
||||
return jsonResponse(players);
|
||||
}
|
||||
|
||||
@Post('players')
|
||||
async createPlayer(@CurrentUser('id') agentId: bigint, @Body() dto: CreatePlayerDto) {
|
||||
const user = await this.agents.createPlayer(agentId, {
|
||||
username: dto.username,
|
||||
password: dto.password,
|
||||
parentId: agentId,
|
||||
});
|
||||
return jsonResponse(user);
|
||||
}
|
||||
|
||||
@Get('agents')
|
||||
async listSubAgents(@CurrentUser('id') agentId: bigint, @CurrentUser('agentLevel') level: number) {
|
||||
if (level !== 1) {
|
||||
return jsonResponse([]);
|
||||
}
|
||||
const agents = await this.agents.getChildAgents(agentId);
|
||||
return jsonResponse(agents);
|
||||
}
|
||||
|
||||
@Post('agents')
|
||||
async createSubAgent(@CurrentUser('id') agentId: bigint, @CurrentUser('agentLevel') level: number, @Body() dto: CreateSubAgentDto) {
|
||||
if (level !== 1) {
|
||||
return jsonResponse(null, 'Only level 1 agents can create sub-agents');
|
||||
}
|
||||
const user = await this.agents.createAgent(agentId, {
|
||||
username: dto.username,
|
||||
password: dto.password,
|
||||
level: 2,
|
||||
parentAgentId: agentId,
|
||||
creditLimit: dto.creditLimit,
|
||||
});
|
||||
return jsonResponse(user);
|
||||
}
|
||||
|
||||
@Post('players/:id/deposit')
|
||||
async depositToPlayer(
|
||||
@CurrentUser('id') agentId: bigint,
|
||||
@Param('id') playerId: string,
|
||||
@Body() dto: TransferDto,
|
||||
) {
|
||||
const result = await this.agents.depositToPlayer(agentId, BigInt(playerId), dto.amount, dto.requestId);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Post('players/:id/withdraw')
|
||||
async withdrawFromPlayer(
|
||||
@CurrentUser('id') agentId: bigint,
|
||||
@Param('id') playerId: string,
|
||||
@Body() dto: TransferDto,
|
||||
) {
|
||||
const result = await this.agents.withdrawFromPlayer(agentId, BigInt(playerId), dto.amount, dto.requestId);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Post('agents/:id/credit')
|
||||
async allocateCredit(
|
||||
@CurrentUser('id') agentId: bigint,
|
||||
@Param('id') subAgentId: string,
|
||||
@Body() dto: CreditDto,
|
||||
) {
|
||||
const subAgent = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: BigInt(subAgentId) },
|
||||
});
|
||||
if (!subAgent || subAgent.parentAgentId !== agentId) {
|
||||
return jsonResponse(null, 'Not your sub-agent');
|
||||
}
|
||||
const result = await this.agents.adjustCredit(
|
||||
BigInt(subAgentId),
|
||||
dto.amount,
|
||||
agentId,
|
||||
dto.requestId,
|
||||
dto.remark,
|
||||
);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('bets')
|
||||
async listBets(@CurrentUser('id') agentId: bigint, @Query('page') page?: string) {
|
||||
const skip = ((page ? parseInt(page) : 1) - 1) * 20;
|
||||
const descendants = await this.prisma.agentClosure.findMany({
|
||||
where: { ancestorId: agentId },
|
||||
});
|
||||
const agentIds = descendants.map((d) => d.descendantId);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.bet.findMany({
|
||||
where: { agentId: { in: agentIds } },
|
||||
include: { selections: true, user: true },
|
||||
orderBy: { placedAt: 'desc' },
|
||||
skip,
|
||||
take: 20,
|
||||
}),
|
||||
this.prisma.bet.count({ where: { agentId: { in: agentIds } } }),
|
||||
]);
|
||||
return jsonResponse({ items, total });
|
||||
}
|
||||
|
||||
@Get('reports/summary')
|
||||
async reportSummary(@CurrentUser('id') agentId: bigint) {
|
||||
const summary = await this.agents.getReportSummary(agentId);
|
||||
return jsonResponse(summary);
|
||||
}
|
||||
|
||||
@Get('wallet-transactions')
|
||||
async walletTransactions(@CurrentUser('id') agentId: bigint, @Query('playerId') playerId?: string) {
|
||||
const players = playerId
|
||||
? [BigInt(playerId)]
|
||||
: (await this.agents.getDirectPlayers(agentId)).map((p) => p.id);
|
||||
|
||||
const transactions = await this.prisma.walletTransaction.findMany({
|
||||
where: { userId: { in: players } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
return jsonResponse(transactions);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/agent-portal/agent-portal.module.ts
Normal file
11
apps/api/src/agent-portal/agent-portal.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AgentPortalController } from './agent-portal.controller';
|
||||
import { AgentsModule } from '../agents/agents.module';
|
||||
import { WalletModule } from '../wallet/wallet.module';
|
||||
import { BetsModule } from '../bets/bets.module';
|
||||
|
||||
@Module({
|
||||
imports: [AgentsModule, WalletModule, BetsModule],
|
||||
controllers: [AgentPortalController],
|
||||
})
|
||||
export class AgentPortalModule {}
|
||||
11
apps/api/src/agents/agents.module.ts
Normal file
11
apps/api/src/agents/agents.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AgentsService } from './agents.service';
|
||||
import { WalletModule } from '../wallet/wallet.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [WalletModule, AuthModule],
|
||||
providers: [AgentsService],
|
||||
exports: [AgentsService],
|
||||
})
|
||||
export class AgentsModule {}
|
||||
296
apps/api/src/agents/agents.service.ts
Normal file
296
apps/api/src/agents/agents.service.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { WalletService } from '../wallet/wallet.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { generateBatchNo } from '../common/decorators';
|
||||
|
||||
@Injectable()
|
||||
export class AgentsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
private auth: AuthService,
|
||||
) {}
|
||||
|
||||
async getProfile(agentId: bigint) {
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: agentId },
|
||||
});
|
||||
if (!profile) throw new BadRequestException('Agent profile not found');
|
||||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||||
return { ...profile, availableCredit: available };
|
||||
}
|
||||
|
||||
async recalculateUsedCredit(agentId: bigint) {
|
||||
const directPlayers = await this.prisma.user.findMany({
|
||||
where: { parentId: agentId, userType: 'PLAYER' },
|
||||
include: { wallet: true },
|
||||
});
|
||||
|
||||
let directLiability = new Decimal(0);
|
||||
for (const p of directPlayers) {
|
||||
if (p.wallet) {
|
||||
directLiability = directLiability
|
||||
.add(p.wallet.availableBalance)
|
||||
.add(p.wallet.frozenBalance);
|
||||
}
|
||||
}
|
||||
|
||||
const childAgents = await this.prisma.agentProfile.findMany({
|
||||
where: { parentAgentId: agentId },
|
||||
});
|
||||
|
||||
let childExposure = new Decimal(0);
|
||||
for (const child of childAgents) {
|
||||
const exposure = Decimal.max(child.creditLimit, child.usedCredit);
|
||||
childExposure = childExposure.add(exposure);
|
||||
}
|
||||
|
||||
const usedCredit = directLiability.add(childExposure);
|
||||
|
||||
await this.prisma.agentProfile.update({
|
||||
where: { userId: agentId },
|
||||
data: {
|
||||
usedCredit,
|
||||
directPlayerLiability: directLiability,
|
||||
childAgentExposure: childExposure,
|
||||
},
|
||||
});
|
||||
|
||||
return usedCredit;
|
||||
}
|
||||
|
||||
async adjustCredit(
|
||||
agentId: bigint,
|
||||
amount: Decimal | number,
|
||||
operatorId: bigint,
|
||||
requestId: string,
|
||||
remark?: string,
|
||||
) {
|
||||
const amt = new Decimal(amount);
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: agentId },
|
||||
});
|
||||
if (!profile) throw new BadRequestException('Agent not found');
|
||||
|
||||
const creditBefore = profile.creditLimit;
|
||||
const creditAfter = creditBefore.add(amt);
|
||||
if (creditAfter.lt(0)) throw new BadRequestException('Credit limit cannot be negative');
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.agentProfile.update({
|
||||
where: { userId: agentId },
|
||||
data: { creditLimit: creditAfter },
|
||||
});
|
||||
|
||||
await tx.agentCreditTransaction.create({
|
||||
data: {
|
||||
agentId,
|
||||
transactionType: amt.gte(0) ? 'CREDIT_INCREASE' : 'CREDIT_DECREASE',
|
||||
amount: amt,
|
||||
creditBefore,
|
||||
creditAfter,
|
||||
operatorId,
|
||||
requestId,
|
||||
remark,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (profile.parentAgentId) {
|
||||
await this.recalculateUsedCredit(profile.parentAgentId);
|
||||
}
|
||||
|
||||
return { creditAfter };
|
||||
}
|
||||
|
||||
async depositToPlayer(
|
||||
agentId: bigint,
|
||||
playerId: bigint,
|
||||
amount: number,
|
||||
requestId: string,
|
||||
) {
|
||||
const player = await this.prisma.user.findUnique({ where: { id: playerId } });
|
||||
if (!player || player.parentId !== agentId) {
|
||||
throw new ForbiddenException('Can only deposit to direct players');
|
||||
}
|
||||
|
||||
const profile = await this.getProfile(agentId);
|
||||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||||
const amt = new Decimal(amount);
|
||||
|
||||
if (available.lt(amt)) {
|
||||
throw new BadRequestException('Insufficient agent credit');
|
||||
}
|
||||
|
||||
await this.wallet.deposit(playerId, amt, agentId, 'Agent deposit', requestId);
|
||||
await this.recalculateUsedCredit(agentId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async withdrawFromPlayer(
|
||||
agentId: bigint,
|
||||
playerId: bigint,
|
||||
amount: number,
|
||||
requestId: string,
|
||||
) {
|
||||
const player = await this.prisma.user.findUnique({ where: { id: playerId } });
|
||||
if (!player || player.parentId !== agentId) {
|
||||
throw new ForbiddenException('Can only withdraw from direct players');
|
||||
}
|
||||
|
||||
await this.wallet.withdraw(playerId, amount, agentId, 'Agent withdraw', requestId);
|
||||
await this.recalculateUsedCredit(agentId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async createAgent(
|
||||
operatorId: bigint,
|
||||
data: {
|
||||
username: string;
|
||||
password: string;
|
||||
level: number;
|
||||
parentAgentId?: bigint;
|
||||
creditLimit?: number;
|
||||
},
|
||||
) {
|
||||
if (data.level === 2 && !data.parentAgentId) {
|
||||
throw new BadRequestException('Level 2 agent requires parent');
|
||||
}
|
||||
|
||||
const hash = await this.auth.hashPassword(data.password);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
username: data.username,
|
||||
userType: 'AGENT',
|
||||
parentId: data.parentAgentId,
|
||||
agentLevel: data.level,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userAuth.create({
|
||||
data: { userId: user.id, passwordHash: hash },
|
||||
});
|
||||
|
||||
await tx.agentProfile.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
level: data.level,
|
||||
parentAgentId: data.parentAgentId,
|
||||
creditLimit: data.creditLimit ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Build closure table
|
||||
await tx.agentClosure.create({
|
||||
data: { ancestorId: user.id, descendantId: user.id, depth: 0 },
|
||||
});
|
||||
|
||||
if (data.parentAgentId) {
|
||||
const ancestors = await tx.agentClosure.findMany({
|
||||
where: { descendantId: data.parentAgentId },
|
||||
});
|
||||
for (const a of ancestors) {
|
||||
await tx.agentClosure.create({
|
||||
data: {
|
||||
ancestorId: a.ancestorId,
|
||||
descendantId: user.id,
|
||||
depth: a.depth + 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (data.parentAgentId) {
|
||||
await this.recalculateUsedCredit(data.parentAgentId);
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
async createPlayer(
|
||||
operatorId: bigint,
|
||||
data: { username: string; password: string; parentId: bigint },
|
||||
) {
|
||||
const hash = await this.auth.hashPassword(data.password);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
username: data.username,
|
||||
userType: 'PLAYER',
|
||||
parentId: data.parentId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userAuth.create({
|
||||
data: { userId: user.id, passwordHash: hash },
|
||||
});
|
||||
|
||||
await tx.wallet.create({
|
||||
data: { userId: user.id },
|
||||
});
|
||||
|
||||
await tx.userPreference.create({
|
||||
data: { userId: user.id },
|
||||
});
|
||||
|
||||
const parent = await tx.user.findUnique({ where: { id: data.parentId } });
|
||||
if (parent?.userType === 'AGENT') {
|
||||
await this.recalculateUsedCredit(data.parentId);
|
||||
}
|
||||
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
||||
async getDirectPlayers(agentId: bigint) {
|
||||
return this.prisma.user.findMany({
|
||||
where: { parentId: agentId, userType: 'PLAYER' },
|
||||
include: { wallet: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getChildAgents(agentId: bigint) {
|
||||
return this.prisma.agentProfile.findMany({
|
||||
where: { parentAgentId: agentId },
|
||||
include: { user: true },
|
||||
});
|
||||
}
|
||||
|
||||
async getReportSummary(agentId: bigint) {
|
||||
const profile = await this.getProfile(agentId);
|
||||
const players = await this.getDirectPlayers(agentId);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const todayBets = await this.prisma.bet.aggregate({
|
||||
where: {
|
||||
agentId,
|
||||
placedAt: { gte: today },
|
||||
},
|
||||
_sum: { stake: true, actualReturn: true },
|
||||
_count: true,
|
||||
});
|
||||
|
||||
return {
|
||||
profile,
|
||||
directPlayerCount: players.length,
|
||||
directPlayerTotalBalance: players.reduce(
|
||||
(sum, p) =>
|
||||
sum +
|
||||
Number(p.wallet?.availableBalance ?? 0) +
|
||||
Number(p.wallet?.frozenBalance ?? 0),
|
||||
0,
|
||||
),
|
||||
todayBetCount: todayBets._count,
|
||||
todayStake: todayBets._sum.stake,
|
||||
todayReturn: todayBets._sum.actualReturn,
|
||||
};
|
||||
}
|
||||
}
|
||||
46
apps/api/src/app.module.ts
Normal file
46
apps/api/src/app.module.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { JwtAuthGuard } from './auth/guards';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { AgentsModule } from './agents/agents.module';
|
||||
import { WalletModule } from './wallet/wallet.module';
|
||||
import { MatchesModule } from './matches/matches.module';
|
||||
import { MarketsModule } from './markets/markets.module';
|
||||
import { BetsModule } from './bets/bets.module';
|
||||
import { SettlementModule } from './settlement/settlement.module';
|
||||
import { CashbackModule } from './cashback/cashback.module';
|
||||
import { ContentModule } from './content/content.module';
|
||||
import { I18nModule } from './i18n/i18n.module';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { PlayerModule } from './player/player.module';
|
||||
import { AgentPortalModule } from './agent-portal/agent-portal.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ScheduleModule.forRoot(),
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
AgentsModule,
|
||||
WalletModule,
|
||||
MatchesModule,
|
||||
MarketsModule,
|
||||
BetsModule,
|
||||
SettlementModule,
|
||||
CashbackModule,
|
||||
ContentModule,
|
||||
I18nModule,
|
||||
AuditModule,
|
||||
AdminModule,
|
||||
PlayerModule,
|
||||
AgentPortalModule,
|
||||
],
|
||||
providers: [{ provide: APP_GUARD, useClass: JwtAuthGuard }],
|
||||
})
|
||||
export class AppModule {}
|
||||
9
apps/api/src/audit/audit.module.ts
Normal file
9
apps/api/src/audit/audit.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AuditService],
|
||||
exports: [AuditService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
48
apps/api/src/audit/audit.service.ts
Normal file
48
apps/api/src/audit/audit.service.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async log(data: {
|
||||
operatorId?: bigint;
|
||||
operatorType: string;
|
||||
action: string;
|
||||
module: string;
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
beforeData?: unknown;
|
||||
afterData?: unknown;
|
||||
ipAddress?: string;
|
||||
}) {
|
||||
return this.prisma.auditLog.create({
|
||||
data: {
|
||||
operatorId: data.operatorId,
|
||||
operatorType: data.operatorType,
|
||||
action: data.action,
|
||||
module: data.module,
|
||||
targetType: data.targetType,
|
||||
targetId: data.targetId,
|
||||
beforeData: data.beforeData ? JSON.stringify(data.beforeData) : null,
|
||||
afterData: data.afterData ? JSON.stringify(data.afterData) : null,
|
||||
ipAddress: data.ipAddress,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async list(page = 1, pageSize = 50, module?: string) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const where = module ? { module } : {};
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.auditLog.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
}
|
||||
45
apps/api/src/auth/auth.controller.ts
Normal file
45
apps/api/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto, ChangePasswordDto } from './auth.dto';
|
||||
import { Public, CurrentUser } from '../common/decorators';
|
||||
import { JwtAuthGuard } from './guards';
|
||||
import { jsonResponse } from '../common/filters';
|
||||
|
||||
@ApiTags('Auth')
|
||||
@Controller()
|
||||
export class AuthController {
|
||||
constructor(private auth: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post('player/auth/login')
|
||||
async playerLogin(@Body() dto: LoginDto) {
|
||||
const result = await this.auth.login(dto.username, dto.password, 'player');
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('admin/auth/login')
|
||||
async adminLogin(@Body() dto: LoginDto) {
|
||||
const result = await this.auth.login(dto.username, dto.password, 'admin');
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('agent/auth/login')
|
||||
async agentLogin(@Body() dto: LoginDto) {
|
||||
const result = await this.auth.login(dto.username, dto.password, 'agent');
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@Post('player/auth/change-password')
|
||||
async playerChangePassword(
|
||||
@CurrentUser('id') userId: bigint,
|
||||
@Body() dto: ChangePasswordDto,
|
||||
) {
|
||||
await this.auth.changePassword(userId, dto.oldPassword, dto.newPassword);
|
||||
return jsonResponse(null, 'Password changed');
|
||||
}
|
||||
}
|
||||
24
apps/api/src/auth/auth.dto.ts
Normal file
24
apps/api/src/auth/auth.dto.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
username!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
oldPassword!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
newPassword!: string;
|
||||
}
|
||||
25
apps/api/src/auth/auth.module.ts
Normal file
25
apps/api/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { AuthController } from './auth.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get('JWT_SECRET', 'dev-secret'),
|
||||
signOptions: { expiresIn: config.get('JWT_PLAYER_EXPIRES', '24h') },
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
controllers: [AuthController],
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
111
apps/api/src/auth/auth.service.ts
Normal file
111
apps/api/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Injectable, UnauthorizedException, ForbiddenException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const MAX_LOGIN_FAILS = 5;
|
||||
const LOCK_DURATION_MS = 15 * 60 * 1000;
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
username: string;
|
||||
userType: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwt: JwtService,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
async login(username: string, password: string, portal: 'player' | 'admin' | 'agent') {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { username },
|
||||
include: { auth: true, adminRole: { include: { role: true } } },
|
||||
});
|
||||
|
||||
if (!user || !user.auth) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const expectedType = portal === 'admin' ? 'ADMIN' : portal === 'agent' ? 'AGENT' : 'PLAYER';
|
||||
if (user.userType !== expectedType) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
if (user.status === 'DISABLED') {
|
||||
throw new ForbiddenException('Account disabled');
|
||||
}
|
||||
|
||||
if (user.auth.lockedUntil && user.auth.lockedUntil > new Date()) {
|
||||
throw new ForbiddenException('Account locked, try again later');
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.auth.passwordHash);
|
||||
if (!valid) {
|
||||
const failCount = user.auth.loginFailCount + 1;
|
||||
const lockedUntil =
|
||||
failCount >= MAX_LOGIN_FAILS ? new Date(Date.now() + LOCK_DURATION_MS) : null;
|
||||
await this.prisma.userAuth.update({
|
||||
where: { userId: user.id },
|
||||
data: { loginFailCount: failCount, lockedUntil },
|
||||
});
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
await this.prisma.userAuth.update({
|
||||
where: { userId: user.id },
|
||||
data: { loginFailCount: 0, lockedUntil: null, lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
const expiresIn =
|
||||
portal === 'admin'
|
||||
? this.config.get('JWT_ADMIN_EXPIRES', '2h')
|
||||
: portal === 'agent'
|
||||
? this.config.get('JWT_AGENT_EXPIRES', '8h')
|
||||
: this.config.get('JWT_PLAYER_EXPIRES', '24h');
|
||||
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id.toString(),
|
||||
username: user.username,
|
||||
userType: user.userType,
|
||||
role: user.adminRole?.role?.code,
|
||||
};
|
||||
|
||||
const token = this.jwt.sign(payload, { expiresIn });
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: user.id.toString(),
|
||||
username: user.username,
|
||||
userType: user.userType,
|
||||
locale: user.locale,
|
||||
role: user.adminRole?.role?.code,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async changePassword(userId: bigint, oldPassword: string, newPassword: string) {
|
||||
const auth = await this.prisma.userAuth.findUnique({ where: { userId } });
|
||||
if (!auth) throw new UnauthorizedException('User not found');
|
||||
|
||||
const valid = await bcrypt.compare(oldPassword, auth.passwordHash);
|
||||
if (!valid) throw new UnauthorizedException('Invalid old password');
|
||||
|
||||
const hash = await bcrypt.hash(newPassword, 10);
|
||||
await this.prisma.userAuth.update({
|
||||
where: { userId },
|
||||
data: { passwordHash: hash },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 10);
|
||||
}
|
||||
}
|
||||
87
apps/api/src/auth/guards.ts
Normal file
87
apps/api/src/auth/guards.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException, ForbiddenException } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { IS_PUBLIC_KEY, PERMISSIONS_KEY } from '../common/decorators';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
handleRequest<TUser = unknown>(err: Error | null, user: TUser): TUser {
|
||||
if (err || !user) throw err || new UnauthorizedException();
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PermissionsGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const required = this.reflector.getAllAndOverride<string[]>(PERMISSIONS_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!required?.length) return true;
|
||||
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
const userPerms: string[] = user?.permissions ?? [];
|
||||
if (user?.role === 'SUPER_ADMIN') return true;
|
||||
|
||||
const hasAll = required.every((p) => userPerms.includes(p));
|
||||
if (!hasAll) throw new ForbiddenException('Insufficient permissions');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function UserTypeGuard(...types: string[]) {
|
||||
@Injectable()
|
||||
class MixedUserTypeGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
if (!types.includes(user?.userType)) {
|
||||
throw new ForbiddenException('Access denied for this portal');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return MixedUserTypeGuard;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PlayerGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
if (user?.userType !== 'PLAYER') throw new ForbiddenException('Player access only');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
if (user?.userType !== 'ADMIN') throw new ForbiddenException('Admin access only');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AgentGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
if (user?.userType !== 'AGENT') throw new ForbiddenException('Agent access only');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
54
apps/api/src/auth/jwt.strategy.ts
Normal file
54
apps/api/src/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { JwtPayload } from './auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
private prisma: PrismaService,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.get('JWT_SECRET', 'dev-secret'),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: BigInt(payload.sub) },
|
||||
include: {
|
||||
adminRole: {
|
||||
include: {
|
||||
role: {
|
||||
include: {
|
||||
permissions: {
|
||||
include: { permission: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!user || user.status !== 'ACTIVE') {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
const permissions =
|
||||
user.adminRole?.role?.permissions?.map((rp) => rp.permission.code) ?? [];
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
userType: user.userType,
|
||||
parentId: user.parentId,
|
||||
agentLevel: user.agentLevel,
|
||||
locale: user.locale,
|
||||
role: payload.role,
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
}
|
||||
10
apps/api/src/bets/bets.module.ts
Normal file
10
apps/api/src/bets/bets.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BetsService } from './bets.service';
|
||||
import { WalletModule } from '../wallet/wallet.module';
|
||||
|
||||
@Module({
|
||||
imports: [WalletModule],
|
||||
providers: [BetsService],
|
||||
exports: [BetsService],
|
||||
})
|
||||
export class BetsModule {}
|
||||
211
apps/api/src/bets/bets.service.ts
Normal file
211
apps/api/src/bets/bets.service.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { WalletService } from '../wallet/wallet.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { generateBetNo } from '../common/decorators';
|
||||
import { isQuarterHandicapOrTotal } from '../settlement/settlement-calculator';
|
||||
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
|
||||
|
||||
interface BetSelectionInput {
|
||||
selectionId: bigint;
|
||||
oddsVersion: bigint;
|
||||
stake?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BetsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
) {}
|
||||
|
||||
private async validateSelection(selectionId: bigint, oddsVersion: bigint) {
|
||||
const selection = await this.prisma.marketSelection.findUnique({
|
||||
where: { id: selectionId },
|
||||
include: { market: { include: { match: true } } },
|
||||
});
|
||||
|
||||
if (!selection) throw new BadRequestException('Selection not found');
|
||||
if (selection.status !== 'OPEN') throw new BadRequestException('Selection closed');
|
||||
if (selection.market.status !== 'OPEN') throw new BadRequestException('Market closed');
|
||||
if (selection.market.match.status !== 'PUBLISHED') {
|
||||
throw new BadRequestException('Match not available for betting');
|
||||
}
|
||||
if (selection.oddsVersion !== oddsVersion) {
|
||||
throw new BadRequestException('Odds changed, please confirm again');
|
||||
}
|
||||
|
||||
return selection;
|
||||
}
|
||||
|
||||
async placeSingleBet(
|
||||
userId: bigint,
|
||||
agentId: bigint | null,
|
||||
selectionId: bigint,
|
||||
oddsVersion: bigint,
|
||||
stake: number,
|
||||
requestId: string,
|
||||
) {
|
||||
if (stake <= 0) throw new BadRequestException('Invalid stake');
|
||||
|
||||
const existing = await this.prisma.bet.findUnique({
|
||||
where: { userId_requestId: { userId, requestId } },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const selection = await this.validateSelection(selectionId, oddsVersion);
|
||||
const odds = new Decimal(selection.odds.toString());
|
||||
const stakeDec = new Decimal(stake);
|
||||
const potentialReturn = stakeDec.mul(odds);
|
||||
const betNo = generateBetNo();
|
||||
|
||||
const bet = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.bet.create({
|
||||
data: {
|
||||
betNo,
|
||||
userId,
|
||||
agentId,
|
||||
betType: 'SINGLE',
|
||||
stake: stakeDec,
|
||||
totalOdds: odds,
|
||||
potentialReturn,
|
||||
requestId,
|
||||
selections: {
|
||||
create: {
|
||||
matchId: selection.market.matchId,
|
||||
marketId: selection.marketId,
|
||||
selectionId: selection.id,
|
||||
marketType: selection.market.marketType,
|
||||
period: selection.market.period,
|
||||
selectionNameSnapshot: selection.selectionName,
|
||||
handicapLine: selection.market.lineValue,
|
||||
totalLine: selection.market.lineValue,
|
||||
odds,
|
||||
oddsVersion,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { selections: true },
|
||||
});
|
||||
|
||||
await this.wallet.freezeForBet(userId, stakeDec, betNo);
|
||||
return created;
|
||||
});
|
||||
|
||||
return bet;
|
||||
}
|
||||
|
||||
async placeParlayBet(
|
||||
userId: bigint,
|
||||
agentId: bigint | null,
|
||||
legs: BetSelectionInput[],
|
||||
stake: number,
|
||||
requestId: string,
|
||||
) {
|
||||
if (stake <= 0) throw new BadRequestException('Invalid stake');
|
||||
if (legs.length < PARLAY_MIN_LEGS || legs.length > PARLAY_MAX_LEGS) {
|
||||
throw new BadRequestException(`Parlay must have ${PARLAY_MIN_LEGS}-${PARLAY_MAX_LEGS} legs`);
|
||||
}
|
||||
|
||||
const existing = await this.prisma.bet.findUnique({
|
||||
where: { userId_requestId: { userId, requestId } },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const selections: Awaited<ReturnType<typeof this.validateSelection>>[] = [];
|
||||
const matchIds = new Set<string>();
|
||||
|
||||
for (const leg of legs) {
|
||||
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion);
|
||||
|
||||
if (sel.market.marketType === 'OUTRIGHT_WINNER') {
|
||||
throw new BadRequestException('Outright cannot be in parlay');
|
||||
}
|
||||
|
||||
const line = sel.market.lineValue ? Number(sel.market.lineValue) : null;
|
||||
if (
|
||||
['FT_HANDICAP', 'HT_HANDICAP', 'FT_OVER_UNDER', 'HT_OVER_UNDER'].includes(
|
||||
sel.market.marketType,
|
||||
) &&
|
||||
isQuarterHandicapOrTotal(line)
|
||||
) {
|
||||
throw new BadRequestException('Quarter line markets cannot be in parlay');
|
||||
}
|
||||
|
||||
const matchKey = sel.market.matchId.toString();
|
||||
if (matchIds.has(matchKey)) {
|
||||
throw new BadRequestException('Same match cannot be in parlay');
|
||||
}
|
||||
matchIds.add(matchKey);
|
||||
selections.push(sel);
|
||||
}
|
||||
|
||||
let totalOdds = new Decimal(1);
|
||||
for (const sel of selections) {
|
||||
totalOdds = totalOdds.mul(sel.odds.toString());
|
||||
}
|
||||
|
||||
const stakeDec = new Decimal(stake);
|
||||
const potentialReturn = stakeDec.mul(totalOdds);
|
||||
const betNo = generateBetNo();
|
||||
|
||||
const bet = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.bet.create({
|
||||
data: {
|
||||
betNo,
|
||||
userId,
|
||||
agentId,
|
||||
betType: 'PARLAY',
|
||||
stake: stakeDec,
|
||||
totalOdds,
|
||||
potentialReturn,
|
||||
requestId,
|
||||
selections: {
|
||||
create: selections.map((sel, i) => ({
|
||||
matchId: sel.market.matchId,
|
||||
marketId: sel.marketId,
|
||||
selectionId: sel.id,
|
||||
marketType: sel.market.marketType,
|
||||
period: sel.market.period,
|
||||
selectionNameSnapshot: sel.selectionName,
|
||||
handicapLine: sel.market.lineValue,
|
||||
totalLine: sel.market.lineValue,
|
||||
odds: sel.odds,
|
||||
oddsVersion: legs[i].oddsVersion,
|
||||
sortOrder: i,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { selections: true },
|
||||
});
|
||||
|
||||
await this.wallet.freezeForBet(userId, stakeDec, betNo);
|
||||
return created;
|
||||
});
|
||||
|
||||
return bet;
|
||||
}
|
||||
|
||||
async getUserBets(userId: bigint, status?: string, page = 1, pageSize = 20) {
|
||||
const where = { userId, ...(status ? { status } : {}) };
|
||||
const skip = (page - 1) * pageSize;
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.bet.findMany({
|
||||
where,
|
||||
include: { selections: true },
|
||||
orderBy: { placedAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.bet.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getBetByNo(betNo: string, userId?: bigint) {
|
||||
return this.prisma.bet.findFirst({
|
||||
where: { betNo, ...(userId ? { userId } : {}) },
|
||||
include: { selections: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
10
apps/api/src/cashback/cashback.module.ts
Normal file
10
apps/api/src/cashback/cashback.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CashbackService } from './cashback.service';
|
||||
import { WalletModule } from '../wallet/wallet.module';
|
||||
|
||||
@Module({
|
||||
imports: [WalletModule],
|
||||
providers: [CashbackService],
|
||||
exports: [CashbackService],
|
||||
})
|
||||
export class CashbackModule {}
|
||||
108
apps/api/src/cashback/cashback.service.ts
Normal file
108
apps/api/src/cashback/cashback.service.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { WalletService } from '../wallet/wallet.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { generateBatchNo } from '../common/decorators';
|
||||
|
||||
@Injectable()
|
||||
export class CashbackService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private wallet: WalletService,
|
||||
) {}
|
||||
|
||||
async previewBatch(periodStart: Date, periodEnd: Date) {
|
||||
const settledBets = await this.prisma.bet.findMany({
|
||||
where: {
|
||||
status: { in: ['WON', 'LOST', 'SETTLED'] },
|
||||
settledAt: { gte: periodStart, lte: periodEnd },
|
||||
},
|
||||
include: { user: { include: { agentProfile: true } } },
|
||||
});
|
||||
|
||||
const playerStakes = new Map<string, { userId: bigint; stake: Decimal; rate: Decimal }>();
|
||||
|
||||
for (const bet of settledBets) {
|
||||
if (bet.status === 'PUSH' || bet.status === 'VOID') continue;
|
||||
|
||||
const key = bet.userId.toString();
|
||||
const existing = playerStakes.get(key) ?? {
|
||||
userId: bet.userId,
|
||||
stake: new Decimal(0),
|
||||
rate: new Decimal(0.01),
|
||||
};
|
||||
existing.stake = existing.stake.add(bet.stake);
|
||||
playerStakes.set(key, existing);
|
||||
}
|
||||
|
||||
const items = Array.from(playerStakes.values()).map((p) => ({
|
||||
userId: p.userId,
|
||||
effectiveStake: p.stake,
|
||||
rate: p.rate,
|
||||
amount: p.stake.mul(p.rate),
|
||||
}));
|
||||
|
||||
const totalAmount = items.reduce((s, i) => s.add(i.amount), new Decimal(0));
|
||||
|
||||
const batch = await this.prisma.cashbackBatch.create({
|
||||
data: {
|
||||
batchNo: generateBatchNo('CB'),
|
||||
periodStart,
|
||||
periodEnd,
|
||||
status: 'PREVIEW',
|
||||
totalAmount,
|
||||
playerCount: items.length,
|
||||
},
|
||||
});
|
||||
|
||||
for (const item of items) {
|
||||
await this.prisma.cashbackItem.create({
|
||||
data: {
|
||||
batchId: batch.id,
|
||||
userId: item.userId,
|
||||
effectiveStake: item.effectiveStake,
|
||||
rate: item.rate,
|
||||
amount: item.amount,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { batch, items, totalAmount };
|
||||
}
|
||||
|
||||
async confirmBatch(batchId: bigint, operatorId: bigint) {
|
||||
const batch = await this.prisma.cashbackBatch.findUnique({
|
||||
where: { id: batchId },
|
||||
include: { items: true },
|
||||
});
|
||||
if (!batch) throw new BadRequestException('Batch not found');
|
||||
if (batch.status !== 'PREVIEW') throw new BadRequestException('Already confirmed');
|
||||
|
||||
for (const item of batch.items) {
|
||||
if (item.amount.gt(0)) {
|
||||
await this.wallet.deposit(
|
||||
item.userId,
|
||||
item.amount,
|
||||
operatorId,
|
||||
`Cashback batch ${batch.batchNo}`,
|
||||
batch.batchNo,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.cashbackBatch.update({
|
||||
where: { id: batchId },
|
||||
data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async getUserCashbacks(userId: bigint) {
|
||||
return this.prisma.cashbackItem.findMany({
|
||||
where: { userId },
|
||||
include: { batch: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
48
apps/api/src/common/decorators.ts
Normal file
48
apps/api/src/common/decorators.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createParamDecorator, ExecutionContext, SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
export const PERMISSIONS_KEY = 'permissions';
|
||||
export const RequirePermissions = (...permissions: string[]) =>
|
||||
SetMetadata(PERMISSIONS_KEY, permissions);
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: string | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
return data ? user?.[data] : user;
|
||||
},
|
||||
);
|
||||
|
||||
export function generateBetNo(): string {
|
||||
const ts = Date.now().toString(36).toUpperCase();
|
||||
const rand = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
return `BET${ts}${rand}`;
|
||||
}
|
||||
|
||||
export function generateTransactionId(): string {
|
||||
const ts = Date.now().toString(36).toUpperCase();
|
||||
const rand = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
return `TXN${ts}${rand}`;
|
||||
}
|
||||
|
||||
export function generateBatchNo(prefix: string): string {
|
||||
const ts = Date.now().toString(36).toUpperCase();
|
||||
return `${prefix}${ts}`;
|
||||
}
|
||||
|
||||
export function serializeBigInt(obj: unknown): unknown {
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
if (typeof obj === 'bigint') return obj.toString();
|
||||
if (obj instanceof Date) return obj.toISOString();
|
||||
if (Array.isArray(obj)) return obj.map(serializeBigInt);
|
||||
if (typeof obj === 'object') {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||
result[key] = serializeBigInt(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
42
apps/api/src/common/filters.ts
Normal file
42
apps/api/src/common/filters.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { serializeBigInt } from './decorators';
|
||||
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'Internal server error';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const res = exception.getResponse();
|
||||
message = typeof res === 'string' ? res : (res as { message?: string }).message || message;
|
||||
} else if (exception instanceof Error) {
|
||||
message = exception.message;
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
success: false,
|
||||
error: message,
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function jsonResponse<T>(data: T, message?: string) {
|
||||
return {
|
||||
success: true,
|
||||
data: serializeBigInt(data),
|
||||
message,
|
||||
};
|
||||
}
|
||||
8
apps/api/src/content/content.module.ts
Normal file
8
apps/api/src/content/content.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ContentService } from './content.service';
|
||||
|
||||
@Module({
|
||||
providers: [ContentService],
|
||||
exports: [ContentService],
|
||||
})
|
||||
export class ContentModule {}
|
||||
59
apps/api/src/content/content.service.ts
Normal file
59
apps/api/src/content/content.service.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class ContentService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async listActive(contentType: string, locale: string) {
|
||||
const now = new Date();
|
||||
const items = await this.prisma.content.findMany({
|
||||
where: {
|
||||
contentType,
|
||||
status: 'ACTIVE',
|
||||
OR: [{ startTime: null }, { startTime: { lte: now } }],
|
||||
AND: [{ OR: [{ endTime: null }, { endTime: { gte: now } }] }],
|
||||
},
|
||||
include: { translations: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
return items.map((item) => {
|
||||
const t =
|
||||
item.translations.find((tr) => tr.locale === locale) ||
|
||||
item.translations.find((tr) => tr.locale === 'en-US') ||
|
||||
item.translations[0];
|
||||
return { ...item, translation: t };
|
||||
});
|
||||
}
|
||||
|
||||
async create(data: {
|
||||
contentType: string;
|
||||
sortOrder?: number;
|
||||
linkType?: string;
|
||||
linkTarget?: string;
|
||||
translations: Array<{ locale: string; title?: string; body?: string; imageUrl?: string }>;
|
||||
}) {
|
||||
return this.prisma.content.create({
|
||||
data: {
|
||||
contentType: data.contentType,
|
||||
sortOrder: data.sortOrder ?? 0,
|
||||
linkType: data.linkType,
|
||||
linkTarget: data.linkTarget,
|
||||
status: 'ACTIVE',
|
||||
translations: {
|
||||
create: data.translations,
|
||||
},
|
||||
},
|
||||
include: { translations: true },
|
||||
});
|
||||
}
|
||||
|
||||
async listAll(contentType?: string) {
|
||||
return this.prisma.content.findMany({
|
||||
where: contentType ? { contentType } : {},
|
||||
include: { translations: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
8
apps/api/src/i18n/i18n.module.ts
Normal file
8
apps/api/src/i18n/i18n.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { I18nService } from './i18n.service';
|
||||
|
||||
@Module({
|
||||
providers: [I18nService],
|
||||
exports: [I18nService],
|
||||
})
|
||||
export class I18nModule {}
|
||||
55
apps/api/src/i18n/i18n.service.ts
Normal file
55
apps/api/src/i18n/i18n.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { DEFAULT_LOCALE } from '@thebet365/shared';
|
||||
|
||||
const FALLBACK_ORDER = ['en-US', 'zh-CN', 'ms-MY'];
|
||||
|
||||
@Injectable()
|
||||
export class I18nService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getMessages(locale: string) {
|
||||
const messages = await this.prisma.i18nMessage.findMany({
|
||||
where: { locale: { in: [locale, ...FALLBACK_ORDER] } },
|
||||
});
|
||||
|
||||
const byKey: Record<string, Record<string, string>> = {};
|
||||
for (const m of messages) {
|
||||
if (!byKey[m.msgKey]) byKey[m.msgKey] = {};
|
||||
byKey[m.msgKey][m.locale] = m.value;
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, locales] of Object.entries(byKey)) {
|
||||
result[key] =
|
||||
locales[locale] ||
|
||||
FALLBACK_ORDER.map((l) => locales[l]).find(Boolean) ||
|
||||
key;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async upsertMessage(msgKey: string, locale: string, value: string) {
|
||||
return this.prisma.i18nMessage.upsert({
|
||||
where: { msgKey_locale: { msgKey, locale } },
|
||||
create: { msgKey, locale, value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
|
||||
async listMissing() {
|
||||
const keys = await this.prisma.i18nMessage.groupBy({ by: ['msgKey'] });
|
||||
const locales = ['zh-CN', 'ms-MY', 'en-US'];
|
||||
const missing = [];
|
||||
|
||||
for (const { msgKey } of keys) {
|
||||
for (const locale of locales) {
|
||||
const exists = await this.prisma.i18nMessage.findUnique({
|
||||
where: { msgKey_locale: { msgKey, locale } },
|
||||
});
|
||||
if (!exists) missing.push({ msgKey, locale });
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
}
|
||||
111
apps/api/src/integration.spec.ts
Normal file
111
apps/api/src/integration.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
settleSelection,
|
||||
calculatePayout,
|
||||
isQuarterHandicapOrTotal,
|
||||
} from './settlement/settlement-calculator';
|
||||
|
||||
/**
|
||||
* Agent credit & wallet integration scenarios (A001-A007)
|
||||
* These tests validate business rules without DB dependency.
|
||||
*/
|
||||
describe('Agent Credit Rules', () => {
|
||||
it('A001: deposit increases player balance and reduces agent available credit', () => {
|
||||
const creditLimit = 10000;
|
||||
const usedCredit = 1000;
|
||||
const depositAmount = 500;
|
||||
const newUsed = usedCredit + depositAmount;
|
||||
expect(creditLimit - newUsed).toBe(8500);
|
||||
});
|
||||
|
||||
it('A002: bet freeze does not change total balance or agent credit', () => {
|
||||
const available = 1000;
|
||||
const frozen = 0;
|
||||
const stake = 100;
|
||||
const totalBefore = available + frozen;
|
||||
const totalAfter = (available - stake) + (frozen + stake);
|
||||
expect(totalAfter).toBe(totalBefore);
|
||||
});
|
||||
|
||||
it('A003: player lose releases agent credit', () => {
|
||||
const usedBefore = 1000;
|
||||
const stake = 100;
|
||||
const usedAfter = usedBefore - stake;
|
||||
expect(usedAfter).toBe(900);
|
||||
});
|
||||
|
||||
it('A004: player win increases agent credit usage', () => {
|
||||
const usedBefore = 1000;
|
||||
const payout = 185;
|
||||
const stake = 100;
|
||||
const netGain = payout - stake;
|
||||
const usedAfter = usedBefore + netGain;
|
||||
expect(usedAfter).toBe(1085);
|
||||
});
|
||||
|
||||
it('A005: negative credit blocks further deposit', () => {
|
||||
const creditLimit = 1000;
|
||||
const usedCredit = 1200;
|
||||
const available = creditLimit - usedCredit;
|
||||
expect(available).toBeLessThan(0);
|
||||
const canDeposit = available > 0;
|
||||
expect(canDeposit).toBe(false);
|
||||
});
|
||||
|
||||
it('A006: level 1 allocating credit to level 2 reduces available', () => {
|
||||
const available = 10000;
|
||||
const allocate = 3000;
|
||||
expect(available - allocate).toBe(7000);
|
||||
});
|
||||
|
||||
it('A007: non-direct player deposit should be rejected', () => {
|
||||
const agentId = BigInt(1);
|
||||
const playerParentId = BigInt(2);
|
||||
const canDeposit = agentId === playerParentId;
|
||||
expect(canDeposit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bet Validation Rules (B001-B010)', () => {
|
||||
it('B003: odds version mismatch should reject', () => {
|
||||
const submitted = BigInt(1);
|
||||
const current = BigInt(2);
|
||||
expect(submitted === current).toBe(false);
|
||||
});
|
||||
|
||||
it('B007: same match in parlay rejected', () => {
|
||||
const matchIds = ['1', '1', '2'];
|
||||
const unique = new Set(matchIds);
|
||||
expect(unique.size !== matchIds.length).toBe(true);
|
||||
});
|
||||
|
||||
it('B008: quarter line in parlay rejected', () => {
|
||||
expect(isQuarterHandicapOrTotal(-0.25)).toBe(true);
|
||||
expect(isQuarterHandicapOrTotal(-0.5)).toBe(false);
|
||||
});
|
||||
|
||||
it('B009: more than 5 legs rejected', () => {
|
||||
const legs = 6;
|
||||
expect(legs > 5).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settlement payout accuracy', () => {
|
||||
it('half win payout formula', () => {
|
||||
const payout = calculatePayout(100, 1.85, 'HALF_WIN');
|
||||
expect(payout.toNumber()).toBe(142.5);
|
||||
});
|
||||
|
||||
it('half lose payout formula', () => {
|
||||
const payout = calculatePayout(100, 1.85, 'HALF_LOSE');
|
||||
expect(payout.toNumber()).toBe(50);
|
||||
});
|
||||
|
||||
it('S004: 0-0 odd/even is even', () => {
|
||||
const result = settleSelection({
|
||||
marketType: 'FT_ODD_EVEN',
|
||||
selectionCode: 'EVEN',
|
||||
score: { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 },
|
||||
});
|
||||
expect(result).toBe('WIN');
|
||||
});
|
||||
});
|
||||
33
apps/api/src/main.ts
Normal file
33
apps/api/src/main.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
app.enableCors({ origin: true, credentials: true });
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('TheBet365 API')
|
||||
.setDescription('足球投注平台 MVP API')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
SwaggerModule.setup('api/docs', app, SwaggerModule.createDocument(app, config));
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port);
|
||||
console.log(`API running on http://localhost:${port}`);
|
||||
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
8
apps/api/src/markets/markets.module.ts
Normal file
8
apps/api/src/markets/markets.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MarketsService } from './markets.service';
|
||||
|
||||
@Module({
|
||||
providers: [MarketsService],
|
||||
exports: [MarketsService],
|
||||
})
|
||||
export class MarketsModule {}
|
||||
203
apps/api/src/markets/markets.service.ts
Normal file
203
apps/api/src/markets/markets.service.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import {
|
||||
FT_CORRECT_SCORE_TEMPLATE,
|
||||
HT_CORRECT_SCORE_TEMPLATE,
|
||||
} from '../settlement/settlement-calculator';
|
||||
|
||||
@Injectable()
|
||||
export class MarketsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async generateTemplates(matchId: bigint, marketTypes: string[]) {
|
||||
const match = await this.prisma.match.findUnique({ where: { id: matchId } });
|
||||
if (!match) throw new NotFoundException('Match not found');
|
||||
|
||||
const created = [];
|
||||
|
||||
for (const marketType of marketTypes) {
|
||||
const existing = await this.prisma.market.findFirst({
|
||||
where: { matchId, marketType },
|
||||
});
|
||||
if (existing) continue;
|
||||
|
||||
const config = this.getMarketConfig(marketType);
|
||||
const market = await this.prisma.market.create({
|
||||
data: {
|
||||
matchId,
|
||||
marketType,
|
||||
period: config.period,
|
||||
lineValue: config.lineValue,
|
||||
allowSingle: true,
|
||||
allowParlay: config.allowParlay,
|
||||
sortOrder: config.sortOrder,
|
||||
selections: {
|
||||
create: config.selections.map((s, i) => ({
|
||||
selectionCode: s.code,
|
||||
selectionName: s.name,
|
||||
odds: s.odds ?? 1.01,
|
||||
sortOrder: i,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { selections: true },
|
||||
});
|
||||
created.push(market);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
private getMarketConfig(marketType: string) {
|
||||
const configs: Record<string, {
|
||||
period: string;
|
||||
lineValue?: number;
|
||||
allowParlay: boolean;
|
||||
sortOrder: number;
|
||||
selections: Array<{ code: string; name: string; odds?: number }>;
|
||||
}> = {
|
||||
FT_1X2: {
|
||||
period: 'FT',
|
||||
allowParlay: true,
|
||||
sortOrder: 1,
|
||||
selections: [
|
||||
{ code: 'HOME', name: 'Home', odds: 2.5 },
|
||||
{ code: 'DRAW', name: 'Draw', odds: 3.2 },
|
||||
{ code: 'AWAY', name: 'Away', odds: 2.8 },
|
||||
],
|
||||
},
|
||||
HT_1X2: {
|
||||
period: 'HT',
|
||||
allowParlay: true,
|
||||
sortOrder: 5,
|
||||
selections: [
|
||||
{ code: 'HOME', name: 'HT Home', odds: 3.0 },
|
||||
{ code: 'DRAW', name: 'HT Draw', odds: 2.0 },
|
||||
{ code: 'AWAY', name: 'HT Away', odds: 3.5 },
|
||||
],
|
||||
},
|
||||
FT_HANDICAP: {
|
||||
period: 'FT',
|
||||
lineValue: -0.5,
|
||||
allowParlay: true,
|
||||
sortOrder: 2,
|
||||
selections: [
|
||||
{ code: 'HOME', name: 'Home -0.5', odds: 1.9 },
|
||||
{ code: 'AWAY', name: 'Away +0.5', odds: 1.9 },
|
||||
],
|
||||
},
|
||||
HT_HANDICAP: {
|
||||
period: 'HT',
|
||||
lineValue: -0.5,
|
||||
allowParlay: true,
|
||||
sortOrder: 6,
|
||||
selections: [
|
||||
{ code: 'HOME', name: 'HT Home -0.5', odds: 1.9 },
|
||||
{ code: 'AWAY', name: 'HT Away +0.5', odds: 1.9 },
|
||||
],
|
||||
},
|
||||
FT_OVER_UNDER: {
|
||||
period: 'FT',
|
||||
lineValue: 2.5,
|
||||
allowParlay: true,
|
||||
sortOrder: 3,
|
||||
selections: [
|
||||
{ code: 'OVER', name: 'Over 2.5', odds: 1.85 },
|
||||
{ code: 'UNDER', name: 'Under 2.5', odds: 1.95 },
|
||||
],
|
||||
},
|
||||
HT_OVER_UNDER: {
|
||||
period: 'HT',
|
||||
lineValue: 1.5,
|
||||
allowParlay: true,
|
||||
sortOrder: 7,
|
||||
selections: [
|
||||
{ code: 'OVER', name: 'HT Over 1.5', odds: 2.0 },
|
||||
{ code: 'UNDER', name: 'HT Under 1.5', odds: 1.75 },
|
||||
],
|
||||
},
|
||||
FT_ODD_EVEN: {
|
||||
period: 'FT',
|
||||
allowParlay: true,
|
||||
sortOrder: 4,
|
||||
selections: [
|
||||
{ code: 'ODD', name: 'Odd', odds: 1.9 },
|
||||
{ code: 'EVEN', name: 'Even', odds: 1.9 },
|
||||
],
|
||||
},
|
||||
FT_CORRECT_SCORE: {
|
||||
period: 'FT',
|
||||
allowParlay: true,
|
||||
sortOrder: 8,
|
||||
selections: FT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
||||
code,
|
||||
name: code.replace('SCORE_', '').replace('_', '-') || code,
|
||||
odds: 8.0,
|
||||
})),
|
||||
},
|
||||
HT_CORRECT_SCORE: {
|
||||
period: 'HT',
|
||||
allowParlay: true,
|
||||
sortOrder: 9,
|
||||
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
||||
code,
|
||||
name: code.replace('SCORE_', '').replace('_', '-') || code,
|
||||
odds: 6.0,
|
||||
})),
|
||||
},
|
||||
SH_CORRECT_SCORE: {
|
||||
period: 'SH',
|
||||
allowParlay: true,
|
||||
sortOrder: 10,
|
||||
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
|
||||
code,
|
||||
name: code.replace('SCORE_', '').replace('_', '-') || code,
|
||||
odds: 6.0,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const config = configs[marketType];
|
||||
if (!config) throw new BadRequestException(`Unknown market type: ${marketType}`);
|
||||
return config;
|
||||
}
|
||||
|
||||
async updateOdds(selectionId: bigint, newOdds: number, operatorId: bigint) {
|
||||
const selection = await this.prisma.marketSelection.findUnique({
|
||||
where: { id: selectionId },
|
||||
});
|
||||
if (!selection) throw new NotFoundException('Selection not found');
|
||||
if (newOdds <= 1) throw new BadRequestException('Odds must be > 1.00');
|
||||
|
||||
const newVersion = selection.oddsVersion + BigInt(1);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.oddsChangeLog.create({
|
||||
data: {
|
||||
selectionId,
|
||||
oldOdds: selection.odds,
|
||||
newOdds,
|
||||
oddsVersion: newVersion,
|
||||
changedBy: operatorId,
|
||||
},
|
||||
});
|
||||
|
||||
return tx.marketSelection.update({
|
||||
where: { id: selectionId },
|
||||
data: { odds: newOdds, oddsVersion: newVersion },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async batchUpdateOdds(
|
||||
updates: Array<{ selectionId: bigint; odds: number }>,
|
||||
operatorId: bigint,
|
||||
) {
|
||||
const results = [];
|
||||
for (const u of updates) {
|
||||
results.push(await this.updateOdds(u.selectionId, u.odds, operatorId));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
}
|
||||
8
apps/api/src/matches/matches.module.ts
Normal file
8
apps/api/src/matches/matches.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MatchesService } from './matches.service';
|
||||
|
||||
@Module({
|
||||
providers: [MatchesService],
|
||||
exports: [MatchesService],
|
||||
})
|
||||
export class MatchesModule {}
|
||||
161
apps/api/src/matches/matches.service.ts
Normal file
161
apps/api/src/matches/matches.service.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class MatchesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async createLeague(code: string, translations: Record<string, string>) {
|
||||
const league = await this.prisma.league.create({ data: { code } });
|
||||
for (const [locale, value] of Object.entries(translations)) {
|
||||
await this.prisma.entityTranslation.create({
|
||||
data: {
|
||||
entityType: 'LEAGUE',
|
||||
entityId: league.id,
|
||||
locale,
|
||||
fieldName: 'name',
|
||||
value,
|
||||
},
|
||||
});
|
||||
}
|
||||
return league;
|
||||
}
|
||||
|
||||
async createTeam(code: string, translations: Record<string, string>) {
|
||||
const team = await this.prisma.team.create({ data: { code } });
|
||||
for (const [locale, value] of Object.entries(translations)) {
|
||||
await this.prisma.entityTranslation.create({
|
||||
data: {
|
||||
entityType: 'TEAM',
|
||||
entityId: team.id,
|
||||
locale,
|
||||
fieldName: 'name',
|
||||
value,
|
||||
},
|
||||
});
|
||||
}
|
||||
return team;
|
||||
}
|
||||
|
||||
async createMatch(data: {
|
||||
leagueId: bigint;
|
||||
homeTeamId: bigint;
|
||||
awayTeamId: bigint;
|
||||
startTime: Date;
|
||||
isHot?: boolean;
|
||||
createdBy?: bigint;
|
||||
}) {
|
||||
return this.prisma.match.create({
|
||||
data: {
|
||||
leagueId: data.leagueId,
|
||||
homeTeamId: data.homeTeamId,
|
||||
awayTeamId: data.awayTeamId,
|
||||
startTime: data.startTime,
|
||||
isHot: data.isHot ?? false,
|
||||
createdBy: data.createdBy,
|
||||
status: 'DRAFT',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async publishMatch(matchId: bigint) {
|
||||
return this.prisma.match.update({
|
||||
where: { id: matchId },
|
||||
data: { status: 'PUBLISHED', publishTime: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async closeMatch(matchId: bigint) {
|
||||
return this.prisma.match.update({
|
||||
where: { id: matchId },
|
||||
data: { status: 'CLOSED', closeTime: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async cancelMatch(matchId: bigint) {
|
||||
return this.prisma.match.update({
|
||||
where: { id: matchId },
|
||||
data: { status: 'CANCELLED' },
|
||||
});
|
||||
}
|
||||
|
||||
async getTranslation(entityType: string, entityId: bigint, locale: string) {
|
||||
const translations = await this.prisma.entityTranslation.findMany({
|
||||
where: { entityType, entityId },
|
||||
});
|
||||
const map = Object.fromEntries(
|
||||
translations.filter((t) => t.fieldName === 'name').map((t) => [t.locale, t.value]),
|
||||
);
|
||||
return map[locale] || map['en-US'] || map['zh-CN'] || Object.values(map)[0] || '';
|
||||
}
|
||||
|
||||
async enrichMatch(match: Record<string, unknown>, locale: string) {
|
||||
const m = match as {
|
||||
id: bigint;
|
||||
leagueId: bigint;
|
||||
homeTeamId: bigint;
|
||||
awayTeamId: bigint;
|
||||
league?: unknown;
|
||||
homeTeam?: unknown;
|
||||
awayTeam?: unknown;
|
||||
markets?: unknown[];
|
||||
};
|
||||
const [leagueName, homeName, awayName] = await Promise.all([
|
||||
this.getTranslation('LEAGUE', m.leagueId, locale),
|
||||
this.getTranslation('TEAM', m.homeTeamId, locale),
|
||||
this.getTranslation('TEAM', m.awayTeamId, locale),
|
||||
]);
|
||||
return {
|
||||
...match,
|
||||
leagueName,
|
||||
homeTeamName: homeName,
|
||||
awayTeamName: awayName,
|
||||
};
|
||||
}
|
||||
|
||||
async listPublished(locale = 'en-US', leagueId?: bigint) {
|
||||
const matches = await this.prisma.match.findMany({
|
||||
where: {
|
||||
status: 'PUBLISHED',
|
||||
...(leagueId ? { leagueId } : {}),
|
||||
},
|
||||
include: {
|
||||
markets: {
|
||||
where: { status: 'OPEN' },
|
||||
include: { selections: { where: { status: 'OPEN' } } },
|
||||
},
|
||||
},
|
||||
orderBy: [{ isHot: 'desc' }, { startTime: 'asc' }],
|
||||
});
|
||||
|
||||
return Promise.all(matches.map((m) => this.enrichMatch(m, locale)));
|
||||
}
|
||||
|
||||
async getMatchDetail(matchId: bigint, locale = 'en-US') {
|
||||
const match = await this.prisma.match.findUnique({
|
||||
where: { id: matchId },
|
||||
include: {
|
||||
markets: {
|
||||
include: { selections: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
score: true,
|
||||
},
|
||||
});
|
||||
if (!match) throw new NotFoundException('Match not found');
|
||||
return this.enrichMatch(match, locale);
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async autoCloseMatches() {
|
||||
const now = new Date();
|
||||
await this.prisma.match.updateMany({
|
||||
where: {
|
||||
status: 'PUBLISHED',
|
||||
startTime: { lte: now },
|
||||
},
|
||||
data: { status: 'CLOSED', closeTime: now },
|
||||
});
|
||||
}
|
||||
}
|
||||
179
apps/api/src/player/player.controller.ts
Normal file
179
apps/api/src/player/player.controller.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard, PlayerGuard } from '../auth/guards';
|
||||
import { CurrentUser } from '../common/decorators';
|
||||
import { jsonResponse } from '../common/filters';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { WalletService } from '../wallet/wallet.service';
|
||||
import { MatchesService } from '../matches/matches.service';
|
||||
import { BetsService } from '../bets/bets.service';
|
||||
import { ContentService } from '../content/content.service';
|
||||
import { CashbackService } from '../cashback/cashback.service';
|
||||
import { IsString, IsNumber, IsArray, ValidateNested, Min, IsOptional } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
class SingleBetDto {
|
||||
@IsString()
|
||||
selectionId!: string;
|
||||
|
||||
@IsString()
|
||||
oddsVersion!: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
stake!: number;
|
||||
|
||||
@IsString()
|
||||
requestId!: string;
|
||||
}
|
||||
|
||||
class ParlayLegDto {
|
||||
@IsString()
|
||||
selectionId!: string;
|
||||
|
||||
@IsString()
|
||||
oddsVersion!: string;
|
||||
}
|
||||
|
||||
class ParlayBetDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ParlayLegDto)
|
||||
legs!: ParlayLegDto[];
|
||||
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
stake!: number;
|
||||
|
||||
@IsString()
|
||||
requestId!: string;
|
||||
}
|
||||
|
||||
class LocaleDto {
|
||||
@IsString()
|
||||
locale!: string;
|
||||
}
|
||||
|
||||
@ApiTags('Player')
|
||||
@Controller('player')
|
||||
@UseGuards(JwtAuthGuard, PlayerGuard)
|
||||
@ApiBearerAuth()
|
||||
export class PlayerController {
|
||||
constructor(
|
||||
private users: UsersService,
|
||||
private wallet: WalletService,
|
||||
private matches: MatchesService,
|
||||
private bets: BetsService,
|
||||
private content: ContentService,
|
||||
private cashback: CashbackService,
|
||||
) {}
|
||||
|
||||
@Get('profile')
|
||||
async profile(@CurrentUser('id') userId: bigint) {
|
||||
const user = await this.users.findById(userId);
|
||||
return jsonResponse(user);
|
||||
}
|
||||
|
||||
@Post('language')
|
||||
async setLanguage(@CurrentUser('id') userId: bigint, @Body() dto: LocaleDto) {
|
||||
const result = await this.users.updateLocale(userId, dto.locale);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('home')
|
||||
async home(@CurrentUser('locale') locale: string) {
|
||||
const [banners, notices, ticker, hotMatches, todayMatches] = await Promise.all([
|
||||
this.content.listActive('BANNER', locale),
|
||||
this.content.listActive('NOTICE', locale),
|
||||
this.content.listActive('TICKER', locale),
|
||||
this.matches.listPublished(locale),
|
||||
this.matches.listPublished(locale),
|
||||
]);
|
||||
return jsonResponse({
|
||||
banners,
|
||||
notices,
|
||||
ticker,
|
||||
hotMatches: (hotMatches as Array<{ isHot?: boolean }>).filter((m) => m.isHot),
|
||||
todayMatches,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('matches')
|
||||
async listMatches(
|
||||
@CurrentUser('locale') locale: string,
|
||||
@Query('leagueId') leagueId?: string,
|
||||
) {
|
||||
const items = await this.matches.listPublished(locale, leagueId ? BigInt(leagueId) : undefined);
|
||||
return jsonResponse(items);
|
||||
}
|
||||
|
||||
@Get('matches/:id')
|
||||
async matchDetail(@Param('id') id: string, @CurrentUser('locale') locale: string) {
|
||||
const match = await this.matches.getMatchDetail(BigInt(id), locale);
|
||||
return jsonResponse(match);
|
||||
}
|
||||
|
||||
@Post('bets/single')
|
||||
async singleBet(@CurrentUser('id') userId: bigint, @CurrentUser('parentId') parentId: bigint, @Body() dto: SingleBetDto) {
|
||||
const bet = await this.bets.placeSingleBet(
|
||||
userId,
|
||||
parentId,
|
||||
BigInt(dto.selectionId),
|
||||
BigInt(dto.oddsVersion),
|
||||
dto.stake,
|
||||
dto.requestId,
|
||||
);
|
||||
return jsonResponse(bet);
|
||||
}
|
||||
|
||||
@Post('bets/parlay')
|
||||
async parlayBet(@CurrentUser('id') userId: bigint, @CurrentUser('parentId') parentId: bigint, @Body() dto: ParlayBetDto) {
|
||||
const bet = await this.bets.placeParlayBet(
|
||||
userId,
|
||||
parentId,
|
||||
dto.legs.map((l) => ({ selectionId: BigInt(l.selectionId), oddsVersion: BigInt(l.oddsVersion) })),
|
||||
dto.stake,
|
||||
dto.requestId,
|
||||
);
|
||||
return jsonResponse(bet);
|
||||
}
|
||||
|
||||
@Get('bets')
|
||||
async myBets(
|
||||
@CurrentUser('id') userId: bigint,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
) {
|
||||
const result = await this.bets.getUserBets(userId, status, page ? parseInt(page) : 1);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('bets/:betNo')
|
||||
async betDetail(@CurrentUser('id') userId: bigint, @Param('betNo') betNo: string) {
|
||||
const bet = await this.bets.getBetByNo(betNo, userId);
|
||||
return jsonResponse(bet);
|
||||
}
|
||||
|
||||
@Get('wallet/transactions')
|
||||
async transactions(
|
||||
@CurrentUser('id') userId: bigint,
|
||||
@Query('page') page?: string,
|
||||
) {
|
||||
const result = await this.wallet.getTransactions(userId, page ? parseInt(page) : 1);
|
||||
return jsonResponse(result);
|
||||
}
|
||||
|
||||
@Get('cashbacks')
|
||||
async cashbacks(@CurrentUser('id') userId: bigint) {
|
||||
const items = await this.cashback.getUserCashbacks(userId);
|
||||
return jsonResponse(items);
|
||||
}
|
||||
}
|
||||
14
apps/api/src/player/player.module.ts
Normal file
14
apps/api/src/player/player.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PlayerController } from './player.controller';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { WalletModule } from '../wallet/wallet.module';
|
||||
import { MatchesModule } from '../matches/matches.module';
|
||||
import { BetsModule } from '../bets/bets.module';
|
||||
import { ContentModule } from '../content/content.module';
|
||||
import { CashbackModule } from '../cashback/cashback.module';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule, WalletModule, MatchesModule, BetsModule, ContentModule, CashbackModule],
|
||||
controllers: [PlayerController],
|
||||
})
|
||||
export class PlayerModule {}
|
||||
9
apps/api/src/prisma/prisma.module.ts
Normal file
9
apps/api/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
13
apps/api/src/prisma/prisma.service.ts
Normal file
13
apps/api/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
187
apps/api/src/settlement/settlement-calculator.spec.ts
Normal file
187
apps/api/src/settlement/settlement-calculator.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
settleSelection,
|
||||
calculatePayout,
|
||||
calculateParlayPayout,
|
||||
isQuarterHandicapOrTotal,
|
||||
ScoreInput,
|
||||
} from './settlement-calculator';
|
||||
|
||||
describe('SettlementCalculator', () => {
|
||||
const score: ScoreInput = { htHome: 1, htAway: 0, ftHome: 2, ftAway: 1 };
|
||||
|
||||
describe('FT_1X2', () => {
|
||||
it('S001: home win', () => {
|
||||
expect(
|
||||
settleSelection({ marketType: 'FT_1X2', selectionCode: 'HOME', score }),
|
||||
).toBe('WIN');
|
||||
expect(
|
||||
settleSelection({ marketType: 'FT_1X2', selectionCode: 'DRAW', score }),
|
||||
).toBe('LOSE');
|
||||
});
|
||||
|
||||
it('S002: draw', () => {
|
||||
const draw = { htHome: 0, htAway: 0, ftHome: 1, ftAway: 1 };
|
||||
expect(
|
||||
settleSelection({ marketType: 'FT_1X2', selectionCode: 'DRAW', score: draw }),
|
||||
).toBe('WIN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HT_1X2', () => {
|
||||
it('S003: half time result', () => {
|
||||
expect(
|
||||
settleSelection({ marketType: 'HT_1X2', selectionCode: 'HOME', score }),
|
||||
).toBe('WIN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FT_ODD_EVEN', () => {
|
||||
it('S004: 0-0 is even', () => {
|
||||
const s = { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 };
|
||||
expect(
|
||||
settleSelection({ marketType: 'FT_ODD_EVEN', selectionCode: 'EVEN', score: s }),
|
||||
).toBe('WIN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Correct Score', () => {
|
||||
it('S005: exact score 2-1', () => {
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'FT_CORRECT_SCORE',
|
||||
selectionCode: 'SCORE_2_1',
|
||||
score,
|
||||
}),
|
||||
).toBe('WIN');
|
||||
});
|
||||
|
||||
it('S006: other home win', () => {
|
||||
const s = { htHome: 2, htAway: 0, ftHome: 5, ftAway: 0 };
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'FT_CORRECT_SCORE',
|
||||
selectionCode: 'OTHER_HOME',
|
||||
score: s,
|
||||
templateScores: ['SCORE_1_0', 'SCORE_2_0'],
|
||||
}),
|
||||
).toBe('WIN');
|
||||
});
|
||||
|
||||
it('S008: second half correct score', () => {
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'SH_CORRECT_SCORE',
|
||||
selectionCode: 'SCORE_1_1',
|
||||
score,
|
||||
}),
|
||||
).toBe('WIN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Handicap', () => {
|
||||
it('S009: full win', () => {
|
||||
const r = settleSelection({
|
||||
marketType: 'FT_HANDICAP',
|
||||
selectionCode: 'HOME',
|
||||
handicapLine: -1,
|
||||
score: { htHome: 0, htAway: 0, ftHome: 2, ftAway: 0 },
|
||||
});
|
||||
expect(r).toBe('WIN');
|
||||
expect(calculatePayout(100, 1.85, r).toNumber()).toBe(185);
|
||||
});
|
||||
|
||||
it('S010: push', () => {
|
||||
const r = settleSelection({
|
||||
marketType: 'FT_HANDICAP',
|
||||
selectionCode: 'HOME',
|
||||
handicapLine: -1,
|
||||
score: { htHome: 0, htAway: 0, ftHome: 1, ftAway: 0 },
|
||||
});
|
||||
expect(r).toBe('PUSH');
|
||||
expect(calculatePayout(100, 1.85, r).toNumber()).toBe(100);
|
||||
});
|
||||
|
||||
it('S011: half win -0.25', () => {
|
||||
const r = settleSelection({
|
||||
marketType: 'FT_HANDICAP',
|
||||
selectionCode: 'HOME',
|
||||
handicapLine: -0.25,
|
||||
score: { htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 },
|
||||
});
|
||||
expect(r).toBe('HALF_LOSE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Over/Under', () => {
|
||||
it('S013: over 2.5 wins with 3 goals', () => {
|
||||
const s = { htHome: 1, htAway: 1, ftHome: 2, ftAway: 1 };
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'FT_OVER_UNDER',
|
||||
selectionCode: 'OVER',
|
||||
totalLine: 2.5,
|
||||
score: s,
|
||||
}),
|
||||
).toBe('WIN');
|
||||
});
|
||||
|
||||
it('S014: push on integer line', () => {
|
||||
const s = { htHome: 1, htAway: 0, ftHome: 1, ftAway: 1 };
|
||||
expect(
|
||||
settleSelection({
|
||||
marketType: 'FT_OVER_UNDER',
|
||||
selectionCode: 'OVER',
|
||||
totalLine: 2,
|
||||
score: s,
|
||||
}),
|
||||
).toBe('PUSH');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Parlay', () => {
|
||||
it('S016: all win', () => {
|
||||
const result = calculateParlayPayout(100, [
|
||||
{ odds: 1.8, result: 'WIN' },
|
||||
{ odds: 2.0, result: 'WIN' },
|
||||
]);
|
||||
expect(result.betResult).toBe('WON');
|
||||
expect(result.payout.toNumber()).toBe(360);
|
||||
});
|
||||
|
||||
it('S017: one lose', () => {
|
||||
const result = calculateParlayPayout(100, [
|
||||
{ odds: 1.8, result: 'WIN' },
|
||||
{ odds: 2.0, result: 'LOSE' },
|
||||
]);
|
||||
expect(result.betResult).toBe('LOST');
|
||||
expect(result.payout.toNumber()).toBe(0);
|
||||
});
|
||||
|
||||
it('S018: one push', () => {
|
||||
const result = calculateParlayPayout(100, [
|
||||
{ odds: 1.8, result: 'WIN' },
|
||||
{ odds: 2.0, result: 'PUSH' },
|
||||
{ odds: 1.9, result: 'WIN' },
|
||||
]);
|
||||
expect(result.betResult).toBe('WON');
|
||||
expect(result.payout.toNumber()).toBe(342);
|
||||
});
|
||||
|
||||
it('S019: all push', () => {
|
||||
const result = calculateParlayPayout(100, [
|
||||
{ odds: 1.8, result: 'PUSH' },
|
||||
{ odds: 2.0, result: 'VOID' },
|
||||
]);
|
||||
expect(result.betResult).toBe('PUSH');
|
||||
expect(result.payout.toNumber()).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Quarter line detection', () => {
|
||||
it('detects quarter lines', () => {
|
||||
expect(isQuarterHandicapOrTotal(-0.25)).toBe(true);
|
||||
expect(isQuarterHandicapOrTotal(2.5)).toBe(false);
|
||||
expect(isQuarterHandicapOrTotal(-1)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
279
apps/api/src/settlement/settlement-calculator.ts
Normal file
279
apps/api/src/settlement/settlement-calculator.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import Decimal from 'decimal.js';
|
||||
|
||||
export type SelectionResult = 'WIN' | 'HALF_WIN' | 'PUSH' | 'HALF_LOSE' | 'LOSE' | 'VOID';
|
||||
|
||||
export interface ScoreInput {
|
||||
htHome: number;
|
||||
htAway: number;
|
||||
ftHome: number;
|
||||
ftAway: number;
|
||||
}
|
||||
|
||||
export interface SettlementInput {
|
||||
marketType: string;
|
||||
selectionCode: string;
|
||||
handicapLine?: number | null;
|
||||
totalLine?: number | null;
|
||||
score: ScoreInput;
|
||||
templateScores?: string[];
|
||||
}
|
||||
|
||||
export function getShScore(score: ScoreInput): { home: number; away: number } {
|
||||
return {
|
||||
home: score.ftHome - score.htHome,
|
||||
away: score.ftAway - score.htAway,
|
||||
};
|
||||
}
|
||||
|
||||
function isQuarterLine(line: number): boolean {
|
||||
const frac = Math.abs(line % 1);
|
||||
return Math.abs(frac - 0.25) < 0.001 || Math.abs(frac - 0.75) < 0.001;
|
||||
}
|
||||
|
||||
function splitQuarterLine(line: number): [number, number] {
|
||||
const sign = line >= 0 ? 1 : -1;
|
||||
const abs = Math.abs(line);
|
||||
const lower = Math.floor(abs * 2) / 2 * sign;
|
||||
const upper = (Math.floor(abs * 2) + 1) / 2 * sign;
|
||||
if (Math.abs(abs % 1 - 0.25) < 0.001) {
|
||||
return [lower, upper];
|
||||
}
|
||||
// 0.75 case: e.g. -0.75 => -0.5 and -1
|
||||
const l = Math.floor(abs) * sign;
|
||||
const u = (Math.floor(abs) + 0.5) * sign;
|
||||
return abs % 1 > 0.5 ? [l, u] : [lower, upper];
|
||||
}
|
||||
|
||||
function settleHandicap(
|
||||
teamGoals: number,
|
||||
oppGoals: number,
|
||||
handicap: number,
|
||||
isHome: boolean,
|
||||
): SelectionResult {
|
||||
const adj = teamGoals + handicap - oppGoals;
|
||||
|
||||
if (!isQuarterLine(handicap)) {
|
||||
if (adj > 0) return 'WIN';
|
||||
if (adj === 0) return 'PUSH';
|
||||
return 'LOSE';
|
||||
}
|
||||
|
||||
const [line1, line2] = splitQuarterLine(handicap);
|
||||
const r1 = teamGoals + line1 - oppGoals;
|
||||
const r2 = teamGoals + line2 - oppGoals;
|
||||
|
||||
const results: SelectionResult[] = [];
|
||||
for (const r of [r1, r2]) {
|
||||
if (r > 0) results.push('WIN');
|
||||
else if (r === 0) results.push('PUSH');
|
||||
else results.push('LOSE');
|
||||
}
|
||||
|
||||
const winCount = results.filter((r) => r === 'WIN').length;
|
||||
const loseCount = results.filter((r) => r === 'LOSE').length;
|
||||
|
||||
if (winCount === 2) return 'WIN';
|
||||
if (loseCount === 2) return 'LOSE';
|
||||
if (winCount === 1 && loseCount === 0) return 'HALF_WIN';
|
||||
if (loseCount === 1 && winCount === 0) return 'HALF_LOSE';
|
||||
return 'PUSH';
|
||||
}
|
||||
|
||||
function settleOverUnder(
|
||||
totalGoals: number,
|
||||
line: number,
|
||||
isOver: boolean,
|
||||
): SelectionResult {
|
||||
if (!isQuarterLine(line)) {
|
||||
if (isOver) {
|
||||
if (totalGoals > line) return 'WIN';
|
||||
if (totalGoals === line) return 'PUSH';
|
||||
return 'LOSE';
|
||||
}
|
||||
if (totalGoals < line) return 'WIN';
|
||||
if (totalGoals === line) return 'PUSH';
|
||||
return 'LOSE';
|
||||
}
|
||||
|
||||
const [line1, line2] = splitQuarterLine(line);
|
||||
const r1 = settleOverUnder(totalGoals, line1, isOver);
|
||||
const r2 = settleOverUnder(totalGoals, line2, isOver);
|
||||
|
||||
const winCount = [r1, r2].filter((r) => r === 'WIN').length;
|
||||
const loseCount = [r1, r2].filter((r) => r === 'LOSE').length;
|
||||
|
||||
if (winCount === 2) return 'WIN';
|
||||
if (loseCount === 2) return 'LOSE';
|
||||
if (winCount === 1) return 'HALF_WIN';
|
||||
if (loseCount === 1) return 'HALF_LOSE';
|
||||
return 'PUSH';
|
||||
}
|
||||
|
||||
function parseScoreCode(code: string): { home: number; away: number } | null {
|
||||
const match = code.match(/SCORE_(\d+)_(\d+)/);
|
||||
if (match) return { home: parseInt(match[1]), away: parseInt(match[2]) };
|
||||
return null;
|
||||
}
|
||||
|
||||
function settleCorrectScore(
|
||||
home: number,
|
||||
away: number,
|
||||
selectionCode: string,
|
||||
templateScores: string[],
|
||||
): SelectionResult {
|
||||
const parsed = parseScoreCode(selectionCode);
|
||||
if (parsed) {
|
||||
return parsed.home === home && parsed.away === away ? 'WIN' : 'LOSE';
|
||||
}
|
||||
|
||||
const actualKey = `SCORE_${home}_${away}`;
|
||||
if (templateScores.includes(actualKey)) {
|
||||
return 'LOSE';
|
||||
}
|
||||
|
||||
const isDraw = home === away;
|
||||
const isHomeWin = home > away;
|
||||
|
||||
if (selectionCode === 'OTHER_DRAW' && isDraw) return 'WIN';
|
||||
if (selectionCode === 'OTHER_HOME' && isHomeWin) return 'WIN';
|
||||
if (selectionCode === 'OTHER_AWAY' && !isHomeWin && !isDraw) return 'WIN';
|
||||
|
||||
return 'LOSE';
|
||||
}
|
||||
|
||||
export function settleSelection(input: SettlementInput): SelectionResult {
|
||||
const { marketType, selectionCode, handicapLine, totalLine, score } = input;
|
||||
const templates = input.templateScores ?? [];
|
||||
|
||||
switch (marketType) {
|
||||
case 'FT_1X2': {
|
||||
if (selectionCode === 'HOME') return score.ftHome > score.ftAway ? 'WIN' : 'LOSE';
|
||||
if (selectionCode === 'DRAW') return score.ftHome === score.ftAway ? 'WIN' : 'LOSE';
|
||||
if (selectionCode === 'AWAY') return score.ftHome < score.ftAway ? 'WIN' : 'LOSE';
|
||||
break;
|
||||
}
|
||||
case 'HT_1X2': {
|
||||
if (selectionCode === 'HOME') return score.htHome > score.htAway ? 'WIN' : 'LOSE';
|
||||
if (selectionCode === 'DRAW') return score.htHome === score.htAway ? 'WIN' : 'LOSE';
|
||||
if (selectionCode === 'AWAY') return score.htHome < score.htAway ? 'WIN' : 'LOSE';
|
||||
break;
|
||||
}
|
||||
case 'FT_ODD_EVEN': {
|
||||
const total = score.ftHome + score.ftAway;
|
||||
const isOdd = total % 2 === 1;
|
||||
if (selectionCode === 'ODD') return isOdd ? 'WIN' : 'LOSE';
|
||||
if (selectionCode === 'EVEN') return !isOdd ? 'WIN' : 'LOSE';
|
||||
break;
|
||||
}
|
||||
case 'FT_HANDICAP': {
|
||||
const line = handicapLine ?? 0;
|
||||
const isHome = selectionCode === 'HOME';
|
||||
const goals = isHome ? score.ftHome : score.ftAway;
|
||||
const opp = isHome ? score.ftAway : score.ftHome;
|
||||
return settleHandicap(goals, opp, isHome ? line : -line, isHome);
|
||||
}
|
||||
case 'HT_HANDICAP': {
|
||||
const line = handicapLine ?? 0;
|
||||
const isHome = selectionCode === 'HOME';
|
||||
const goals = isHome ? score.htHome : score.htAway;
|
||||
const opp = isHome ? score.htAway : score.htHome;
|
||||
return settleHandicap(goals, opp, isHome ? line : -line, isHome);
|
||||
}
|
||||
case 'FT_OVER_UNDER': {
|
||||
const total = score.ftHome + score.ftAway;
|
||||
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
|
||||
}
|
||||
case 'HT_OVER_UNDER': {
|
||||
const total = score.htHome + score.htAway;
|
||||
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
|
||||
}
|
||||
case 'FT_CORRECT_SCORE':
|
||||
return settleCorrectScore(score.ftHome, score.ftAway, selectionCode, templates);
|
||||
case 'HT_CORRECT_SCORE':
|
||||
return settleCorrectScore(score.htHome, score.htAway, selectionCode, templates);
|
||||
case 'SH_CORRECT_SCORE': {
|
||||
const sh = getShScore(score);
|
||||
return settleCorrectScore(sh.home, sh.away, selectionCode, templates);
|
||||
}
|
||||
case 'OUTRIGHT_WINNER':
|
||||
return selectionCode === `TEAM_${input.score.ftHome}` ? 'WIN' : 'LOSE';
|
||||
}
|
||||
|
||||
return 'LOSE';
|
||||
}
|
||||
|
||||
export function calculatePayout(
|
||||
stake: Decimal | number,
|
||||
odds: Decimal | number,
|
||||
result: SelectionResult,
|
||||
): Decimal {
|
||||
const s = new Decimal(stake);
|
||||
const o = new Decimal(odds);
|
||||
|
||||
switch (result) {
|
||||
case 'WIN':
|
||||
return s.mul(o);
|
||||
case 'HALF_WIN':
|
||||
return s.div(2).mul(o).add(s.div(2));
|
||||
case 'PUSH':
|
||||
case 'VOID':
|
||||
return s;
|
||||
case 'HALF_LOSE':
|
||||
return s.div(2);
|
||||
case 'LOSE':
|
||||
return new Decimal(0);
|
||||
default:
|
||||
return new Decimal(0);
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateParlayPayout(
|
||||
stake: Decimal | number,
|
||||
selections: Array<{ odds: Decimal | number; result: SelectionResult }>,
|
||||
): { betResult: 'WON' | 'LOST' | 'PUSH'; payout: Decimal; effectiveOdds: Decimal } {
|
||||
const s = new Decimal(stake);
|
||||
|
||||
if (selections.some((sel) => sel.result === 'LOSE')) {
|
||||
return { betResult: 'LOST', payout: new Decimal(0), effectiveOdds: new Decimal(0) };
|
||||
}
|
||||
|
||||
let combinedOdds = new Decimal(1);
|
||||
for (const sel of selections) {
|
||||
if (sel.result === 'WIN') {
|
||||
combinedOdds = combinedOdds.mul(sel.odds);
|
||||
} else if (sel.result === 'HALF_WIN') {
|
||||
combinedOdds = combinedOdds.mul(new Decimal(sel.odds).add(1).div(2));
|
||||
} else if (sel.result === 'HALF_LOSE') {
|
||||
combinedOdds = combinedOdds.mul(0.5);
|
||||
}
|
||||
// PUSH/VOID => odds 1.00
|
||||
}
|
||||
|
||||
const allPush = selections.every(
|
||||
(sel) => sel.result === 'PUSH' || sel.result === 'VOID',
|
||||
);
|
||||
if (allPush) {
|
||||
return { betResult: 'PUSH', payout: s, effectiveOdds: new Decimal(1) };
|
||||
}
|
||||
|
||||
return { betResult: 'WON', payout: s.mul(combinedOdds), effectiveOdds: combinedOdds };
|
||||
}
|
||||
|
||||
export function isQuarterHandicapOrTotal(line: number | null | undefined): boolean {
|
||||
if (line == null) return false;
|
||||
return isQuarterLine(line);
|
||||
}
|
||||
|
||||
export const FT_CORRECT_SCORE_TEMPLATE = [
|
||||
'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'SCORE_3_3', 'SCORE_4_4', 'OTHER_DRAW',
|
||||
'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'SCORE_3_1', 'SCORE_3_2',
|
||||
'SCORE_4_0', 'SCORE_4_1', 'SCORE_4_2', 'SCORE_4_3', 'OTHER_HOME',
|
||||
'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'SCORE_1_3', 'SCORE_2_3',
|
||||
'SCORE_0_4', 'SCORE_1_4', 'SCORE_2_4', 'SCORE_3_4', 'OTHER_AWAY',
|
||||
];
|
||||
|
||||
export const HT_CORRECT_SCORE_TEMPLATE = [
|
||||
'SCORE_0_0', 'SCORE_1_1', 'SCORE_2_2', 'OTHER_DRAW',
|
||||
'SCORE_1_0', 'SCORE_2_0', 'SCORE_2_1', 'SCORE_3_0', 'OTHER_HOME',
|
||||
'SCORE_0_1', 'SCORE_0_2', 'SCORE_1_2', 'SCORE_0_3', 'OTHER_AWAY',
|
||||
];
|
||||
11
apps/api/src/settlement/settlement.module.ts
Normal file
11
apps/api/src/settlement/settlement.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SettlementService } from './settlement.service';
|
||||
import { WalletModule } from '../wallet/wallet.module';
|
||||
import { AgentsModule } from '../agents/agents.module';
|
||||
|
||||
@Module({
|
||||
imports: [WalletModule, AgentsModule],
|
||||
providers: [SettlementService],
|
||||
exports: [SettlementService],
|
||||
})
|
||||
export class SettlementModule {}
|
||||
312
apps/api/src/settlement/settlement.service.ts
Normal file
312
apps/api/src/settlement/settlement.service.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { WalletService } from '../wallet/wallet.service';
|
||||
import { AgentsService } from '../agents/agents.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { generateBatchNo } from '../common/decorators';
|
||||
import {
|
||||
settleSelection,
|
||||
calculatePayout,
|
||||
calculateParlayPayout,
|
||||
ScoreInput,
|
||||
FT_CORRECT_SCORE_TEMPLATE,
|
||||
HT_CORRECT_SCORE_TEMPLATE,
|
||||
} from './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 };
|
||||
}
|
||||
}
|
||||
8
apps/api/src/users/users.module.ts
Normal file
8
apps/api/src/users/users.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
43
apps/api/src/users/users.service.ts
Normal file
43
apps/api/src/users/users.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async findById(id: bigint) {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: { wallet: true, agentProfile: true, preferences: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updateLocale(userId: bigint, locale: string) {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { locale },
|
||||
});
|
||||
await this.prisma.userPreference.upsert({
|
||||
where: { userId },
|
||||
create: { userId, locale },
|
||||
update: { locale },
|
||||
});
|
||||
return { locale };
|
||||
}
|
||||
|
||||
async listPlayers(page = 1, pageSize = 20, parentId?: bigint) {
|
||||
const where = { userType: 'PLAYER', ...(parentId ? { parentId } : {}) };
|
||||
const skip = (page - 1) * pageSize;
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where,
|
||||
include: { wallet: true },
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
}
|
||||
8
apps/api/src/wallet/wallet.module.ts
Normal file
8
apps/api/src/wallet/wallet.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WalletService } from './wallet.service';
|
||||
|
||||
@Module({
|
||||
providers: [WalletService],
|
||||
exports: [WalletService],
|
||||
})
|
||||
export class WalletModule {}
|
||||
222
apps/api/src/wallet/wallet.service.ts
Normal file
222
apps/api/src/wallet/wallet.service.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import { generateTransactionId } from '../common/decorators';
|
||||
|
||||
@Injectable()
|
||||
export class WalletService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async getWallet(userId: bigint) {
|
||||
const wallet = await this.prisma.wallet.findUnique({ where: { userId } });
|
||||
if (!wallet) throw new BadRequestException('Wallet not found');
|
||||
return wallet;
|
||||
}
|
||||
|
||||
async createWallet(userId: bigint, currency = 'USD') {
|
||||
return this.prisma.wallet.create({
|
||||
data: { userId, currency },
|
||||
});
|
||||
}
|
||||
|
||||
private async lockWallet(tx: Parameters<Parameters<PrismaService['$transaction']>[0]>[0], userId: bigint) {
|
||||
const wallets = await tx.$queryRaw<Array<{ id: bigint; available_balance: Decimal; frozen_balance: Decimal; version: number }>>`
|
||||
SELECT id, available_balance, frozen_balance, version FROM wallets WHERE user_id = ${userId} FOR UPDATE
|
||||
`;
|
||||
if (!wallets.length) throw new BadRequestException('Wallet not found');
|
||||
return wallets[0];
|
||||
}
|
||||
|
||||
async deposit(
|
||||
userId: bigint,
|
||||
amount: Decimal | number,
|
||||
operatorId: bigint,
|
||||
remark?: string,
|
||||
referenceId?: string,
|
||||
) {
|
||||
const amt = new Decimal(amount);
|
||||
if (amt.lte(0)) throw new BadRequestException('Amount must be positive');
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const w = await this.lockWallet(tx, userId);
|
||||
const balanceBefore = new Decimal(w.available_balance);
|
||||
const balanceAfter = balanceBefore.add(amt);
|
||||
|
||||
await tx.wallet.update({
|
||||
where: { id: w.id },
|
||||
data: {
|
||||
availableBalance: balanceAfter,
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
await tx.walletTransaction.create({
|
||||
data: {
|
||||
transactionId: generateTransactionId(),
|
||||
userId,
|
||||
walletId: w.id,
|
||||
transactionType: 'MANUAL_DEPOSIT',
|
||||
amount: amt,
|
||||
balanceBefore,
|
||||
balanceAfter,
|
||||
frozenBefore: w.frozen_balance,
|
||||
frozenAfter: w.frozen_balance,
|
||||
referenceType: 'DEPOSIT',
|
||||
referenceId,
|
||||
operatorId,
|
||||
remark,
|
||||
},
|
||||
});
|
||||
|
||||
return { balanceAfter };
|
||||
});
|
||||
}
|
||||
|
||||
async withdraw(
|
||||
userId: bigint,
|
||||
amount: Decimal | number,
|
||||
operatorId: bigint,
|
||||
remark?: string,
|
||||
referenceId?: string,
|
||||
) {
|
||||
const amt = new Decimal(amount);
|
||||
if (amt.lte(0)) throw new BadRequestException('Amount must be positive');
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const w = await this.lockWallet(tx, userId);
|
||||
const balanceBefore = new Decimal(w.available_balance);
|
||||
if (balanceBefore.lt(amt)) throw new BadRequestException('Insufficient balance');
|
||||
const balanceAfter = balanceBefore.sub(amt);
|
||||
|
||||
await tx.wallet.update({
|
||||
where: { id: w.id },
|
||||
data: {
|
||||
availableBalance: balanceAfter,
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
await tx.walletTransaction.create({
|
||||
data: {
|
||||
transactionId: generateTransactionId(),
|
||||
userId,
|
||||
walletId: w.id,
|
||||
transactionType: 'MANUAL_WITHDRAW',
|
||||
amount: amt.neg(),
|
||||
balanceBefore,
|
||||
balanceAfter,
|
||||
frozenBefore: w.frozen_balance,
|
||||
frozenAfter: w.frozen_balance,
|
||||
referenceType: 'WITHDRAW',
|
||||
referenceId,
|
||||
operatorId,
|
||||
remark,
|
||||
},
|
||||
});
|
||||
|
||||
return { balanceAfter };
|
||||
});
|
||||
}
|
||||
|
||||
async freezeForBet(userId: bigint, stake: Decimal | number, betId: string) {
|
||||
const amt = new Decimal(stake);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const w = await this.lockWallet(tx, userId);
|
||||
const avail = new Decimal(w.available_balance);
|
||||
if (avail.lt(amt)) throw new BadRequestException('Insufficient balance');
|
||||
|
||||
const balanceAfter = avail.sub(amt);
|
||||
const frozenAfter = new Decimal(w.frozen_balance).add(amt);
|
||||
|
||||
await tx.wallet.update({
|
||||
where: { id: w.id },
|
||||
data: {
|
||||
availableBalance: balanceAfter,
|
||||
frozenBalance: frozenAfter,
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
await tx.walletTransaction.create({
|
||||
data: {
|
||||
transactionId: generateTransactionId(),
|
||||
userId,
|
||||
walletId: w.id,
|
||||
transactionType: 'BET_FREEZE',
|
||||
amount: amt.neg(),
|
||||
balanceBefore: avail,
|
||||
balanceAfter,
|
||||
frozenBefore: w.frozen_balance,
|
||||
frozenAfter,
|
||||
referenceType: 'BET',
|
||||
referenceId: betId,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async settleBet(
|
||||
userId: bigint,
|
||||
stake: Decimal,
|
||||
payout: Decimal,
|
||||
betId: string,
|
||||
result: 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE',
|
||||
) {
|
||||
const txTypeMap: Record<string, string> = {
|
||||
WIN: 'BET_SETTLE_WIN',
|
||||
LOSE: 'BET_SETTLE_LOSE',
|
||||
PUSH: 'BET_SETTLE_PUSH',
|
||||
VOID: 'BET_VOID_REFUND',
|
||||
HALF_WIN: 'BET_SETTLE_WIN',
|
||||
HALF_LOSE: 'BET_SETTLE_LOSE',
|
||||
};
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const w = await this.lockWallet(tx, userId);
|
||||
const avail = new Decimal(w.available_balance);
|
||||
const frozen = new Decimal(w.frozen_balance);
|
||||
const frozenAfter = frozen.sub(stake);
|
||||
const balanceAfter = avail.add(payout);
|
||||
|
||||
await tx.wallet.update({
|
||||
where: { id: w.id },
|
||||
data: {
|
||||
availableBalance: balanceAfter,
|
||||
frozenBalance: frozenAfter.lt(0) ? new Decimal(0) : frozenAfter,
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
await tx.walletTransaction.create({
|
||||
data: {
|
||||
transactionId: generateTransactionId(),
|
||||
userId,
|
||||
walletId: w.id,
|
||||
transactionType: txTypeMap[result] || 'BET_SETTLE_WIN',
|
||||
amount: payout,
|
||||
balanceBefore: avail,
|
||||
balanceAfter,
|
||||
frozenBefore: frozen,
|
||||
frozenAfter: frozenAfter.lt(0) ? new Decimal(0) : frozenAfter,
|
||||
referenceType: 'BET',
|
||||
referenceId: betId,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getTransactions(userId: bigint, page = 1, pageSize = 20) {
|
||||
const skip = (page - 1) * pageSize;
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.walletTransaction.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.walletTransaction.count({ where: { userId } }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
}
|
||||
25
apps/api/tsconfig.json
Normal file
25
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2022",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*", "prisma/seed.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user