This commit is contained in:
wchino
2026-06-13 17:38:25 +08:00
parent e7e938f261
commit 7b33d9f9fa
190 changed files with 23222 additions and 4336 deletions

View File

@@ -1,14 +1,23 @@
module.exports = {
moduleFileExtensions: ['js', 'json', 'ts'],
moduleFileExtensions: ['ts', 'js', 'json'],
rootDir: 'src',
testRegex: '.*\\.spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': 'ts-jest',
'^.+\\.ts$': [
'ts-jest',
{
tsconfig: {
esModuleInterop: true,
resolveJsonModule: true,
},
},
],
},
collectCoverageFrom: ['**/*.(t|j)s'],
coverageDirectory: '../coverage',
testEnvironment: 'node',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
'^@thebet365/shared$': '<rootDir>/../../../packages/shared/src/index.ts',
},
};

View File

@@ -0,0 +1,23 @@
-- AlterTable
ALTER TABLE "wallet_transactions" ADD COLUMN IF NOT EXISTS "business_key" VARCHAR(128);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "wallet_transactions_business_key_key" ON "wallet_transactions"("business_key");
-- Deduplicate legacy settlement preview items before enforcing idempotency.
WITH ranked_settlement_items AS (
SELECT
"id",
ROW_NUMBER() OVER (
PARTITION BY "batch_id", "bet_id"
ORDER BY "id"
) AS row_num
FROM "settlement_items"
)
DELETE FROM "settlement_items" si
USING ranked_settlement_items r
WHERE si."id" = r."id"
AND r.row_num > 1;
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "settlement_items_batch_id_bet_id_key" ON "settlement_items"("batch_id", "bet_id");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "show_on_player" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -0,0 +1,81 @@
-- Market templates, line identity, and multilingual labels.
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "market_key" VARCHAR(64);
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "line_key" VARCHAR(180);
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "params_json" JSONB;
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "promo_label_i18n" JSONB;
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "name_i18n" JSONB;
ALTER TABLE "markets" ADD COLUMN IF NOT EXISTS "template_item_id" BIGINT;
UPDATE "markets"
SET
"market_key" = COALESCE("market_key", "market_type"),
"line_key" = COALESCE(
"line_key",
"market_type" || ':' || COALESCE(to_char("line_value", 'FM999999990.00'), 'none')
)
WHERE "market_key" IS NULL OR "line_key" IS NULL;
ALTER TABLE "market_selections" ADD COLUMN IF NOT EXISTS "name_i18n" JSONB;
ALTER TABLE "bet_selections" ADD COLUMN IF NOT EXISTS "market_name_snapshot" VARCHAR(255);
CREATE TABLE IF NOT EXISTS "market_templates" (
"id" BIGSERIAL PRIMARY KEY,
"sport_type" VARCHAR(20) NOT NULL DEFAULT 'FOOTBALL',
"name" VARCHAR(128) NOT NULL,
"name_i18n" JSONB,
"description" VARCHAR(500),
"is_default" BOOLEAN NOT NULL DEFAULT false,
"status" VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
"sort_order" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS "market_template_items" (
"id" BIGSERIAL PRIMARY KEY,
"template_id" BIGINT NOT NULL,
"market_type" VARCHAR(64) NOT NULL,
"market_key" VARCHAR(64),
"line_key" VARCHAR(180) NOT NULL,
"period" VARCHAR(16) NOT NULL,
"line_value" DECIMAL(8,2),
"params_json" JSONB,
"status" VARCHAR(20) NOT NULL DEFAULT 'OPEN',
"allow_single" BOOLEAN NOT NULL DEFAULT true,
"allow_parlay" BOOLEAN NOT NULL DEFAULT true,
"show_on_player" BOOLEAN NOT NULL DEFAULT true,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"promo_label" VARCHAR(100),
"promo_label_i18n" JSONB,
"name_i18n" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "market_template_items_template_id_fkey"
FOREIGN KEY ("template_id") REFERENCES "market_templates"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE IF NOT EXISTS "market_template_selections" (
"id" BIGSERIAL PRIMARY KEY,
"template_item_id" BIGINT NOT NULL,
"selection_code" VARCHAR(64) NOT NULL,
"selection_name" VARCHAR(255) NOT NULL,
"name_i18n" JSONB,
"odds" DECIMAL(18,6) NOT NULL,
"status" VARCHAR(20) NOT NULL DEFAULT 'OPEN',
"sort_order" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "market_template_selections_template_item_id_fkey"
FOREIGN KEY ("template_item_id") REFERENCES "market_template_items"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS "markets_match_id_line_key_idx" ON "markets"("match_id", "line_key");
CREATE INDEX IF NOT EXISTS "market_templates_sport_type_status_idx" ON "market_templates"("sport_type", "status");
CREATE INDEX IF NOT EXISTS "market_templates_is_default_idx" ON "market_templates"("is_default");
CREATE INDEX IF NOT EXISTS "market_template_items_template_id_idx" ON "market_template_items"("template_id");
CREATE INDEX IF NOT EXISTS "market_template_items_market_type_idx" ON "market_template_items"("market_type");
CREATE UNIQUE INDEX IF NOT EXISTS "market_template_items_template_id_line_key_key" ON "market_template_items"("template_id", "line_key");
CREATE INDEX IF NOT EXISTS "market_template_selections_template_item_id_idx" ON "market_template_selections"("template_item_id");
CREATE UNIQUE INDEX IF NOT EXISTS "market_template_selections_template_item_id_selection_code_key"
ON "market_template_selections"("template_item_id", "selection_code");

View File

@@ -0,0 +1,11 @@
ALTER TABLE "match_scores"
ADD COLUMN "home_corners" INTEGER,
ADD COLUMN "away_corners" INTEGER,
ADD COLUMN "home_cards" INTEGER,
ADD COLUMN "away_cards" INTEGER;
ALTER TABLE "settlement_batches"
ADD COLUMN "home_corners" INTEGER,
ADD COLUMN "away_corners" INTEGER,
ADD COLUMN "home_cards" INTEGER,
ADD COLUMN "away_cards" INTEGER;

View File

@@ -0,0 +1 @@
ALTER TABLE "matches" DROP COLUMN IF EXISTS "correct_score_enabled";

View File

@@ -0,0 +1,11 @@
ALTER TABLE "match_scores"
ADD COLUMN "home_yellow_cards" INTEGER,
ADD COLUMN "away_yellow_cards" INTEGER,
ADD COLUMN "home_red_cards" INTEGER,
ADD COLUMN "away_red_cards" INTEGER;
ALTER TABLE "settlement_batches"
ADD COLUMN "home_yellow_cards" INTEGER,
ADD COLUMN "away_yellow_cards" INTEGER,
ADD COLUMN "home_red_cards" INTEGER,
ADD COLUMN "away_red_cards" INTEGER;

View File

@@ -10,19 +10,19 @@ datasource db {
// ============ 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)
inviteCode String? @unique @map("invite_code") @db.VarChar(16)
inviteSponsorId BigInt? @map("invite_sponsor_id")
usedInviteId BigInt? @map("used_invite_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
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)
inviteCode String? @unique @map("invite_code") @db.VarChar(16)
inviteSponsorId BigInt? @map("invite_sponsor_id")
usedInviteId BigInt? @map("used_invite_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
auth UserAuth?
wallet Wallet?
@@ -30,14 +30,14 @@ model User {
adminRole AdminUserRole?
bets Bet[]
preferences UserPreference?
depositOrders DepositOrder[] @relation("PlayerDepositOrders")
depositOrders DepositOrder[] @relation("PlayerDepositOrders")
parent User? @relation("UserHierarchy", fields: [parentId], references: [id])
children User[] @relation("UserHierarchy")
inviteSponsor User? @relation("InviteSponsor", fields: [inviteSponsorId], references: [id])
invitedPlayers User[] @relation("InviteSponsor")
usedInvite UserInvite? @relation("UsedInvite", fields: [usedInviteId], references: [id])
invites UserInvite[] @relation("UserInvites")
parent User? @relation("UserHierarchy", fields: [parentId], references: [id])
children User[] @relation("UserHierarchy")
inviteSponsor User? @relation("InviteSponsor", fields: [inviteSponsorId], references: [id])
invitedPlayers User[] @relation("InviteSponsor")
usedInvite UserInvite? @relation("UsedInvite", fields: [usedInviteId], references: [id])
invites UserInvite[] @relation("UserInvites")
@@index([userType])
@@index([parentId])
@@ -56,8 +56,8 @@ model UserInvite {
revokedAt DateTime? @map("revoked_at")
cashbackRate Decimal? @map("cashback_rate") @db.Decimal(8, 4)
sponsor User @relation("UserInvites", fields: [sponsorId], references: [id])
registrations User[] @relation("UsedInvite")
sponsor User @relation("UserInvites", fields: [sponsorId], references: [id])
registrations User[] @relation("UsedInvite")
@@index([sponsorId])
@@index([status])
@@ -66,16 +66,16 @@ model UserInvite {
}
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")
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])
user User @relation(fields: [userId], references: [id])
@@map("user_auth")
}
@@ -95,18 +95,18 @@ model UserPreference {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
user User @relation(fields: [userId], references: [id])
@@index([phoneCountryDial, phoneLocal])
@@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")
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[]
@@ -115,23 +115,23 @@ model Role {
}
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")
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[]
roles RolePermission[]
@@map("permissions")
}
model RolePermission {
roleId BigInt @map("role_id")
permissionId BigInt @map("permission_id")
roleId BigInt @map("role_id")
permissionId BigInt @map("permission_id")
role Role @relation(fields: [roleId], references: [id])
permission Permission @relation(fields: [permissionId], references: [id])
role Role @relation(fields: [roleId], references: [id])
permission Permission @relation(fields: [permissionId], references: [id])
@@id([roleId, permissionId])
@@map("role_permissions")
@@ -142,8 +142,8 @@ model AdminUserRole {
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])
user User @relation(fields: [userId], references: [id])
role Role @relation(fields: [roleId], references: [id])
@@map("admin_user_roles")
}
@@ -151,23 +151,23 @@ model AdminUserRole {
// ============ 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)
blockDirectPlayerLogin Boolean @default(false) @map("block_direct_player_login")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
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)
blockDirectPlayerLogin Boolean @default(false) @map("block_direct_player_login")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
user User @relation(fields: [userId], references: [id])
@@index([parentAgentId])
@@map("agent_profiles")
@@ -215,8 +215,8 @@ model Wallet {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
transactions WalletTransaction[]
user User @relation(fields: [userId], references: [id])
transactions WalletTransaction[]
@@map("wallets")
}
@@ -234,11 +234,12 @@ model WalletTransaction {
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)
businessKey String? @unique @map("business_key") @db.VarChar(128)
operatorId BigInt? @map("operator_id")
remark String? @db.VarChar(500)
createdAt DateTime @default(now()) @map("created_at")
wallet Wallet @relation(fields: [walletId], references: [id])
wallet Wallet @relation(fields: [walletId], references: [id])
@@index([userId])
@@index([walletId])
@@ -259,7 +260,7 @@ model League {
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
matches Match[]
matches Match[]
@@map("leagues")
}
@@ -296,41 +297,40 @@ model EntityTranslation {
}
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")
officialMatchNo Int? @map("official_match_no")
stage String? @db.VarChar(32)
groupName String? @map("group_name") @db.VarChar(8)
liveMatchId BigInt? @unique @map("live_match_id")
additionMatchId BigInt? @map("addition_match_id")
channelId String? @map("channel_id") @db.VarChar(64)
matchName String? @map("match_name") @db.VarChar(200)
venueJson Json? @map("venue_json")
kickoffJson Json? @map("kickoff_json")
externalStatus String? @map("external_status") @db.VarChar(32)
correctScoreEnabled Boolean @default(true) @map("correct_score_enabled")
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")
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")
officialMatchNo Int? @map("official_match_no")
stage String? @db.VarChar(32)
groupName String? @map("group_name") @db.VarChar(8)
liveMatchId BigInt? @unique @map("live_match_id")
additionMatchId BigInt? @map("addition_match_id")
channelId String? @map("channel_id") @db.VarChar(64)
matchName String? @map("match_name") @db.VarChar(200)
venueJson Json? @map("venue_json")
kickoffJson Json? @map("kickoff_json")
externalStatus String? @map("external_status") @db.VarChar(32)
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[]
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])
@@ -339,40 +339,56 @@ model Match {
}
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")
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")
homeCorners Int? @map("home_corners")
awayCorners Int? @map("away_corners")
homeYellowCards Int? @map("home_yellow_cards")
awayYellowCards Int? @map("away_yellow_cards")
homeRedCards Int? @map("home_red_cards")
awayRedCards Int? @map("away_red_cards")
homeCards Int? @map("home_cards")
awayCards Int? @map("away_cards")
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])
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")
promoLabel String? @map("promo_label") @db.VarChar(100)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id BigInt @id @default(autoincrement())
matchId BigInt @map("match_id")
marketType String @map("market_type") @db.VarChar(64)
marketKey String? @map("market_key") @db.VarChar(64)
lineKey String? @map("line_key") @db.VarChar(180)
period String @db.VarChar(16)
lineValue Decimal? @map("line_value") @db.Decimal(8, 2)
paramsJson Json? @map("params_json")
status String @default("OPEN") @db.VarChar(20)
allowSingle Boolean @default(true) @map("allow_single")
allowParlay Boolean @default(true) @map("allow_parlay")
showOnPlayer Boolean @default(true) @map("show_on_player")
sortOrder Int @default(0) @map("sort_order")
promoLabel String? @map("promo_label") @db.VarChar(100)
promoLabelI18n Json? @map("promo_label_i18n")
nameI18n Json? @map("name_i18n")
templateItemId BigInt? @map("template_item_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
match Match @relation(fields: [matchId], references: [id])
selections MarketSelection[]
match Match @relation(fields: [matchId], references: [id])
selections MarketSelection[]
@@index([matchId])
@@index([matchId, lineKey])
@@index([marketType])
@@map("markets")
}
@@ -382,6 +398,7 @@ model MarketSelection {
marketId BigInt @map("market_id")
selectionCode String @map("selection_code") @db.VarChar(64)
selectionName String @map("selection_name") @db.VarChar(255)
nameI18n Json? @map("name_i18n")
odds Decimal @db.Decimal(18, 6)
oddsVersion BigInt @default(1) @map("odds_version")
status String @default("OPEN") @db.VarChar(20)
@@ -389,13 +406,80 @@ model MarketSelection {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
market Market @relation(fields: [marketId], references: [id])
oddsLogs OddsChangeLog[]
market Market @relation(fields: [marketId], references: [id])
oddsLogs OddsChangeLog[]
@@index([marketId])
@@map("market_selections")
}
model MarketTemplate {
id BigInt @id @default(autoincrement())
sportType String @default("FOOTBALL") @map("sport_type") @db.VarChar(20)
name String @db.VarChar(128)
nameI18n Json? @map("name_i18n")
description String? @db.VarChar(500)
isDefault Boolean @default(false) @map("is_default")
status String @default("ACTIVE") @db.VarChar(20)
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
items MarketTemplateItem[]
@@index([sportType, status])
@@index([isDefault])
@@map("market_templates")
}
model MarketTemplateItem {
id BigInt @id @default(autoincrement())
templateId BigInt @map("template_id")
marketType String @map("market_type") @db.VarChar(64)
marketKey String? @map("market_key") @db.VarChar(64)
lineKey String @map("line_key") @db.VarChar(180)
period String @db.VarChar(16)
lineValue Decimal? @map("line_value") @db.Decimal(8, 2)
paramsJson Json? @map("params_json")
status String @default("OPEN") @db.VarChar(20)
allowSingle Boolean @default(true) @map("allow_single")
allowParlay Boolean @default(true) @map("allow_parlay")
showOnPlayer Boolean @default(true) @map("show_on_player")
sortOrder Int @default(0) @map("sort_order")
promoLabel String? @map("promo_label") @db.VarChar(100)
promoLabelI18n Json? @map("promo_label_i18n")
nameI18n Json? @map("name_i18n")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
template MarketTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade)
selections MarketTemplateSelection[]
@@unique([templateId, lineKey])
@@index([templateId])
@@index([marketType])
@@map("market_template_items")
}
model MarketTemplateSelection {
id BigInt @id @default(autoincrement())
templateItemId BigInt @map("template_item_id")
selectionCode String @map("selection_code") @db.VarChar(64)
selectionName String @map("selection_name") @db.VarChar(255)
nameI18n Json? @map("name_i18n")
odds Decimal @db.Decimal(18, 6)
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")
item MarketTemplateItem @relation(fields: [templateItemId], references: [id], onDelete: Cascade)
@@unique([templateItemId, selectionCode])
@@index([templateItemId])
@@map("market_template_selections")
}
model OddsChangeLog {
id BigInt @id @default(autoincrement())
selectionId BigInt @map("selection_id")
@@ -405,7 +489,7 @@ model OddsChangeLog {
changedBy BigInt? @map("changed_by")
createdAt DateTime @default(now()) @map("created_at")
selection MarketSelection @relation(fields: [selectionId], references: [id])
selection MarketSelection @relation(fields: [selectionId], references: [id])
@@index([selectionId])
@@map("odds_change_logs")
@@ -433,9 +517,9 @@ model Bet {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id])
selections BetSelection[]
cashbackClaims CashbackBet[]
user User @relation(fields: [userId], references: [id])
selections BetSelection[]
cashbackClaims CashbackBet[]
@@unique([userId, requestId])
@@index([userId])
@@ -453,6 +537,7 @@ model BetSelection {
selectionId BigInt @map("selection_id")
marketType String @map("market_type") @db.VarChar(64)
period String? @db.VarChar(16)
marketNameSnapshot String? @map("market_name_snapshot") @db.VarChar(255)
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)
@@ -463,7 +548,7 @@ model BetSelection {
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at")
bet Bet @relation(fields: [betId], references: [id])
bet Bet @relation(fields: [betId], references: [id])
@@index([betId])
@@index([matchId])
@@ -473,41 +558,50 @@ model BetSelection {
// ============ 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")
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")
homeCorners Int? @map("home_corners")
awayCorners Int? @map("away_corners")
homeYellowCards Int? @map("home_yellow_cards")
awayYellowCards Int? @map("away_yellow_cards")
homeRedCards Int? @map("home_red_cards")
awayRedCards Int? @map("away_red_cards")
homeCards Int? @map("home_cards")
awayCards Int? @map("away_cards")
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[]
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")
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])
batch SettlementBatch @relation(fields: [batchId], references: [id])
@@unique([batchId, betId])
@@index([batchId])
@@index([betId])
@@map("settlement_items")
@@ -516,57 +610,59 @@ model SettlementItem {
// ============ 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")
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)
totalEffectiveStake Decimal @default(0) @map("total_effective_stake") @db.Decimal(18, 4)
totalBetCount Int @default(0) @map("total_bet_count")
playerCount Int @default(0) @map("player_count")
operatorId BigInt? @map("operator_id")
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)
totalEffectiveStake Decimal @default(0) @map("total_effective_stake") @db.Decimal(18, 4)
totalBetCount Int @default(0) @map("total_bet_count")
playerCount Int @default(0) @map("player_count")
operatorId BigInt? @map("operator_id")
confirmedAt DateTime? @map("confirmed_at")
createdAt DateTime @default(now()) @map("created_at")
createdAt DateTime @default(now()) @map("created_at")
items CashbackItem[]
bets CashbackBet[]
items CashbackItem[]
bets CashbackBet[]
@@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)
betCount Int @default(0) @map("bet_count")
rate Decimal @db.Decimal(8, 4)
amount Decimal @db.Decimal(18, 4)
createdAt DateTime @default(now()) @map("created_at")
id BigInt @id @default(autoincrement())
batchId BigInt @map("batch_id")
userId BigInt @map("user_id")
effectiveStake Decimal @map("effective_stake") @db.Decimal(18, 4)
betCount Int @default(0) @map("bet_count")
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])
batch CashbackBatch @relation(fields: [batchId], references: [id])
@@index([batchId])
@@index([userId])
@@map("cashback_items")
}
/** 返水批次占用的注单(每笔注单全局仅能计入一次待发放/已发放批次) */
/**
* 返水批次占用的注单(每笔注单全局仅能计入一次待发放/已发放批次)
*/
model CashbackBet {
id BigInt @id @default(autoincrement())
batchId BigInt @map("batch_id")
@@ -576,8 +672,8 @@ model CashbackBet {
amount Decimal @db.Decimal(18, 4)
createdAt DateTime @default(now()) @map("created_at")
batch CashbackBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
bet Bet @relation(fields: [betId], references: [id])
batch CashbackBatch @relation(fields: [batchId], references: [id], onDelete: Cascade)
bet Bet @relation(fields: [betId], references: [id])
@@index([batchId])
@@index([userId])
@@ -612,7 +708,7 @@ model ContentTranslation {
body String? @db.Text
imageUrl String? @map("image_url") @db.VarChar(500)
content Content @relation(fields: [contentId], references: [id])
content Content @relation(fields: [contentId], references: [id])
@@unique([contentId, locale])
@@map("content_translations")
@@ -649,20 +745,20 @@ model UploadedFile {
// ============ Manual Deposit / Recharge ============
model PaymentMethod {
id BigInt @id @default(autoincrement())
methodType String @map("method_type") @db.VarChar(20)
bankName String? @map("bank_name") @db.VarChar(128)
accountHolder String? @map("account_holder") @db.VarChar(128)
accountNumber String? @map("account_number") @db.VarChar(128)
usdtAddress String? @map("usdt_address") @db.VarChar(256)
qrCodeUrl String? @map("qr_code_url") @db.VarChar(500)
displayName String? @map("display_name") @db.VarChar(128)
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
showOnPlayer Boolean @default(true) @map("show_on_player")
createdBy BigInt? @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id BigInt @id @default(autoincrement())
methodType String @map("method_type") @db.VarChar(20)
bankName String? @map("bank_name") @db.VarChar(128)
accountHolder String? @map("account_holder") @db.VarChar(128)
accountNumber String? @map("account_number") @db.VarChar(128)
usdtAddress String? @map("usdt_address") @db.VarChar(256)
qrCodeUrl String? @map("qr_code_url") @db.VarChar(500)
displayName String? @map("display_name") @db.VarChar(128)
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
showOnPlayer Boolean @default(true) @map("show_on_player")
createdBy BigInt? @map("created_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
depositOrders DepositOrder[]
@@ -699,28 +795,28 @@ model DepositOrder {
// ============ 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")
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")
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])

View File

@@ -25,6 +25,62 @@ const outrightTeams = [...outrightSrc.matchAll(/code: '([^']+)', names: \{ 'zh-C
en: m[3],
}));
const CODE_TO_FLAG_ISO = {
ALG: 'dz',
ARG: 'ar',
AUS: 'au',
AUT: 'at',
BEL: 'be',
BIH: 'ba',
BRA: 'br',
CAN: 'ca',
CIV: 'ci',
COD: 'cd',
COL: 'co',
CPV: 'cv',
CRO: 'hr',
CUW: 'cw',
CZE: 'cz',
ECU: 'ec',
EGY: 'eg',
ENG: 'gb-eng',
ESP: 'es',
FRA: 'fr',
GER: 'de',
GHA: 'gh',
HAI: 'ht',
IRN: 'ir',
IRQ: 'iq',
JOR: 'jo',
JPN: 'jp',
KOR: 'kr',
KSA: 'sa',
MAR: 'ma',
MEX: 'mx',
NED: 'nl',
NOR: 'no',
NZL: 'nz',
PAN: 'pa',
PAR: 'py',
POR: 'pt',
QAT: 'qa',
RSA: 'za',
SCO: 'gb-sct',
SEN: 'sn',
SUI: 'ch',
SWE: 'se',
TUN: 'tn',
TUR: 'tr',
URU: 'uy',
USA: 'us',
UZB: 'uz',
};
function flagUrlForCode(code) {
const iso = CODE_TO_FLAG_ISO[code];
return iso ? `https://flagcdn.com/${iso}.svg` : '';
}
function pickNames(names) {
return {
zh: names?.zh ?? null,
@@ -37,11 +93,12 @@ function pickNames(names) {
}
function pickTeam(team) {
const code = resolveCanonicalCode(team);
return {
id: team.id,
name: team.name,
names: pickNames(team.names),
image: team.image ?? '',
image: code ? flagUrlForCode(code) : '',
};
}
@@ -65,7 +122,7 @@ function slimMatch(m) {
},
homeTeam: pickTeam(m.homeTeam),
awayTeam: pickTeam(m.awayTeam),
status: { state: m.status.state, isHot: m.status.isHot ?? 0 },
status: { state: m.status.state, isHot: 0 },
venue: {
names: pickNames(m.venue?.names),
city: pickNames(m.venue?.city),
@@ -76,7 +133,6 @@ function slimMatch(m) {
}
function resolveCanonicalCode(team) {
if (team.id == null) return null;
const en = (team.name || team.names?.en || '').toLowerCase();
const zh = team.names?.zh || '';
const hit = outrightTeams.find(
@@ -104,13 +160,16 @@ for (const m of raw.matches || []) {
}
const zhiboToCode = {};
const logoByCode = {};
const unmatched = [];
const missingFlagCodes = new Set();
for (const [id, team] of teamById) {
const code = resolveCanonicalCode(team);
if (code) {
zhiboToCode[id] = code;
if (team.image) logoByCode[code] = team.image;
const flagUrl = flagUrlForCode(code);
if (!flagUrl) {
missingFlagCodes.add(code);
}
} else {
unmatched.push({ id, name: team.name });
}
@@ -125,9 +184,9 @@ const mapLines = Object.entries(zhiboToCode)
.map(([id, code]) => ` ${id}: '${code}',`)
.join('\n');
const logoLines = Object.entries(logoByCode)
const flagIsoLines = Object.entries(CODE_TO_FLAG_ISO)
.sort(([a], [b]) => a.localeCompare(b))
.map(([code, url]) => ` ${code}: '${url.replace(/'/g, "\\'")}',`)
.map(([code, iso]) => ` ${code}: '${iso}',`)
.join('\n');
const mapTs = `/** 由 build-wc2026-seed-json.mjs 生成 — zhibo externalId → WC2026 canonical code */
@@ -135,10 +194,17 @@ export const WC2026_ZIBO_ID_TO_CODE: Record<number, string> = {
${mapLines}
};
/** zhibo 球队 logoseed 时写入 teams.logo_url */
export const WC2026_TEAM_LOGO_BY_CODE: Record<string, string> = {
${logoLines}
/** WC2026 默认国家/地区国旗FlagCDNseed 时写入 teams.logo_url */
export const WC2026_FLAG_ISO_BY_CODE: Record<string, string> = {
${flagIsoLines}
};
export const WC2026_TEAM_LOGO_BY_CODE: Record<string, string> = Object.fromEntries(
Object.entries(WC2026_FLAG_ISO_BY_CODE).map(([code, iso]) => [
code,
\`https://flagcdn.com/\${iso}.svg\`,
]),
);
`;
const mapOut = path.join(seedDataDir, 'wc2026-zhibo-team-map.ts');
@@ -150,3 +216,7 @@ if (unmatched.length) {
console.warn('Unmatched teams:', unmatched);
process.exit(1);
}
if (missingFlagCodes.size) {
console.warn('Missing FlagCDN ISO mapping:', [...missingFlagCodes].sort());
process.exit(1);
}

View File

@@ -81,6 +81,14 @@ type AdminUploadUser = {
permissions?: string[];
};
function parseMatchStartTime(value: string): Date {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw appBadRequest('MATCH_START_TIME_INVALID');
}
return date;
}
function uploadCategory(value?: string): UploadCategory {
const category = (value || 'contents').trim();
if (UPLOAD_CATEGORIES.includes(category as UploadCategory)) {
@@ -503,9 +511,6 @@ class CreatePlatformMatchDto {
@IsString()
awayTeamLogoUrl?: string;
@IsOptional()
@IsBoolean()
correctScoreEnabled?: boolean;
}
class UpdatePlatformMatchDto {
@@ -560,9 +565,6 @@ class UpdatePlatformMatchDto {
@IsString()
awayTeamLogoUrl?: string;
@IsOptional()
@IsBoolean()
correctScoreEnabled?: boolean;
}
class ReopenMatchDto {
@@ -591,6 +593,12 @@ class UpdateMarketDto {
@IsString()
promoLabel?: string | null;
@IsOptional()
promoLabelI18n?: Record<string, string> | null;
@IsOptional()
nameI18n?: Record<string, string> | null;
@IsOptional()
@IsString()
status?: string;
@@ -598,6 +606,10 @@ class UpdateMarketDto {
@IsOptional()
@IsNumber()
lineValue?: number | null;
@IsOptional()
@IsBoolean()
showOnPlayer?: boolean;
}
class UpdateSelectionDto {
@@ -605,6 +617,9 @@ class UpdateSelectionDto {
@IsString()
selectionName?: string;
@IsOptional()
nameI18n?: Record<string, string> | null;
@IsOptional()
@IsNumber()
@Min(1.01)
@@ -637,6 +652,38 @@ class ScoreDto {
@IsNumber()
ftAway?: number;
@IsOptional()
@IsNumber()
homeCorners?: number;
@IsOptional()
@IsNumber()
awayCorners?: number;
@IsOptional()
@IsNumber()
homeYellowCards?: number;
@IsOptional()
@IsNumber()
awayYellowCards?: number;
@IsOptional()
@IsNumber()
homeRedCards?: number;
@IsOptional()
@IsNumber()
awayRedCards?: number;
@IsOptional()
@IsNumber()
homeCards?: number;
@IsOptional()
@IsNumber()
awayCards?: number;
/** 冠军盘结算:获胜球队 ID */
@IsOptional()
@IsNumber()
@@ -674,6 +721,136 @@ class MarketTemplatesDto {
marketTypes!: string[];
}
class MarketDraftSelectionDto {
@IsOptional()
@IsString()
id?: string;
@IsString()
selectionCode!: string;
@IsOptional()
@IsString()
selectionName?: string;
@IsOptional()
nameI18n?: Record<string, string> | null;
@IsNumber()
@Min(1.01)
odds!: number;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsNumber()
sortOrder?: number;
}
class MarketDraftDto {
@IsOptional()
@IsString()
id?: string;
@IsString()
marketType!: string;
@IsOptional()
@IsString()
marketKey?: string | null;
@IsOptional()
@IsString()
lineKey?: string | null;
@IsOptional()
@IsString()
period?: string;
@IsOptional()
@IsNumber()
lineValue?: number | null;
@IsOptional()
paramsJson?: Record<string, unknown> | null;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsBoolean()
allowSingle?: boolean;
@IsOptional()
@IsBoolean()
allowParlay?: boolean;
@IsOptional()
@IsBoolean()
showOnPlayer?: boolean;
@IsOptional()
@IsNumber()
sortOrder?: number;
@IsOptional()
@IsString()
promoLabel?: string | null;
@IsOptional()
promoLabelI18n?: Record<string, string> | null;
@IsOptional()
nameI18n?: Record<string, string> | null;
@IsOptional()
@IsArray()
selections?: MarketDraftSelectionDto[];
}
class MarketTemplateSaveDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
nameI18n?: Record<string, string> | null;
@IsOptional()
@IsString()
description?: string | null;
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsNumber()
sortOrder?: number;
@IsOptional()
@IsArray()
items?: MarketDraftDto[];
}
class ApplyMarketTemplateDto {
@IsOptional()
@IsString()
templateId?: string;
}
class BulkMatchMarketsDto {
@IsArray()
markets!: MarketDraftDto[];
}
class UpdateOddsDto {
@IsNumber()
odds!: number;
@@ -892,6 +1069,38 @@ class ResettlePreviewDto {
@IsNumber()
ftAway?: number;
@IsOptional()
@IsNumber()
homeCorners?: number;
@IsOptional()
@IsNumber()
awayCorners?: number;
@IsOptional()
@IsNumber()
homeYellowCards?: number;
@IsOptional()
@IsNumber()
awayYellowCards?: number;
@IsOptional()
@IsNumber()
homeRedCards?: number;
@IsOptional()
@IsNumber()
awayRedCards?: number;
@IsOptional()
@IsNumber()
homeCards?: number;
@IsOptional()
@IsNumber()
awayCards?: number;
@IsOptional()
@IsString()
reason?: string;
@@ -1665,7 +1874,7 @@ export class AdminController {
awayTeamEn: dto.awayTeamEn,
awayTeamZh: dto.awayTeamZh,
awayTeamMs: dto.awayTeamMs,
startTime: new Date(dto.startTime),
startTime: parseMatchStartTime(dto.startTime),
isHot: dto.isHot,
displayOrder: dto.displayOrder,
matchName: dto.matchName,
@@ -1673,7 +1882,6 @@ export class AdminController {
groupName: dto.groupName,
homeTeamLogoUrl: dto.homeTeamLogoUrl,
awayTeamLogoUrl: dto.awayTeamLogoUrl,
correctScoreEnabled: dto.correctScoreEnabled,
updatedBy: operatorId,
});
await this.outright.syncOutrightTeamsForLeagueIfExists(match.leagueId);
@@ -1725,9 +1933,8 @@ export class AdminController {
awayTeamEn: dto.awayTeamEn,
awayTeamZh: dto.awayTeamZh,
awayTeamMs: dto.awayTeamMs,
startTime: new Date(dto.startTime),
startTime: parseMatchStartTime(dto.startTime),
isHot: dto.isHot,
correctScoreEnabled: dto.correctScoreEnabled,
displayOrder: dto.displayOrder,
matchName: dto.matchName,
stage: dto.stage,
@@ -1775,7 +1982,7 @@ export class AdminController {
@Post('matches/:id/reopen')
@RequirePermissions(P.matches)
async reopenMatch(@Param('id') id: string, @Body() dto: ReopenMatchDto) {
const startTime = dto.startTime ? new Date(dto.startTime) : undefined;
const startTime = dto.startTime ? parseMatchStartTime(dto.startTime) : undefined;
const match = await this.matches.reopenMatch(BigInt(id), startTime);
return jsonResponse(match);
}
@@ -1783,11 +1990,58 @@ export class AdminController {
@Post('matches/:id/cancel')
@RequirePermissions(P.matches)
async cancelMatch(@Param('id') id: string) {
await this.matches.cancelMatch(BigInt(id));
const voided = await this.settlement.voidMatchBets(BigInt(id));
const voided = await this.settlement.cancelMatchAndVoidBets(BigInt(id));
return jsonResponse(voided);
}
@Get('market-definitions')
@RequirePermissions(P.matches)
async listMarketDefinitions() {
return jsonResponse(this.markets.listMarketDefinitions());
}
@Get('market-templates')
@RequirePermissions(P.matches)
async listMarketTemplates() {
const templates = await this.markets.listTemplates();
return jsonResponse(templates);
}
@Post('market-templates')
@RequirePermissions(P.matches)
async createMarketTemplate(@Body() dto: MarketTemplateSaveDto) {
const template = await this.markets.createTemplate(dto);
return jsonResponse(template);
}
@Get('market-templates/:id')
@RequirePermissions(P.matches)
async getMarketTemplate(@Param('id') id: string) {
const template = await this.markets.getTemplate(BigInt(id));
return jsonResponse(template);
}
@Put('market-templates/:id')
@RequirePermissions(P.matches)
async updateMarketTemplate(@Param('id') id: string, @Body() dto: MarketTemplateSaveDto) {
const template = await this.markets.updateTemplate(BigInt(id), dto);
return jsonResponse(template);
}
@Post('market-templates/:id/duplicate')
@RequirePermissions(P.matches)
async duplicateMarketTemplate(@Param('id') id: string) {
const template = await this.markets.duplicateTemplate(BigInt(id));
return jsonResponse(template);
}
@Post('market-templates/:id/set-default')
@RequirePermissions(P.matches)
async setDefaultMarketTemplate(@Param('id') id: string) {
const template = await this.markets.setDefaultTemplate(BigInt(id));
return jsonResponse(template);
}
@Post('matches/:id/markets/templates')
@RequirePermissions(P.matches)
async generateTemplates(@Param('id') id: string, @Body() dto: MarketTemplatesDto) {
@@ -1795,6 +2049,27 @@ export class AdminController {
return jsonResponse(markets);
}
@Post('matches/:id/markets/apply-template')
@RequirePermissions(P.matches)
async applyMarketTemplate(@Param('id') id: string, @Body() dto: ApplyMarketTemplateDto) {
const result = await this.markets.applyTemplateToMatch(
BigInt(id),
dto.templateId ? BigInt(dto.templateId) : null,
);
return jsonResponse(result);
}
@Put('matches/:id/markets/bulk')
@RequirePermissions(P.matches)
async bulkSaveMatchMarkets(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: BulkMatchMarketsDto,
) {
const result = await this.markets.saveMatchMarkets(BigInt(id), dto.markets, operatorId);
return jsonResponse(result);
}
@Put('matches/:id/odds')
@RequirePermissions(P.matches)
async batchUpdateMatchOdds(
@@ -1815,8 +2090,11 @@ export class AdminController {
async updateMarket(@Param('id') id: string, @Body() dto: UpdateMarketDto) {
const market = await this.markets.updateMarket(BigInt(id), {
promoLabel: dto.promoLabel,
promoLabelI18n: dto.promoLabelI18n,
nameI18n: dto.nameI18n,
status: dto.status,
lineValue: dto.lineValue,
showOnPlayer: dto.showOnPlayer,
});
return jsonResponse(market);
}
@@ -1832,6 +2110,7 @@ export class AdminController {
BigInt(id),
{
selectionName: dto.selectionName,
nameI18n: dto.nameI18n,
odds: dto.odds,
status: dto.status,
},
@@ -2057,6 +2336,16 @@ export class AdminController {
dto.ftAway ?? 0,
operatorId,
dto.winnerTeamId != null ? BigInt(dto.winnerTeamId) : undefined,
{
homeCorners: dto.homeCorners ?? null,
awayCorners: dto.awayCorners ?? null,
homeYellowCards: dto.homeYellowCards ?? null,
awayYellowCards: dto.awayYellowCards ?? null,
homeRedCards: dto.homeRedCards ?? null,
awayRedCards: dto.awayRedCards ?? null,
homeCards: dto.homeCards ?? null,
awayCards: dto.awayCards ?? null,
},
);
return jsonResponse(result);
}
@@ -2074,6 +2363,14 @@ export class AdminController {
htAway: dto?.htAway,
ftHome: dto?.ftHome,
ftAway: dto?.ftAway,
homeCorners: dto?.homeCorners,
awayCorners: dto?.awayCorners,
homeYellowCards: dto?.homeYellowCards,
awayYellowCards: dto?.awayYellowCards,
homeRedCards: dto?.homeRedCards,
awayRedCards: dto?.awayRedCards,
homeCards: dto?.homeCards,
awayCards: dto?.awayCards,
winnerTeamId: dto?.winnerTeamId != null ? BigInt(dto.winnerTeamId) : undefined,
page: dto?.page ? Math.max(1, dto.page) : 1,
pageSize: dto?.pageSize ? Math.min(100, Math.max(1, dto.pageSize)) : 10,
@@ -2123,6 +2420,14 @@ export class AdminController {
htAway: dto.htAway ?? 0,
ftHome: dto.ftHome ?? 0,
ftAway: dto.ftAway ?? 0,
homeCorners: dto.homeCorners ?? null,
awayCorners: dto.awayCorners ?? null,
homeYellowCards: dto.homeYellowCards ?? null,
awayYellowCards: dto.awayYellowCards ?? null,
homeRedCards: dto.homeRedCards ?? null,
awayRedCards: dto.awayRedCards ?? null,
homeCards: dto.homeCards ?? null,
awayCards: dto.awayCards ?? null,
},
operatorId,
dto.reason,

View File

@@ -93,6 +93,28 @@ class UpdateProfileDto {
username?: string;
}
function safeTimeZone(input?: string): string {
const value = input?.trim();
if (!value) return 'UTC';
try {
new Intl.DateTimeFormat('en-US', { timeZone: value }).format(new Date());
return value;
} catch {
return 'UTC';
}
}
function dayKeyInTimeZone(date: Date, timeZone: string): string {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date);
const map = new Map(parts.map((part) => [part.type, part.value]));
return `${map.get('year')}-${map.get('month')}-${map.get('day')}`;
}
@ApiTags('Player')
@Controller('player')
@UseGuards(JwtAuthGuard, PlayerGuard)
@@ -160,6 +182,7 @@ export class PlayerController {
async home(
@CurrentUser('locale') userLocale: string | undefined,
@Headers('x-locale') headerLocale?: string,
@Headers('x-time-zone') headerTimeZone?: string,
) {
const locale = userLocale || headerLocale || 'zh-CN';
const [banners, announcements, allMatches] = await Promise.all([
@@ -167,19 +190,13 @@ export class PlayerController {
this.content.listActiveAnnouncements(locale),
this.matches.listPublished(locale, undefined, { includeMarkets: false }),
]);
const now = new Date();
const isKickoffToday = (startTime: string) => {
const d = new Date(startTime);
return (
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate()
);
};
const timeZone = safeTimeZone(headerTimeZone);
const todayKey = dayKeyInTimeZone(new Date(), timeZone);
const hotMatches = (allMatches as Array<{ isHot?: boolean }>).filter((m) => m.isHot);
const todayMatches = (allMatches as Array<{ startTime: string }>).filter((m) =>
isKickoffToday(m.startTime),
);
const todayMatches = (allMatches as Array<{ startTime: string }>).filter((m) => {
const kickoff = new Date(m.startTime);
return !Number.isNaN(kickoff.getTime()) && dayKeyInTimeZone(kickoff, timeZone) === todayKey;
});
return jsonResponse({
banners,
announcements,

View File

@@ -0,0 +1,97 @@
import { Decimal } from '@prisma/client/runtime/library';
import { AgentCreditService } from './agent-credit.service';
import { expectAppError } from '../../testing/prisma-mock';
describe('AgentCreditService', () => {
const tx = {
$queryRaw: jest.fn(),
user: {
findFirst: jest.fn(),
findMany: jest.fn(),
},
agentClosure: {
findMany: jest.fn(),
},
agentProfile: {
findUnique: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
walletTransaction: {
findUnique: jest.fn(),
aggregate: jest.fn(),
},
};
const prisma = {
...tx,
$transaction: jest.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
};
const funds = {
deposit: jest.fn(),
withdraw: jest.fn(),
};
let service: AgentCreditService;
beforeEach(() => {
jest.clearAllMocks();
service = new AgentCreditService(prisma as never, funds as never);
tx.walletTransaction.findUnique.mockResolvedValue(null);
tx.user.findFirst.mockResolvedValue({ id: 10n, parentId: 1n, userType: 'PLAYER' });
tx.user.findMany.mockResolvedValue([]);
tx.agentClosure.findMany.mockResolvedValue([{ ancestorId: 1n, depth: 0 }]);
tx.agentProfile.findMany.mockResolvedValue([]);
tx.agentProfile.update.mockResolvedValue({});
tx.$queryRaw.mockResolvedValue([
{
user_id: 1n,
parent_agent_id: null,
credit_limit: new Decimal(1000),
used_credit: new Decimal(0),
max_single_deposit: null,
max_daily_deposit: new Decimal(100),
},
]);
tx.agentProfile.findUnique.mockResolvedValue({
userId: 1n,
parentAgentId: null,
creditLimit: new Decimal(1000),
usedCredit: new Decimal(0),
maxSingleDeposit: null,
maxDailyDeposit: new Decimal(100),
});
tx.walletTransaction.aggregate.mockResolvedValue({ _sum: { amount: new Decimal(80) } });
});
it('counts prior AGENT_DEPOSIT postings when enforcing the daily top-up limit', async () => {
await expect(
service.depositToPlayer(1n, 10n, 30, 'REQ-DAILY-LIMIT'),
).rejects.toMatchObject(expectAppError('AGENT_DAILY_TOPUP_LIMIT'));
expect(tx.walletTransaction.aggregate).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
operatorId: 1n,
transactionType: { in: ['AGENT_DEPOSIT', 'MANUAL_DEPOSIT'] },
}),
}),
);
expect(funds.deposit).not.toHaveBeenCalled();
});
it('recalculates the changed agent and every ancestor in closure order', async () => {
tx.agentClosure.findMany.mockResolvedValue([
{ ancestorId: 3n, depth: 0 },
{ ancestorId: 2n, depth: 1 },
{ ancestorId: 1n, depth: 2 },
]);
await service.recalculateUsedCredit(3n);
expect(tx.agentProfile.update.mock.calls.map(([arg]) => arg.where.userId)).toEqual([
3n,
2n,
1n,
]);
});
});

View File

@@ -0,0 +1,474 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Decimal } from '@prisma/client/runtime/library';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { FundsPostingService } from '../ledger/funds-posting.service';
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
type TxClient = Prisma.TransactionClient;
type PrismaClientLike = PrismaService | TxClient;
const AGENT_DAILY_DEPOSIT_TX_TYPES = ['AGENT_DEPOSIT', 'MANUAL_DEPOSIT'] as const;
function dec(v: Decimal | null | undefined) {
return v?.toString() ?? '0';
}
@Injectable()
export class AgentCreditService {
constructor(
private prisma: PrismaService,
private funds: FundsPostingService,
) {}
async getProfile(agentId: bigint) {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: agentId },
});
if (!profile) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
return { ...profile, availableCredit: available };
}
async recalculateUsedCredit(agentId: bigint, tx?: TxClient) {
const client = tx ?? this.prisma;
const cascadeIds = await this.getCreditCascadeIds(agentId, client);
let requestedUsedCredit = new Decimal(0);
for (const id of cascadeIds) {
const usedCredit = await this.recalculateOneUsedCredit(id, client);
if (id === agentId) requestedUsedCredit = usedCredit;
}
return requestedUsedCredit;
}
private async recalculateOneUsedCredit(agentId: bigint, client: PrismaClientLike) {
const directPlayers = await client.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 client.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 client.agentProfile.update({
where: { userId: agentId },
data: {
usedCredit,
directPlayerLiability: directLiability,
childAgentExposure: childExposure,
},
});
return usedCredit;
}
private async getCreditCascadeIds(agentId: bigint, client: PrismaClientLike) {
const closureRows = await client.agentClosure.findMany({
where: { descendantId: agentId },
select: { ancestorId: true, depth: true },
orderBy: { depth: 'asc' },
});
if (closureRows.length > 0) {
return closureRows.map((row) => row.ancestorId);
}
const ids: bigint[] = [];
let current: bigint | null = agentId;
while (current) {
ids.push(current);
const profile: { parentAgentId: bigint | null } | null = await client.agentProfile.findUnique({
where: { userId: current },
select: { parentAgentId: true },
});
current = profile?.parentAgentId ?? null;
}
return ids;
}
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 appBadRequest('AGENT_NOT_FOUND');
const creditBefore = profile.creditLimit;
const creditAfter = creditBefore.add(amt);
if (creditAfter.lt(0)) throw appBadRequest('CREDIT_LIMIT_NEGATIVE');
if (profile.parentAgentId) {
await this.assertChildCreditWithinParent(profile.parentAgentId, profile, creditAfter);
}
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,
},
});
});
await this.recalculateUsedCredit(agentId);
return { creditAfter };
}
async assertPlayerParentCreditForDeposit(playerId: bigint, amount: Decimal | number) {
const user = await this.prisma.user.findFirst({
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
select: { parentId: true },
});
if (!user?.parentId) return;
await this.recalculateUsedCredit(user.parentId);
const profile = await this.getProfile(user.parentId);
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
const amt = new Decimal(amount);
if (available.lt(amt)) {
throw appBadRequest('CREDIT_TOPUP_EXCEEDED');
}
}
async adminDepositToPlayer(
playerId: bigint,
amount: number,
operatorId: bigint,
remark?: string,
requestId?: string,
) {
await this.assertPlayerParentCreditForDeposit(playerId, amount);
const result = await this.funds.deposit({
userId: playerId,
amount,
operatorId,
remark: remark ?? '管理员上分',
referenceId: requestId,
transactionType: 'ADMIN_DEPOSIT',
businessKey: requestId ? `admin-deposit:${operatorId}:${requestId}` : undefined,
});
const player = await this.prisma.user.findUnique({
where: { id: playerId },
select: { parentId: true },
});
if (player?.parentId) {
await this.recalculateUsedCredit(player.parentId);
}
return result;
}
async adminWithdrawFromPlayer(
playerId: bigint,
amount: number,
operatorId: bigint,
remark?: string,
requestId?: string,
) {
const result = await this.funds.withdraw({
userId: playerId,
amount,
operatorId,
remark: remark ?? '管理员下分',
referenceId: requestId,
transactionType: 'ADMIN_WITHDRAW',
businessKey: requestId ? `admin-withdraw:${operatorId}:${requestId}` : undefined,
});
const player = await this.prisma.user.findUnique({
where: { id: playerId },
select: { parentId: true },
});
if (player?.parentId) {
await this.recalculateUsedCredit(player.parentId);
}
return result;
}
async getPlayerTransferContext(
playerId: bigint,
options: { forAdmin?: boolean; actingAgentId?: bigint } = {},
) {
const player = await this.prisma.user.findFirst({
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
include: { wallet: true },
});
if (!player) throw appNotFound('PLAYER_NOT_FOUND');
if (options.actingAgentId) {
await this.requireDirectPlayer(options.actingAgentId, playerId);
}
const creditAgentId = options.forAdmin ? player.parentId : (options.actingAgentId ?? null);
let credit: Record<string, unknown> | null = null;
if (creditAgentId) {
await this.recalculateUsedCredit(creditAgentId);
const profile = await this.getProfile(creditAgentId);
const parent = profile.parentAgentId
? await this.prisma.agentProfile.findUnique({ where: { userId: profile.parentAgentId } })
: null;
const { maxSingleDeposit, maxDailyDeposit } = this.resolveEffectiveDepositLimits(profile, parent);
let dailyDepositUsed: string | null = null;
if (!options.forAdmin) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const dailyAgg = await this.prisma.walletTransaction.aggregate({
where: {
operatorId: creditAgentId,
transactionType: { in: [...AGENT_DAILY_DEPOSIT_TX_TYPES] },
createdAt: { gte: today },
},
_sum: { amount: true },
});
dailyDepositUsed = dec(dailyAgg._sum.amount);
}
const agentUser = await this.prisma.user.findUnique({
where: { id: creditAgentId },
select: { username: true },
});
credit = {
agentId: creditAgentId.toString(),
agentUsername: agentUser?.username ?? '',
agentLevel: profile.level,
creditLimit: dec(profile.creditLimit),
usedCredit: dec(profile.usedCredit),
availableCredit: dec(profile.availableCredit),
maxSingleDeposit: maxSingleDeposit?.toString() ?? null,
maxDailyDeposit: maxDailyDeposit?.toString() ?? null,
dailyDepositUsed,
appliesDepositLimits: !options.forAdmin,
};
}
return {
player: {
id: player.id.toString(),
username: player.username,
availableBalance: dec(player.wallet?.availableBalance),
frozenBalance: dec(player.wallet?.frozenBalance),
},
credit,
};
}
async depositToPlayer(
agentId: bigint,
playerId: bigint,
amount: number,
requestId: string,
remark?: string,
) {
const amt = new Decimal(amount);
const businessKey = `agent-deposit:${agentId}:${requestId}`;
await this.prisma.$transaction(async (tx) => {
const existing = await tx.walletTransaction.findUnique({ where: { businessKey } });
if (existing) return;
await this.requireDirectPlayer(agentId, playerId, tx);
await this.recalculateUsedCredit(agentId, tx);
const profile = await this.lockAgentProfile(tx, agentId);
const available = new Decimal(profile.credit_limit).sub(profile.used_credit);
if (available.lt(amt)) {
throw appBadRequest('INSUFFICIENT_AGENT_CREDIT');
}
await this.assertAgentDepositLimits(agentId, amt, tx);
await this.funds.deposit({
userId: playerId,
amount: amt,
operatorId: agentId,
remark: remark ?? '代理上分',
referenceId: requestId,
transactionType: 'AGENT_DEPOSIT',
businessKey,
tx,
});
await this.recalculateUsedCredit(agentId, tx);
});
return { success: true };
}
async withdrawFromPlayer(
agentId: bigint,
playerId: bigint,
amount: number,
requestId: string,
remark?: string,
) {
await this.requireDirectPlayer(agentId, playerId);
await this.funds.withdraw({
userId: playerId,
amount,
operatorId: agentId,
remark: remark ?? '代理下分',
referenceId: requestId,
transactionType: 'AGENT_WITHDRAW',
businessKey: `agent-withdraw:${agentId}:${requestId}`,
});
await this.recalculateUsedCredit(agentId);
return { success: true };
}
private async requireDirectPlayer(agentId: bigint, playerId: bigint, tx?: TxClient) {
const client = tx ?? this.prisma;
const player = await client.user.findFirst({
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
include: { auth: true, wallet: true, preferences: true },
});
if (!player) throw appNotFound('PLAYER_NOT_FOUND');
if (player.parentId !== agentId) {
throw appForbidden('MANAGE_DIRECT_PLAYERS_ONLY');
}
return player;
}
private resolveEffectiveDepositLimits(
profile: {
maxSingleDeposit: Decimal | null;
maxDailyDeposit: Decimal | null;
},
parent?: { maxSingleDeposit: Decimal | null; maxDailyDeposit: Decimal | null } | null,
) {
let maxSingleDeposit = profile.maxSingleDeposit;
let maxDailyDeposit = profile.maxDailyDeposit;
if (parent) {
if (parent.maxSingleDeposit != null) {
maxSingleDeposit =
maxSingleDeposit != null
? Decimal.min(maxSingleDeposit, parent.maxSingleDeposit)
: parent.maxSingleDeposit;
}
if (parent.maxDailyDeposit != null) {
maxDailyDeposit =
maxDailyDeposit != null
? Decimal.min(maxDailyDeposit, parent.maxDailyDeposit)
: parent.maxDailyDeposit;
}
}
return { maxSingleDeposit, maxDailyDeposit };
}
private async assertAgentDepositLimits(
creditAgentId: bigint,
amount: Decimal,
tx?: TxClient,
) {
const client = tx ?? this.prisma;
const profile = await client.agentProfile.findUnique({
where: { userId: creditAgentId },
});
if (!profile) return;
const parent = profile.parentAgentId
? await client.agentProfile.findUnique({
where: { userId: profile.parentAgentId },
})
: null;
const { maxSingleDeposit, maxDailyDeposit } = this.resolveEffectiveDepositLimits(profile, parent);
if (maxSingleDeposit && amount.gt(maxSingleDeposit)) {
throw appBadRequest('AGENT_SINGLE_TOPUP_LIMIT');
}
if (maxDailyDeposit) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const dailyAgg = await client.walletTransaction.aggregate({
where: {
operatorId: creditAgentId,
transactionType: { in: [...AGENT_DAILY_DEPOSIT_TX_TYPES] },
createdAt: { gte: today },
},
_sum: { amount: true },
});
const dailyTotal = new Decimal(dailyAgg._sum.amount ?? 0).add(amount);
if (dailyTotal.gt(maxDailyDeposit)) {
throw appBadRequest('AGENT_DAILY_TOPUP_LIMIT');
}
}
}
private async lockAgentProfile(tx: TxClient, agentId: bigint) {
const profiles = await tx.$queryRaw<
Array<{
user_id: bigint;
parent_agent_id: bigint | null;
credit_limit: Decimal;
used_credit: Decimal;
max_single_deposit: Decimal | null;
max_daily_deposit: Decimal | null;
}>
>`
SELECT user_id, parent_agent_id, credit_limit, used_credit, max_single_deposit, max_daily_deposit
FROM agent_profiles
WHERE user_id = ${agentId}
FOR UPDATE
`;
if (!profiles.length) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
return profiles[0];
}
private async assertChildCreditWithinParent(
parentAgentId: bigint,
childProfile: { userId: bigint; creditLimit: Decimal; usedCredit: Decimal },
creditAfter: Decimal,
) {
const parent = await this.getProfile(parentAgentId);
const parentAvailable = new Decimal(parent.creditLimit).sub(parent.usedCredit);
const oldExposure = Decimal.max(childProfile.creditLimit, childProfile.usedCredit);
const newExposure = Decimal.max(creditAfter, childProfile.usedCredit);
const exposureDelta = newExposure.sub(oldExposure);
if (creditAfter.lt(0)) throw appBadRequest('CREDIT_LIMIT_NEGATIVE');
if (creditAfter.gt(parent.creditLimit)) throw appBadRequest('CREDIT_EXCEEDS_PARENT');
if (exposureDelta.gt(0) && exposureDelta.gt(parentAvailable)) {
throw appBadRequest('INSUFFICIENT_AGENT_CREDIT');
}
}
}

View File

@@ -0,0 +1,92 @@
import { AgentNetworkService } from './agent-network.service';
describe('AgentNetworkService', () => {
let prisma: {
agentClosure: { findMany: jest.Mock };
agentProfile: { findUnique: jest.Mock; findMany: jest.Mock };
user: { findMany: jest.Mock; findFirst: jest.Mock };
};
let service: AgentNetworkService;
beforeEach(() => {
prisma = {
agentClosure: { findMany: jest.fn() },
agentProfile: {
findUnique: jest.fn(),
findMany: jest.fn(),
},
user: {
findMany: jest.fn(),
findFirst: jest.fn(),
},
};
service = new AgentNetworkService(prisma as never);
});
it('resolves portal scope from AgentClosure rows', async () => {
prisma.agentProfile.findUnique.mockResolvedValue({ userId: 1n, level: 2 });
prisma.agentClosure.findMany.mockResolvedValue([
{ descendantId: 1n, depth: 0 },
{ descendantId: 2n, depth: 1 },
{ descendantId: 4n, depth: 1 },
{ descendantId: 3n, depth: 2 },
]);
const scope = await service.resolveScope(1n);
expect(scope.rootAgentId).toBe(1n);
expect(scope.rootLevel).toBe(2);
expect(scope.subtreeIds).toEqual([1n, 2n, 4n, 3n]);
expect(scope.descendantIds).toEqual([2n, 4n, 3n]);
expect(scope.directChildIds).toEqual([2n, 4n]);
expect(scope.subtreeIdSet.has('3')).toBe(true);
expect(prisma.agentProfile.findMany).not.toHaveBeenCalled();
});
it('falls back to AgentProfile adjacency when closure rows are absent', async () => {
prisma.agentProfile.findUnique.mockResolvedValue({ userId: 1n, level: 1 });
prisma.agentClosure.findMany.mockResolvedValue([]);
prisma.agentProfile.findMany.mockImplementation(({ where }: { where: { parentAgentId: bigint } }) => {
const childrenByParent = new Map<bigint, Array<{ userId: bigint }>>([
[1n, [{ userId: 2n }, { userId: 4n }]],
[2n, [{ userId: 3n }]],
[3n, []],
[4n, []],
]);
return Promise.resolve(childrenByParent.get(where.parentAgentId) ?? []);
});
const scope = await service.resolveScope(1n);
expect(scope.subtreeIds).toEqual([1n, 2n, 4n, 3n]);
expect(scope.descendantIds).toEqual([2n, 4n, 3n]);
expect(scope.directChildIds).toEqual([2n, 4n]);
});
it('rejects agent outside descendant scope', async () => {
prisma.agentClosure.findMany.mockResolvedValue([
{ descendantId: 1n, depth: 0 },
{ descendantId: 2n, depth: 1 },
]);
await expect(service.assertDescendantAgent(1n, 9n)).rejects.toMatchObject({
response: expect.objectContaining({ code: 'NOT_SUB_AGENT' }),
});
expect(prisma.agentProfile.findUnique).not.toHaveBeenCalled();
});
it('returns direct child profile for matching parent', async () => {
const profile = { userId: 2n, parentAgentId: 1n };
prisma.agentProfile.findUnique.mockResolvedValue(profile);
await expect(service.assertDirectChildAgent(1n, 2n)).resolves.toBe(profile);
});
it('rejects non-direct child agent', async () => {
prisma.agentProfile.findUnique.mockResolvedValue({ userId: 2n, parentAgentId: 9n });
await expect(service.assertDirectChildAgent(1n, 2n)).rejects.toMatchObject({
response: expect.objectContaining({ code: 'NOT_SUB_AGENT' }),
});
});
});

View File

@@ -0,0 +1,337 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
export interface AgentScope {
rootAgentId: bigint;
rootLevel: number;
subtreeIds: bigint[];
descendantIds: bigint[];
directChildIds: bigint[];
subtreeIdSet: Set<string>;
}
type ClosureRow = {
ancestorId?: bigint;
descendantId: bigint;
depth: number;
};
function uniqueBigints(values: bigint[]) {
const seen = new Set<string>();
const out: bigint[] = [];
for (const value of values) {
const key = value.toString();
if (seen.has(key)) continue;
seen.add(key);
out.push(value);
}
return out;
}
@Injectable()
export class AgentNetworkService {
constructor(private prisma: PrismaService) {}
async buildAgentAncestorChainMap(parentAgentIds: (bigint | null | undefined)[]) {
return this.buildAncestorChainMap(parentAgentIds, undefined);
}
async buildScopedAncestorChainMap(
parentAgentIds: (bigint | null | undefined)[],
rootAgentId: bigint,
) {
return this.buildAncestorChainMap(parentAgentIds, rootAgentId);
}
async resolveScope(rootAgentId: bigint): Promise<AgentScope> {
const [profile, closureRows] = await Promise.all([
this.prisma.agentProfile.findUnique({
where: { userId: rootAgentId },
}),
this.findSubtreeClosureRows(rootAgentId),
]);
if (!profile) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
const hasClosureRows = closureRows.length > 0;
const subtreeIds = hasClosureRows
? this.idsFromSubtreeClosure(rootAgentId, closureRows)
: await this.getSubtreeAgentIdsFromProfiles(rootAgentId);
const descendantIds = subtreeIds.filter((id) => id !== rootAgentId);
const directChildIds = hasClosureRows
? uniqueBigints(
closureRows
.filter((row) => row.depth === 1)
.map((row) => row.descendantId),
)
: await this.getDirectChildAgentIds(rootAgentId);
return {
rootAgentId,
rootLevel: profile.level,
subtreeIds,
descendantIds,
directChildIds,
subtreeIdSet: new Set(subtreeIds.map((id) => id.toString())),
};
}
assertAgentInScope(scope: AgentScope, agentId: bigint) {
if (!scope.subtreeIdSet.has(agentId.toString())) {
throw appForbidden('NOT_SUB_AGENT');
}
}
async requirePlayerInPortalSubtree(rootAgentId: bigint, playerId: bigint) {
const scope = await this.resolveScope(rootAgentId);
const player = await this.prisma.user.findFirst({
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
include: { wallet: true, preferences: true, auth: true },
});
if (!player?.parentId || !scope.subtreeIdSet.has(player.parentId.toString())) {
throw appForbidden('MANAGE_DIRECT_PLAYERS_ONLY');
}
return { player, scope, isDirect: player.parentId.toString() === rootAgentId.toString() };
}
async getChildAgents(agentId: bigint) {
return this.prisma.agentProfile.findMany({
where: { parentAgentId: agentId },
include: { user: true },
});
}
async assertDescendantAgent(rootAgentId: bigint, targetAgentId: bigint) {
const subtreeIds = await this.getSubtreeAgentIds(rootAgentId);
if (!subtreeIds.some((id) => id === targetAgentId)) {
throw appForbidden('NOT_SUB_AGENT');
}
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: targetAgentId },
});
if (!profile) throw appNotFound('AGENT_NOT_FOUND');
return profile;
}
async assertDirectChildAgent(parentAgentId: bigint, subAgentId: bigint) {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: subAgentId },
});
if (!profile || profile.parentAgentId !== parentAgentId) {
throw appForbidden('NOT_SUB_AGENT');
}
return profile;
}
async getSubtreeAgentIds(agentId: bigint) {
const closureRows = await this.findSubtreeClosureRows(agentId);
if (closureRows.length > 0) {
return this.idsFromSubtreeClosure(agentId, closureRows);
}
return this.getSubtreeAgentIdsFromProfiles(agentId);
}
private async buildAncestorChainMap(
parentAgentIds: (bigint | null | undefined)[],
scopedRootAgentId: bigint | undefined,
) {
const agentIds = uniqueBigints(parentAgentIds.filter((id): id is bigint => id != null));
const map = new Map<string, string[]>();
if (agentIds.length === 0) return map;
const closureRows = await this.prisma.agentClosure.findMany({
where: { descendantId: { in: agentIds } },
select: { ancestorId: true, descendantId: true, depth: true },
orderBy: [{ descendantId: 'asc' }, { depth: 'desc' }],
});
const rowsByDescendant = new Map<string, ClosureRow[]>();
for (const row of closureRows) {
const key = row.descendantId.toString();
rowsByDescendant.set(key, [...(rowsByDescendant.get(key) ?? []), row]);
}
const idsNeedingProfileFallback: bigint[] = [];
const ancestorIds = new Set<bigint>();
for (const id of agentIds) {
const rows = rowsByDescendant.get(id.toString());
if (!rows || rows.length === 0) {
idsNeedingProfileFallback.push(id);
continue;
}
const scopedRows = scopedRootAgentId
? this.sliceRowsToScopedRoot(rows, scopedRootAgentId)
: rows;
for (const row of scopedRows) {
if (row.ancestorId) ancestorIds.add(row.ancestorId);
}
}
const users =
ancestorIds.size > 0
? await this.prisma.user.findMany({
where: { id: { in: [...ancestorIds] } },
select: { id: true, username: true },
})
: [];
const usernameMap = new Map(users.map((u) => [u.id.toString(), u.username]));
for (const id of agentIds) {
const rows = rowsByDescendant.get(id.toString());
if (!rows || rows.length === 0) continue;
const scopedRows = scopedRootAgentId
? this.sliceRowsToScopedRoot(rows, scopedRootAgentId)
: rows;
const chain = scopedRows
.map((row) => (row.ancestorId ? usernameMap.get(row.ancestorId.toString()) : undefined))
.filter((username): username is string => Boolean(username));
map.set(id.toString(), chain.length === scopedRows.length ? chain : []);
}
if (idsNeedingProfileFallback.length > 0) {
const fallbackMap = scopedRootAgentId
? await this.buildScopedAncestorChainMapFromProfiles(idsNeedingProfileFallback, scopedRootAgentId)
: await this.buildAncestorChainMapFromProfiles(idsNeedingProfileFallback);
for (const [key, value] of fallbackMap) {
map.set(key, value);
}
}
for (const id of parentAgentIds) {
if (id && !map.has(id.toString())) {
map.set(id.toString(), []);
}
}
return map;
}
private sliceRowsToScopedRoot(rows: ClosureRow[], rootAgentId: bigint) {
const rootRow = rows.find((row) => row.ancestorId === rootAgentId);
if (!rootRow) return [];
return rows.filter((row) => row.depth <= rootRow.depth);
}
private async buildAncestorChainMapFromProfiles(agentIds: bigint[]) {
const cache = await this.loadAncestorProfileCache(agentIds, undefined);
const build = (startId: bigint | null | undefined): string[] => {
const chain: string[] = [];
let cur = startId ?? null;
while (cur) {
const hit = cache.get(cur.toString());
if (!hit) break;
chain.unshift(hit.username);
cur = hit.parentAgentId;
}
return chain;
};
return new Map(agentIds.map((id) => [id.toString(), build(id)]));
}
private async buildScopedAncestorChainMapFromProfiles(agentIds: bigint[], rootAgentId: bigint) {
const rootKey = rootAgentId.toString();
const cache = await this.loadAncestorProfileCache(agentIds, rootAgentId);
const build = (startId: bigint | null | undefined): string[] => {
if (!startId) return [];
const chain: string[] = [];
let cur: bigint | null = startId;
let reachedRoot = false;
while (cur) {
const hit = cache.get(cur.toString());
if (!hit) return [];
chain.unshift(hit.username);
if (cur.toString() === rootKey) {
reachedRoot = true;
break;
}
cur = hit.parentAgentId;
}
return reachedRoot ? chain : [];
};
return new Map(agentIds.map((id) => [id.toString(), build(id)]));
}
private async loadAncestorProfileCache(agentIds: bigint[], stopBeforeParentOf?: bigint) {
const cache = new Map<string, { username: string; parentAgentId: bigint | null }>();
const pending = new Set(agentIds);
if (stopBeforeParentOf) pending.add(stopBeforeParentOf);
const stopKey = stopBeforeParentOf?.toString();
while (pending.size > 0) {
const batch = [...pending];
pending.clear();
const profiles = await this.prisma.agentProfile.findMany({
where: { userId: { in: batch } },
select: {
userId: true,
parentAgentId: true,
user: { select: { username: true } },
},
});
for (const profile of profiles) {
cache.set(profile.userId.toString(), {
username: profile.user.username,
parentAgentId: profile.parentAgentId,
});
if (
profile.parentAgentId &&
profile.parentAgentId.toString() !== stopKey &&
!cache.has(profile.parentAgentId.toString())
) {
pending.add(profile.parentAgentId);
}
}
}
return cache;
}
private async findSubtreeClosureRows(agentId: bigint) {
return this.prisma.agentClosure.findMany({
where: { ancestorId: agentId },
select: { descendantId: true, depth: true },
orderBy: [{ depth: 'asc' }, { descendantId: 'asc' }],
});
}
private idsFromSubtreeClosure(agentId: bigint, rows: ClosureRow[]) {
return uniqueBigints([agentId, ...rows.map((row) => row.descendantId)]);
}
private async getDirectChildAgentIds(agentId: bigint) {
const children = await this.prisma.agentProfile.findMany({
where: { parentAgentId: agentId },
select: { userId: true },
orderBy: { createdAt: 'desc' },
});
return children.map((child) => child.userId);
}
private async getSubtreeAgentIdsFromProfiles(agentId: bigint) {
const ids: bigint[] = [];
const queue: bigint[] = [agentId];
const seen = new Set<string>();
while (queue.length > 0) {
const current = queue.shift()!;
const key = current.toString();
if (seen.has(key)) continue;
seen.add(key);
ids.push(current);
const children = await this.prisma.agentProfile.findMany({
where: { parentAgentId: current },
select: { userId: true },
});
for (const child of children) {
queue.push(child.userId);
}
}
return ids;
}
}

View File

@@ -1,12 +1,14 @@
import { Module } from '@nestjs/common';
import { AgentsService } from './agents.service';
import { AgentNetworkService } from './agent-network.service';
import { AgentCreditService } from './agent-credit.service';
import { WalletModule } from '../ledger/wallet.module';
import { AuthModule } from '../identity/auth.module';
import { SystemConfigModule } from '../../shared/config/system-config.module';
@Module({
imports: [WalletModule, AuthModule, SystemConfigModule],
providers: [AgentsService],
exports: [AgentsService],
providers: [AgentsService, AgentNetworkService, AgentCreditService],
exports: [AgentsService, AgentNetworkService, AgentCreditService],
})
export class AgentsModule {}

View File

@@ -0,0 +1,100 @@
import { Decimal } from '@prisma/client/runtime/library';
import { AgentsService } from './agents.service';
import { createPrismaMock } from '../../testing/prisma-mock';
describe('AgentsService', () => {
const parentAgentId = 1n;
const createdAgentId = 2n;
const tx = {
user: {
create: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
userAuth: {
create: jest.fn(),
},
userPreference: {
create: jest.fn(),
},
userInvite: {
create: jest.fn(),
findUnique: jest.fn(),
},
agentProfile: {
create: jest.fn(),
findUnique: jest.fn(),
},
agentClosure: {
create: jest.fn(),
findMany: jest.fn(),
},
};
const prisma = createPrismaMock(tx);
const auth = {
hashPassword: jest.fn(),
};
const systemConfig = {
getAgentHierarchySettings: jest.fn(),
};
const network = {};
const credit = {
recalculateUsedCredit: jest.fn(),
};
let service: AgentsService;
beforeEach(() => {
jest.clearAllMocks();
service = new AgentsService(
prisma as never,
auth as never,
systemConfig as never,
network as never,
credit as never,
);
systemConfig.getAgentHierarchySettings.mockResolvedValue({ maxAgentLevel: 3 });
auth.hashPassword.mockResolvedValue('hashed-password');
tx.agentProfile.findUnique.mockResolvedValue({
userId: parentAgentId,
level: 1,
creditLimit: new Decimal(1000),
usedCredit: new Decimal(0),
cashbackRate: new Decimal(10),
maxSingleDeposit: null,
maxDailyDeposit: null,
});
tx.user.create.mockResolvedValue({
id: createdAgentId,
username: 'agent-child',
userType: 'AGENT',
});
tx.user.findUnique.mockResolvedValue(null);
tx.user.update.mockResolvedValue({});
tx.userInvite.findUnique.mockResolvedValue(null);
tx.userInvite.create.mockResolvedValue({});
tx.userAuth.create.mockResolvedValue({});
tx.userPreference.create.mockResolvedValue({});
tx.agentProfile.create.mockResolvedValue({});
tx.agentClosure.create.mockResolvedValue({});
tx.agentClosure.findMany.mockResolvedValue([
{ ancestorId: parentAgentId, depth: 0 },
]);
});
it('recalculates parent credit exposure after creating a child agent', async () => {
await service.createAgent(99n, {
username: 'agent-child',
password: 'secret',
level: 2,
parentAgentId,
creditLimit: 300,
});
expect(credit.recalculateUsedCredit).toHaveBeenCalledWith(parentAgentId);
expect(credit.recalculateUsedCredit).not.toHaveBeenCalledWith(createdAgentId);
});
});

View File

@@ -2,7 +2,6 @@ import { Injectable, BadRequestException } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { WalletService } from '../ledger/wallet.service';
import { AuthService } from '../identity/auth.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import { Decimal } from '@prisma/client/runtime/library';
@@ -10,6 +9,8 @@ import { generateBatchNo } from '../../shared/common/decorators';
import { assertPlayerUsername, validateInitialDepositRemark } from '@thebet365/shared';
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
import { assignInviteCodeWithHistory } from '../../shared/common/invite-code.util';
import { AgentNetworkService, type AgentScope } from './agent-network.service';
import { AgentCreditService } from './agent-credit.service';
function dec(v: Decimal | null | undefined) {
return v?.toString() ?? '0';
@@ -23,9 +24,10 @@ function sub(a: Decimal | null | undefined, b: Decimal | null | undefined) {
export class AgentsService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
private auth: AuthService,
private systemConfig: SystemConfigService,
private network: AgentNetworkService,
private credit: AgentCreditService,
) {}
async getMaxAgentLevel(): Promise<number> {
@@ -39,52 +41,7 @@ export class AgentsService {
}
private async buildAgentAncestorChainMap(parentAgentIds: (bigint | null | undefined)[]) {
const cache = new Map<string, { username: string; parentAgentId: bigint | null }>();
const pending = new Set<bigint>();
for (const id of parentAgentIds) {
if (id) pending.add(id);
}
while (pending.size > 0) {
const batch = [...pending];
pending.clear();
const profiles = await this.prisma.agentProfile.findMany({
where: { userId: { in: batch } },
select: {
userId: true,
parentAgentId: true,
user: { select: { username: true } },
},
});
for (const profile of profiles) {
cache.set(profile.userId.toString(), {
username: profile.user.username,
parentAgentId: profile.parentAgentId,
});
if (profile.parentAgentId && !cache.has(profile.parentAgentId.toString())) {
pending.add(profile.parentAgentId);
}
}
}
const build = (startId: bigint | null | undefined): string[] => {
const chain: string[] = [];
let cur = startId ?? null;
while (cur) {
const hit = cache.get(cur.toString());
if (!hit) break;
chain.unshift(hit.username);
cur = hit.parentAgentId;
}
return chain;
};
const map = new Map<string, string[]>();
for (const id of parentAgentIds) {
if (id) map.set(id.toString(), build(id));
}
return map;
return this.network.buildAgentAncestorChainMap(parentAgentIds);
}
private async agentCashbackRateMap(agentUserIds: bigint[]): Promise<Map<string, string>> {
@@ -142,100 +99,23 @@ export class AgentsService {
parentAgentIds: (bigint | null | undefined)[],
rootAgentId: bigint,
) {
const cache = new Map<string, { username: string; parentAgentId: bigint | null }>();
const pending = new Set<bigint>();
const rootKey = rootAgentId.toString();
for (const id of parentAgentIds) {
if (id) pending.add(id);
}
pending.add(rootAgentId);
while (pending.size > 0) {
const batch = [...pending];
pending.clear();
const profiles = await this.prisma.agentProfile.findMany({
where: { userId: { in: batch } },
select: {
userId: true,
parentAgentId: true,
user: { select: { username: true } },
},
});
for (const profile of profiles) {
cache.set(profile.userId.toString(), {
username: profile.user.username,
parentAgentId: profile.parentAgentId,
});
if (
profile.parentAgentId &&
profile.parentAgentId.toString() !== rootKey &&
!cache.has(profile.parentAgentId.toString())
) {
pending.add(profile.parentAgentId);
}
}
}
const build = (startId: bigint | null | undefined): string[] => {
if (!startId) return [];
const chain: string[] = [];
let cur: bigint | null = startId;
let reachedRoot = false;
while (cur) {
const hit = cache.get(cur.toString());
if (!hit) return [];
chain.unshift(hit.username);
if (cur.toString() === rootKey) {
reachedRoot = true;
break;
}
cur = hit.parentAgentId;
}
return reachedRoot ? chain : [];
};
const map = new Map<string, string[]>();
for (const id of parentAgentIds) {
if (id) map.set(id.toString(), build(id));
}
return map;
return this.network.buildScopedAncestorChainMap(parentAgentIds, rootAgentId);
}
private async getAgentPortalScope(rootAgentId: bigint) {
const profile = await this.getProfile(rootAgentId);
const subtreeIds = await this.getSubtreeAgentIds(rootAgentId);
const descendantIds = subtreeIds.filter((id) => id !== rootAgentId);
const subtreeIdSet = new Set(subtreeIds.map((id) => id.toString()));
return {
rootAgentId,
rootLevel: profile.level,
subtreeIds,
descendantIds,
subtreeIdSet,
};
private async getAgentPortalScope(rootAgentId: bigint): Promise<AgentScope> {
return this.network.resolveScope(rootAgentId);
}
private assertAgentInPortalSubtree(
scope: { subtreeIdSet: Set<string> },
scope: AgentScope,
agentId: bigint,
) {
if (!scope.subtreeIdSet.has(agentId.toString())) {
throw appForbidden('NOT_SUB_AGENT');
}
this.network.assertAgentInScope(scope, agentId);
}
/** 代理端:玩家必须挂在当前代理子树内某代理下(自己或下级) */
async requirePlayerInPortalSubtree(rootAgentId: bigint, playerId: bigint) {
const scope = await this.getAgentPortalScope(rootAgentId);
const player = await this.prisma.user.findFirst({
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
include: { wallet: true, preferences: true, auth: true },
});
if (!player?.parentId || !scope.subtreeIdSet.has(player.parentId.toString())) {
throw appForbidden('MANAGE_DIRECT_PLAYERS_ONLY');
}
return { player, scope, isDirect: player.parentId.toString() === rootAgentId.toString() };
return this.network.requirePlayerInPortalSubtree(rootAgentId, playerId);
}
private async validateAgentLevel(level: number, parentAgentId?: bigint) {
@@ -270,51 +150,11 @@ export class AgentsService {
}
async getProfile(agentId: bigint) {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: agentId },
});
if (!profile) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
return { ...profile, availableCredit: available };
return this.credit.getProfile(agentId);
}
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;
return this.credit.recalculateUsedCredit(agentId);
}
async adjustCredit(
@@ -324,45 +164,7 @@ export class AgentsService {
requestId: string,
remark?: string,
) {
const amt = new Decimal(amount);
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: agentId },
});
if (!profile) throw appBadRequest('AGENT_NOT_FOUND');
const creditBefore = profile.creditLimit;
const creditAfter = creditBefore.add(amt);
if (creditAfter.lt(0)) throw appBadRequest('CREDIT_LIMIT_NEGATIVE');
if (profile.parentAgentId) {
await this.assertChildCreditWithinParent(profile.parentAgentId, profile, creditAfter);
}
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 };
return this.credit.adjustCredit(agentId, amount, operatorId, requestId, remark);
}
/** 代理只能操作直属玩家parentId === 当前代理) */
@@ -462,19 +264,7 @@ export class AgentsService {
/** 玩家有所属代理时,上分金额不得超过该代理当前可用授信(会先重算 usedCredit */
async assertPlayerParentCreditForDeposit(playerId: bigint, amount: Decimal | number) {
const user = await this.prisma.user.findFirst({
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
select: { parentId: true },
});
if (!user?.parentId) return;
await this.recalculateUsedCredit(user.parentId);
const profile = await this.getProfile(user.parentId);
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
const amt = new Decimal(amount);
if (available.lt(amt)) {
throw appBadRequest('CREDIT_TOPUP_EXCEEDED');
}
return this.credit.assertPlayerParentCreditForDeposit(playerId, amount);
}
/** 管理员给玩家上分:校验上级授信后入账,并刷新代理占用额度 */
@@ -485,23 +275,7 @@ export class AgentsService {
remark?: string,
requestId?: string,
) {
await this.assertPlayerParentCreditForDeposit(playerId, amount);
const result = await this.wallet.deposit(
playerId,
amount,
operatorId,
remark ?? '管理员上分',
requestId,
'ADMIN_DEPOSIT',
);
const player = await this.prisma.user.findUnique({
where: { id: playerId },
select: { parentId: true },
});
if (player?.parentId) {
await this.recalculateUsedCredit(player.parentId);
}
return result;
return this.credit.adminDepositToPlayer(playerId, amount, operatorId, remark, requestId);
}
/** 管理员给玩家下分:扣款后刷新上级代理占用额度 */
@@ -512,22 +286,7 @@ export class AgentsService {
remark?: string,
requestId?: string,
) {
const result = await this.wallet.withdraw(
playerId,
amount,
operatorId,
remark ?? '管理员下分',
requestId,
'ADMIN_WITHDRAW',
);
const player = await this.prisma.user.findUnique({
where: { id: playerId },
select: { parentId: true },
});
if (player?.parentId) {
await this.recalculateUsedCredit(player.parentId);
}
return result;
return this.credit.adminWithdrawFromPlayer(playerId, amount, operatorId, remark, requestId);
}
/** 上下分弹窗:玩家余额 + 授信代理可用额度/限额上下文 */
@@ -535,70 +294,7 @@ export class AgentsService {
playerId: bigint,
options: { forAdmin?: boolean; actingAgentId?: bigint } = {},
) {
const player = await this.prisma.user.findFirst({
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
include: { wallet: true },
});
if (!player) throw appNotFound('PLAYER_NOT_FOUND');
if (options.actingAgentId) {
await this.requireDirectPlayer(options.actingAgentId, playerId);
}
const creditAgentId = options.forAdmin ? player.parentId : (options.actingAgentId ?? null);
let credit: Record<string, unknown> | null = null;
if (creditAgentId) {
await this.recalculateUsedCredit(creditAgentId);
const profile = await this.getProfile(creditAgentId);
const parent = profile.parentAgentId
? await this.prisma.agentProfile.findUnique({ where: { userId: profile.parentAgentId } })
: null;
const { maxSingleDeposit, maxDailyDeposit } = this.resolveEffectiveDepositLimits(profile, parent);
let dailyDepositUsed: string | null = null;
if (!options.forAdmin) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const dailyAgg = await this.prisma.walletTransaction.aggregate({
where: {
operatorId: creditAgentId,
transactionType: 'MANUAL_DEPOSIT',
createdAt: { gte: today },
},
_sum: { amount: true },
});
dailyDepositUsed = dec(dailyAgg._sum.amount);
}
const agentUser = await this.prisma.user.findUnique({
where: { id: creditAgentId },
select: { username: true },
});
credit = {
agentId: creditAgentId.toString(),
agentUsername: agentUser?.username ?? '',
agentLevel: profile.level,
creditLimit: dec(profile.creditLimit),
usedCredit: dec(profile.usedCredit),
availableCredit: dec(profile.availableCredit),
maxSingleDeposit: maxSingleDeposit?.toString() ?? null,
maxDailyDeposit: maxDailyDeposit?.toString() ?? null,
dailyDepositUsed,
appliesDepositLimits: !options.forAdmin,
};
}
return {
player: {
id: player.id.toString(),
username: player.username,
availableBalance: dec(player.wallet?.availableBalance),
frozenBalance: dec(player.wallet?.frozenBalance),
},
credit,
};
return this.credit.getPlayerTransferContext(playerId, options);
}
private async assertAgentDepositLimits(creditAgentId: bigint, amount: Decimal) {
@@ -624,9 +320,9 @@ export class AgentsService {
const dailyAgg = await this.prisma.walletTransaction.aggregate({
where: {
operatorId: creditAgentId,
transactionType: 'MANUAL_DEPOSIT',
createdAt: { gte: today },
},
transactionType: { in: ['AGENT_DEPOSIT', 'MANUAL_DEPOSIT'] },
createdAt: { gte: today },
},
_sum: { amount: true },
});
const dailyTotal = new Decimal(dailyAgg._sum.amount ?? 0).add(amount);
@@ -660,22 +356,7 @@ export class AgentsService {
requestId: string,
remark?: string,
) {
await this.requireDirectPlayer(agentId, playerId);
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 appBadRequest('INSUFFICIENT_AGENT_CREDIT');
}
await this.assertAgentDepositLimits(agentId, amt);
await this.wallet.deposit(playerId, amt, agentId, remark ?? '代理上分', requestId, 'AGENT_DEPOSIT');
await this.recalculateUsedCredit(agentId);
return { success: true };
return this.credit.depositToPlayer(agentId, playerId, amount, requestId, remark);
}
async withdrawFromPlayer(
@@ -685,19 +366,7 @@ export class AgentsService {
requestId: string,
remark?: string,
) {
await this.requireDirectPlayer(agentId, playerId);
await this.wallet.withdraw(
playerId,
amount,
agentId,
remark ?? '代理下分',
requestId,
'AGENT_WITHDRAW',
);
await this.recalculateUsedCredit(agentId);
return { success: true };
return this.credit.withdrawFromPlayer(agentId, playerId, amount, requestId, remark);
}
async getDirectPlayerDetail(agentId: bigint, playerId: bigint) {
@@ -1508,7 +1177,7 @@ export class AgentsService {
const hash = await this.auth.hashPassword(data.password);
return this.prisma.$transaction(async (tx) => {
const user = await this.prisma.$transaction(async (tx) => {
const locale = data.locale ?? 'zh-CN';
const user = await tx.user.create({
data: {
@@ -1564,13 +1233,16 @@ export class AgentsService {
},
});
}
if (data.parentAgentId) {
await this.recalculateUsedCredit(data.parentAgentId);
}
}
return user;
});
if (data.parentAgentId) {
await this.recalculateUsedCredit(data.parentAgentId);
}
return user;
}
async createPlayer(
@@ -1707,18 +1379,7 @@ export class AgentsService {
if (!remarkResult.ok) throw appBadRequest(remarkResult.code);
const requestId =
data.depositRequestId ?? `admin-create-${user.id}-${Date.now()}`;
await this.assertPlayerParentCreditForDeposit(user.id, initial);
await this.wallet.deposit(
user.id,
initial,
operatorId,
remarkResult.remark,
requestId,
'ADMIN_DEPOSIT',
);
if (parentId) {
await this.recalculateUsedCredit(parentId);
}
await this.credit.adminDepositToPlayer(user.id, initial, operatorId, remarkResult.remark, requestId);
}
return user;
@@ -1799,10 +1460,7 @@ export class AgentsService {
}
async getChildAgents(agentId: bigint) {
return this.prisma.agentProfile.findMany({
where: { parentAgentId: agentId },
include: { user: true },
});
return this.network.getChildAgents(agentId);
}
async listChildAgentsSummary(parentAgentId: bigint) {
@@ -1980,15 +1638,7 @@ export class AgentsService {
}
async assertDescendantAgent(rootAgentId: bigint, targetAgentId: bigint) {
const subtreeIds = await this.getSubtreeAgentIds(rootAgentId);
if (!subtreeIds.some((id) => id === targetAgentId)) {
throw appForbidden('NOT_SUB_AGENT');
}
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: targetAgentId },
});
if (!profile) throw appNotFound('AGENT_NOT_FOUND');
return profile;
return this.network.assertDescendantAgent(rootAgentId, targetAgentId);
}
async countSubtreeAgentsByLevel(rootAgentId: bigint) {
@@ -2242,13 +1892,7 @@ export class AgentsService {
}
async assertDirectChildAgent(parentAgentId: bigint, subAgentId: bigint) {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: subAgentId },
});
if (!profile || profile.parentAgentId !== parentAgentId) {
throw appForbidden('NOT_SUB_AGENT');
}
return profile;
return this.network.assertDirectChildAgent(parentAgentId, subAgentId);
}
async getSubAgentForParent(parentAgentId: bigint, subAgentId: bigint) {
@@ -2275,27 +1919,7 @@ export class AgentsService {
}
async getSubtreeAgentIds(agentId: bigint) {
const ids: bigint[] = [];
const queue: bigint[] = [agentId];
const seen = new Set<string>();
while (queue.length > 0) {
const current = queue.shift()!;
const key = current.toString();
if (seen.has(key)) continue;
seen.add(key);
ids.push(current);
const children = await this.prisma.agentProfile.findMany({
where: { parentAgentId: current },
select: { userId: true },
});
for (const child of children) {
queue.push(child.userId);
}
}
return ids;
return this.network.getSubtreeAgentIds(agentId);
}
async getReportSummary(agentId: bigint) {

View File

@@ -0,0 +1,153 @@
import { Decimal } from '@prisma/client/runtime/library';
import { BetsService } from './bets.service';
import { expectAppError } from '../../testing/prisma-mock';
describe('BetsService', () => {
const tx = {
bet: {
findUnique: jest.fn(),
create: jest.fn(),
},
marketSelection: {
findUnique: jest.fn(),
},
};
const prisma = {
...tx,
$transaction: jest.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
};
const funds = {
freezeBet: jest.fn(),
};
const bettingLimits = {
validateBet: jest.fn(),
};
let service: BetsService;
function selection(id: bigint, oddsVersion: bigint, matchId = 88n) {
return {
id,
marketId: id + 100n,
selectionCode: id === 1n ? 'HOME' : 'AWAY',
selectionName: `Selection ${id}`,
nameI18n: null,
odds: new Decimal(id === 1n ? 1.9 : 2.1),
oddsVersion,
status: 'OPEN',
market: {
matchId,
marketType: 'FT_1X2',
period: 'FT',
nameI18n: null,
lineValue: null,
allowSingle: true,
allowParlay: true,
showOnPlayer: true,
status: 'OPEN',
match: {
status: 'PUBLISHED',
sportType: 'FOOTBALL',
isOutright: false,
startTime: new Date(Date.now() + 60 * 60 * 1000),
},
},
};
}
beforeEach(() => {
jest.clearAllMocks();
service = new BetsService(prisma as never, funds as never, bettingLimits as never);
tx.bet.findUnique.mockResolvedValue(null);
tx.bet.create.mockResolvedValue({ id: 123n, betNo: 'BET-OK', selections: [] });
bettingLimits.validateBet.mockResolvedValue(undefined);
tx.marketSelection.findUnique.mockImplementation(({ where }: { where: { id: bigint } }) =>
Promise.resolve(selection(where.id, where.id)),
);
});
it('rejects a parlay with multiple legs from the same match', async () => {
await expect(
service.placeParlayBet(
7n,
3n,
[
{ selectionId: 1n, oddsVersion: 1n },
{ selectionId: 2n, oddsVersion: 2n },
],
50,
'REQ-SAME-MATCH',
),
).rejects.toMatchObject(expectAppError('PARLAY_SAME_MATCH_FORBIDDEN'));
expect(tx.bet.create).not.toHaveBeenCalled();
expect(bettingLimits.validateBet).not.toHaveBeenCalled();
expect(funds.freezeBet).not.toHaveBeenCalled();
});
it('allows a parlay only when every leg is from a different match', async () => {
tx.marketSelection.findUnique.mockImplementation(({ where }: { where: { id: bigint } }) =>
Promise.resolve(selection(where.id, where.id, where.id === 1n ? 88n : 99n)),
);
await service.placeParlayBet(
7n,
3n,
[
{ selectionId: 1n, oddsVersion: 1n },
{ selectionId: 2n, oddsVersion: 2n },
],
50,
'REQ-SAME-MATCH',
);
expect(tx.bet.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
betType: 'PARLAY',
selections: expect.objectContaining({
create: expect.arrayContaining([
expect.objectContaining({ matchId: 88n, selectionId: 1n }),
expect.objectContaining({ matchId: 99n, selectionId: 2n }),
]),
}),
}),
}),
);
expect(bettingLimits.validateBet).toHaveBeenCalledWith(
expect.objectContaining({ betType: 'PARLAY', tx }),
);
expect(funds.freezeBet).toHaveBeenCalledWith(
expect.objectContaining({ userId: 7n, stake: expect.any(Decimal), tx }),
);
});
it('rejects a single bet when the market disables single betting', async () => {
tx.marketSelection.findUnique.mockResolvedValue({
...selection(1n, 1n),
market: {
...selection(1n, 1n).market,
allowSingle: false,
},
});
await expect(
service.placeSingleBet(7n, 3n, 1n, 1n, 50, 'REQ-SINGLE-DISABLED'),
).rejects.toMatchObject(expectAppError('SINGLE_MARKET_NOT_ALLOWED'));
expect(tx.bet.create).not.toHaveBeenCalled();
expect(bettingLimits.validateBet).not.toHaveBeenCalled();
expect(funds.freezeBet).not.toHaveBeenCalled();
});
it('revalidates odds version inside the placement transaction', async () => {
tx.marketSelection.findUnique.mockResolvedValue(selection(1n, 2n));
await expect(
service.placeSingleBet(7n, 3n, 1n, 1n, 50, 'REQ-ODDS-CHANGED'),
).rejects.toMatchObject(expectAppError('ODDS_CHANGED'));
expect(tx.bet.create).not.toHaveBeenCalled();
expect(funds.freezeBet).not.toHaveBeenCalled();
});
});

View File

@@ -1,7 +1,7 @@
import { Injectable, ConflictException, BadRequestException } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { WalletService } from '../ledger/wallet.service';
import { FundsPostingService } from '../ledger/funds-posting.service';
import { BettingLimitsService } from './betting-limits.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateBetNo } from '../../shared/common/decorators';
@@ -10,12 +10,17 @@ import {
PARLAY_MIN_LEGS,
PARLAY_MAX_LEGS,
canSelectForParlay,
hasDuplicateParlayMatch,
defaultMarketName,
defaultSelectionName,
isPreMatchKickoff,
isSupportedSport,
resolveMarketText,
resolveTranslationFallback,
} from '@thebet365/shared';
type TxClient = Prisma.TransactionClient;
type PrismaClientLike = PrismaService | TxClient;
type MatchContext = {
matchLabel: string;
leagueName: string;
@@ -25,6 +30,7 @@ type BetSelectionRow = {
matchId: bigint | null;
marketType: string;
period: string | null;
marketNameSnapshot: string | null;
selectionNameSnapshot: string;
handicapLine: Decimal | null;
totalLine: Decimal | null;
@@ -32,6 +38,16 @@ type BetSelectionRow = {
sortOrder?: number;
};
type SelectionWithMarketLabel = {
selectionCode: string;
selectionName: string;
nameI18n: unknown;
market: {
marketType: string;
nameI18n: unknown;
};
};
interface BetSelectionInput {
selectionId: bigint;
oddsVersion: bigint;
@@ -42,16 +58,17 @@ interface BetSelectionInput {
export class BetsService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
private funds: FundsPostingService,
private bettingLimits: BettingLimitsService,
) {}
private async validateSelection(
selectionId: bigint,
oddsVersion: bigint,
options?: { forParlay?: boolean },
options?: { forParlay?: boolean; tx?: TxClient },
) {
const selection = await this.prisma.marketSelection.findUnique({
const client: PrismaClientLike = options?.tx ?? this.prisma;
const selection = await client.marketSelection.findUnique({
where: { id: selectionId },
include: { market: { include: { match: true } } },
});
@@ -59,6 +76,10 @@ export class BetsService {
if (!selection) throw appBadRequest('SELECTION_NOT_FOUND');
if (selection.status !== 'OPEN') throw appBadRequest('SELECTION_CLOSED');
if (selection.market.status !== 'OPEN') throw appBadRequest('MARKET_CLOSED');
if (selection.market.showOnPlayer === false) throw appBadRequest('MARKET_CLOSED');
if (!options?.forParlay && selection.market.allowSingle === false) {
throw appBadRequest('SINGLE_MARKET_NOT_ALLOWED');
}
if (selection.market.match.status !== 'PUBLISHED') {
throw appBadRequest('MATCH_NOT_BETTING');
}
@@ -68,14 +89,6 @@ export class BetsService {
if (!selection.market.match.isOutright && !isPreMatchKickoff(selection.market.match.startTime)) {
throw appBadRequest('PRE_MATCH_ONLY');
}
// Block correct-score bets when the match has the CS toggle turned off
const CS_MARKET_TYPES = ['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE'];
if (
CS_MARKET_TYPES.includes(selection.market.marketType) &&
!(selection.market.match.correctScoreEnabled ?? true)
) {
throw appBadRequest('CORRECT_SCORE_DISABLED');
}
if (selection.oddsVersion !== oddsVersion) {
throw appBadRequest('ODDS_CHANGED');
}
@@ -112,57 +125,68 @@ export class BetsService {
) {
if (stake <= 0) throw appBadRequest('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, { forParlay: false });
const odds = new Decimal(selection.odds.toString());
const stakeDec = new Decimal(stake);
const potentialReturn = stakeDec.mul(odds);
await this.bettingLimits.validateBet({
userId,
betType: 'SINGLE',
stake,
potentialReturn,
});
const betNo = generateBetNo();
const bet = await this.prisma.$transaction(async (tx) => {
const created = await tx.bet.create({
data: {
betNo,
try {
return await this.prisma.$transaction(async (tx) => {
const existing = await tx.bet.findUnique({
where: { userId_requestId: { userId, requestId } },
});
if (existing) return existing;
const selection = await this.validateSelection(selectionId, oddsVersion, {
forParlay: false,
tx,
});
const locale = await this.resolveUserLocale(userId, tx);
const odds = new Decimal(selection.odds.toString());
const potentialReturn = stakeDec.mul(odds);
await this.bettingLimits.validateBet({
userId,
agentId,
betType: 'SINGLE',
stake: stakeDec,
totalOdds: odds,
stake,
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,
tx,
});
const betNo = generateBetNo();
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,
marketNameSnapshot: this.marketNameSnapshot(selection, locale),
selectionNameSnapshot: this.selectionNameSnapshot(selection, locale),
handicapLine: selection.market.lineValue,
totalLine: selection.market.lineValue,
odds,
oddsVersion,
},
},
},
},
include: { selections: true },
include: { selections: true },
});
await this.funds.freezeBet({ userId, stake: stakeDec, betNo, tx });
return created;
});
await this.wallet.freezeForBet(userId, stakeDec, betNo);
return created;
});
return bet;
} catch (error) {
const existing = await this.findExistingRequestBetOnUniqueError(userId, requestId, error);
if (existing) return existing;
throw error;
}
}
async placeParlayBet(
@@ -177,73 +201,100 @@ export class BetsService {
throw appBadRequest('PARLAY_LEG_COUNT_INVALID', { min: PARLAY_MIN_LEGS, max: PARLAY_MAX_LEGS });
}
const existing = await this.prisma.bet.findUnique({
where: { userId_requestId: { userId, requestId } },
});
if (existing) return existing;
const selections: Awaited<ReturnType<typeof this.validateSelection>>[] = [];
for (const leg of legs) {
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion, { forParlay: true });
selections.push(sel);
}
const matchIds = selections.map((s) => s.market.matchId);
if (hasDuplicateParlayMatch(matchIds)) {
throw appBadRequest('PARLAY_SAME_MATCH_FORBIDDEN');
}
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);
await this.bettingLimits.validateBet({
userId,
betType: 'PARLAY',
stake,
potentialReturn,
});
const betNo = generateBetNo();
const bet = await this.prisma.$transaction(async (tx) => {
const created = await tx.bet.create({
data: {
betNo,
try {
return await this.prisma.$transaction(async (tx) => {
const existing = await tx.bet.findUnique({
where: { userId_requestId: { userId, requestId } },
});
if (existing) return existing;
const selections: Awaited<ReturnType<typeof this.validateSelection>>[] = [];
for (const leg of legs) {
const sel = await this.validateSelection(leg.selectionId, leg.oddsVersion, {
forParlay: true,
tx,
});
selections.push(sel);
}
const matchIds = new Set<string>();
for (const sel of selections) {
const matchKey = sel.market.matchId.toString();
if (matchIds.has(matchKey)) {
throw appBadRequest('PARLAY_SAME_MATCH_FORBIDDEN');
}
matchIds.add(matchKey);
}
const locale = await this.resolveUserLocale(userId, tx);
let totalOdds = new Decimal(1);
for (const sel of selections) {
totalOdds = totalOdds.mul(sel.odds.toString());
}
const potentialReturn = stakeDec.mul(totalOdds);
await this.bettingLimits.validateBet({
userId,
agentId,
betType: 'PARLAY',
stake: stakeDec,
totalOdds,
stake,
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,
})),
tx,
});
const betNo = generateBetNo();
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,
marketNameSnapshot: this.marketNameSnapshot(sel, locale),
selectionNameSnapshot: this.selectionNameSnapshot(sel, locale),
handicapLine: sel.market.lineValue,
totalLine: sel.market.lineValue,
odds: sel.odds,
oddsVersion: legs[i].oddsVersion,
sortOrder: i,
})),
},
},
},
include: { selections: true },
include: { selections: true },
});
await this.funds.freezeBet({ userId, stake: stakeDec, betNo, tx });
return created;
});
} catch (error) {
const existing = await this.findExistingRequestBetOnUniqueError(userId, requestId, error);
if (existing) return existing;
throw error;
}
}
await this.wallet.freezeForBet(userId, stakeDec, betNo);
return created;
});
return bet;
private async findExistingRequestBetOnUniqueError(
userId: bigint,
requestId: string,
error: unknown,
) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
return this.prisma.bet.findUnique({ where: { userId_requestId: { userId, requestId } } });
}
return null;
}
async getUserBets(
@@ -392,6 +443,7 @@ export class BetsService {
matchLabel: ctx?.matchLabel ?? '—',
leagueName: ctx?.leagueName ?? '',
marketType: s.marketType,
marketName: s.marketNameSnapshot ?? null,
period: s.period,
selectionName: s.selectionNameSnapshot,
odds: this.dec(s.odds),
@@ -584,6 +636,7 @@ export class BetsService {
matchLabel: preview?.matchLabel ?? '—',
leagueName: preview?.leagueName ?? '',
marketType: s.marketType,
marketName: s.marketNameSnapshot,
period: s.period,
selectionName: s.selectionNameSnapshot,
handicapLine: s.handicapLine ? this.dec(s.handicapLine) : null,
@@ -596,4 +649,37 @@ export class BetsService {
}),
};
}
private async resolveUserLocale(userId: bigint, client: PrismaClientLike) {
const userDelegate = (client as PrismaClientLike & {
user?: {
findUnique?: PrismaService['user']['findUnique'];
};
}).user;
if (!userDelegate?.findUnique) return 'zh-CN';
const user = await userDelegate.findUnique({
where: { id: userId },
select: { locale: true, preferences: { select: { locale: true } } },
});
return user?.preferences?.locale || user?.locale || 'zh-CN';
}
private marketNameSnapshot(selection: SelectionWithMarketLabel, locale: string) {
const market = selection.market;
return (
resolveMarketText(market.nameI18n, locale, defaultMarketName(market.marketType, locale)) ||
market.marketType
);
}
private selectionNameSnapshot(selection: SelectionWithMarketLabel, locale: string) {
return (
resolveMarketText(
selection.nameI18n,
locale,
defaultSelectionName(selection.market.marketType, selection.selectionCode, locale) ||
selection.selectionName,
) || selection.selectionName
);
}
}

View File

@@ -1,5 +1,6 @@
import { BettingLimitsService } from './betting-limits.service';
import { Decimal } from '@prisma/client/runtime/library';
import { expectAppError } from '../../testing/prisma-mock';
describe('BettingLimitsService', () => {
const prisma = {
@@ -27,7 +28,7 @@ describe('BettingLimitsService', () => {
stake: 0.5,
potentialReturn: new Decimal(1),
}),
).rejects.toThrow('Minimum stake is 1');
).rejects.toMatchObject(expectAppError('MIN_STAKE'));
});
it('rejects stake above single max', async () => {
@@ -38,7 +39,7 @@ describe('BettingLimitsService', () => {
stake: 60000,
potentialReturn: new Decimal(70000),
}),
).rejects.toThrow('Maximum stake is 50000');
).rejects.toMatchObject(expectAppError('MAX_STAKE'));
});
it('rejects potential return above payout cap', async () => {
@@ -49,6 +50,6 @@ describe('BettingLimitsService', () => {
stake: 100,
potentialReturn: new Decimal(600000),
}),
).rejects.toThrow('Potential return exceeds limit');
).rejects.toMatchObject(expectAppError('MAX_PAYOUT'));
});
});

View File

@@ -1,8 +1,12 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Decimal } from '@prisma/client/runtime/library';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { appBadRequest } from '../../shared/common/app-error';
type TxClient = Prisma.TransactionClient;
type PrismaClientLike = PrismaService | TxClient;
export type BettingLimits = {
minStake: number;
maxStakeSingle: number;
@@ -34,8 +38,12 @@ const DEFAULTS: BettingLimits = {
export class BettingLimitsService {
constructor(private prisma: PrismaService) {}
private async getNumber(key: string, fallback: number): Promise<number> {
const row = await this.prisma.systemConfig.findUnique({ where: { configKey: key } });
private async getNumber(
key: string,
fallback: number,
client: PrismaClientLike = this.prisma,
): Promise<number> {
const row = await client.systemConfig.findUnique({ where: { configKey: key } });
if (!row) return fallback;
const n = Number(row.configValue);
return Number.isFinite(n) && n >= 0 ? n : fallback;
@@ -49,14 +57,15 @@ export class BettingLimitsService {
});
}
async getLimits(): Promise<BettingLimits> {
async getLimits(tx?: TxClient): Promise<BettingLimits> {
const client = tx ?? this.prisma;
return {
minStake: await this.getNumber(BET_LIMIT_KEYS.minStake, DEFAULTS.minStake),
maxStakeSingle: await this.getNumber(BET_LIMIT_KEYS.maxStakeSingle, DEFAULTS.maxStakeSingle),
maxStakeParlay: await this.getNumber(BET_LIMIT_KEYS.maxStakeParlay, DEFAULTS.maxStakeParlay),
maxPayoutSingle: await this.getNumber(BET_LIMIT_KEYS.maxPayoutSingle, DEFAULTS.maxPayoutSingle),
maxPayoutParlay: await this.getNumber(BET_LIMIT_KEYS.maxPayoutParlay, DEFAULTS.maxPayoutParlay),
dailyStakeLimit: await this.getNumber(BET_LIMIT_KEYS.dailyStakeLimit, DEFAULTS.dailyStakeLimit),
minStake: await this.getNumber(BET_LIMIT_KEYS.minStake, DEFAULTS.minStake, client),
maxStakeSingle: await this.getNumber(BET_LIMIT_KEYS.maxStakeSingle, DEFAULTS.maxStakeSingle, client),
maxStakeParlay: await this.getNumber(BET_LIMIT_KEYS.maxStakeParlay, DEFAULTS.maxStakeParlay, client),
maxPayoutSingle: await this.getNumber(BET_LIMIT_KEYS.maxPayoutSingle, DEFAULTS.maxPayoutSingle, client),
maxPayoutParlay: await this.getNumber(BET_LIMIT_KEYS.maxPayoutParlay, DEFAULTS.maxPayoutParlay, client),
dailyStakeLimit: await this.getNumber(BET_LIMIT_KEYS.dailyStakeLimit, DEFAULTS.dailyStakeLimit, client),
};
}
@@ -85,8 +94,10 @@ export class BettingLimitsService {
betType: 'SINGLE' | 'PARLAY';
stake: number;
potentialReturn: Decimal;
tx?: TxClient;
}) {
const limits = await this.getLimits();
const client = params.tx ?? this.prisma;
const limits = await this.getLimits(params.tx);
const stake = params.stake;
if (stake < limits.minStake) {
@@ -110,7 +121,7 @@ export class BettingLimitsService {
const endOfDay = new Date();
endOfDay.setHours(23, 59, 59, 999);
const agg = await this.prisma.bet.aggregate({
const agg = await client.bet.aggregate({
where: {
userId: params.userId,
placedAt: { gte: startOfDay, lte: endOfDay },

View File

@@ -15,7 +15,7 @@ describe('CatalogArchiveService', () => {
entityTranslation: { findFirst: jest.Mock };
$transaction: jest.Mock;
};
let matches: { betStatsForMatches: jest.Mock };
let matchBetStats: { betStatsForMatches: jest.Mock };
let service: CatalogArchiveService;
beforeEach(() => {
@@ -34,8 +34,8 @@ describe('CatalogArchiveService', () => {
entityTranslation: { findFirst: jest.fn().mockResolvedValue(null) },
$transaction: jest.fn(async (fn: (tx: typeof prisma) => Promise<void>) => fn(prisma)),
};
matches = { betStatsForMatches: jest.fn().mockResolvedValue(new Map()) };
service = new CatalogArchiveService(prisma as never, matches as never);
matchBetStats = { betStatsForMatches: jest.fn().mockResolvedValue(new Map()) };
service = new CatalogArchiveService(prisma as never, matchBetStats as never);
});
const baseMatch = {
@@ -71,21 +71,13 @@ describe('CatalogArchiveService', () => {
});
});
it('archive with force soft-deletes and cancels match', async () => {
it('archive with force rejects draft matches', async () => {
prisma.match.findFirst.mockResolvedValue({ ...baseMatch, status: 'DRAFT' });
prisma.match.update.mockResolvedValue({});
const result = await service.archiveMatch(matchId, { force: true });
expect(result.matchId).toBe(matchId.toString());
expect(prisma.marketSelection.updateMany).toHaveBeenCalled();
expect(prisma.market.updateMany).toHaveBeenCalled();
expect(prisma.match.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: matchId },
data: expect.objectContaining({ status: 'CANCELLED', deletedAt: expect.any(Date) }),
}),
);
await expect(service.archiveMatch(matchId, { force: true })).rejects.toMatchObject({
response: expect.objectContaining({ code: 'MATCH_DELETE_DRAFT_ONLY' }),
});
expect(prisma.match.update).not.toHaveBeenCalled();
});
it('league preview blocks when child match is not terminal', async () => {
@@ -102,7 +94,7 @@ describe('CatalogArchiveService', () => {
awayTeam: { code: 'B' },
},
]);
matches.betStatsForMatches.mockResolvedValue(
matchBetStats.betStatsForMatches.mockResolvedValue(
new Map([[matchId.toString(), { betCount: 0, totalStake: '0', pendingCount: 0 }]]),
);
@@ -129,7 +121,7 @@ describe('CatalogArchiveService', () => {
},
])
.mockResolvedValueOnce([{ id: matchId, status: 'SETTLED' }]);
matches.betStatsForMatches.mockResolvedValue(
matchBetStats.betStatsForMatches.mockResolvedValue(
new Map([[matchId.toString(), { betCount: 1, totalStake: '100', pendingCount: 0 }]]),
);
@@ -163,7 +155,7 @@ describe('CatalogArchiveService', () => {
awayTeam: { code: 'B' },
},
]);
matches.betStatsForMatches.mockResolvedValue(
matchBetStats.betStatsForMatches.mockResolvedValue(
new Map([[matchId.toString(), { betCount: 0, totalStake: '0', pendingCount: 0 }]]),
);

View File

@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Decimal } from '@prisma/client/runtime/library';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { appBadRequest, appConflict, appNotFound } from '../../shared/common/app-error';
import { MatchesService } from './matches.service';
import { MatchBetStatsService } from './match-bet-stats.service';
const TERMINAL_MATCH_STATUSES = new Set(['SETTLED', 'CANCELLED', 'VOID']);
@@ -39,7 +39,7 @@ export type LeagueArchivePreview = {
export class CatalogArchiveService {
constructor(
private prisma: PrismaService,
private matches: MatchesService,
private matchBetStats: MatchBetStatsService,
) {}
async getMatchArchivePreview(matchId: bigint): Promise<MatchArchivePreview> {
@@ -114,7 +114,7 @@ export class CatalogArchiveService {
});
const matchIds = matches.map((m) => m.id);
const stats = await this.matches.betStatsForMatches(matchIds);
const stats = await this.matchBetStats.betStatsForMatches(matchIds);
const previewBatches = matchIds.length
? await this.prisma.settlementBatch.findMany({
where: { matchId: { in: matchIds }, status: 'PREVIEW' },

View File

@@ -0,0 +1,71 @@
import { Decimal } from '@prisma/client/runtime/library';
import { MatchBetStatsService } from './match-bet-stats.service';
describe('MatchBetStatsService', () => {
let prisma: {
betSelection: { findMany: jest.Mock };
};
let service: MatchBetStatsService;
beforeEach(() => {
prisma = {
betSelection: { findMany: jest.fn().mockResolvedValue([]) },
};
service = new MatchBetStatsService(prisma as never);
});
it('returns empty stats without querying when match ids are empty', async () => {
const stats = await service.betStatsForMatches([]);
expect(stats.size).toBe(0);
expect(prisma.betSelection.findMany).not.toHaveBeenCalled();
});
it('deduplicates bets and fills zero stats for matches without bets', async () => {
const matchId = BigInt(10);
const emptyMatchId = BigInt(11);
prisma.betSelection.findMany.mockResolvedValue([
{
matchId,
betId: BigInt(100),
bet: { stake: new Decimal(50), status: 'PENDING' },
},
{
matchId,
betId: BigInt(100),
bet: { stake: new Decimal(50), status: 'PENDING' },
},
{
matchId,
betId: BigInt(101),
bet: { stake: new Decimal(25), status: 'WON' },
},
{
matchId: null,
betId: BigInt(102),
bet: { stake: new Decimal(99), status: 'PENDING' },
},
]);
const stats = await service.betStatsForMatches([matchId, emptyMatchId]);
expect(prisma.betSelection.findMany).toHaveBeenCalledWith({
where: { matchId: { in: [matchId, emptyMatchId] } },
select: {
matchId: true,
betId: true,
bet: { select: { stake: true, status: true } },
},
});
expect(stats.get(matchId.toString())).toEqual({
betCount: 2,
totalStake: '75',
pendingCount: 1,
});
expect(stats.get(emptyMatchId.toString())).toEqual({
betCount: 0,
totalStake: '0',
pendingCount: 0,
});
});
});

View File

@@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common';
import { Decimal } from '@prisma/client/runtime/library';
import { PrismaService } from '../../shared/prisma/prisma.service';
export type MatchBetStatsSummary = {
betCount: number;
totalStake: string;
pendingCount: number;
};
@Injectable()
export class MatchBetStatsService {
constructor(private prisma: PrismaService) {}
/** 批量汇总多场关联注单(按 bet 去重计注单数) */
async betStatsForMatches(
matchIds: bigint[],
): Promise<Map<string, MatchBetStatsSummary>> {
const result = new Map<string, MatchBetStatsSummary>();
if (!matchIds.length) return result;
const legs = await this.prisma.betSelection.findMany({
where: { matchId: { in: matchIds } },
select: {
matchId: true,
betId: true,
bet: { select: { stake: true, status: true } },
},
});
const byMatch = new Map<
string,
Map<string, { stake: Decimal; status: string }>
>();
for (const leg of legs) {
if (leg.matchId == null) continue;
const mid = leg.matchId.toString();
if (!byMatch.has(mid)) byMatch.set(mid, new Map());
const bets = byMatch.get(mid)!;
if (!bets.has(leg.betId.toString())) {
bets.set(leg.betId.toString(), {
stake: leg.bet.stake,
status: leg.bet.status,
});
}
}
for (const id of matchIds) {
const mid = id.toString();
const bets = byMatch.get(mid);
if (!bets) {
result.set(mid, {
betCount: 0,
totalStake: '0',
pendingCount: 0,
});
continue;
}
let totalStake = new Decimal(0);
let pendingCount = 0;
for (const b of bets.values()) {
totalStake = totalStake.add(b.stake);
if (b.status === 'PENDING') pendingCount += 1;
}
result.set(mid, {
betCount: bets.size,
totalStake: totalStake.toString(),
pendingCount,
});
}
return result;
}
}

View File

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

View File

@@ -1,3 +1,12 @@
jest.mock('@thebet365/shared', () => ({
isPreMatchKickoff: jest.fn(() => true),
PARLAY_MARKET_TYPES: [],
resolveTranslationFallback: jest.fn(
(translations: Map<string, string>, locale: string) =>
translations.get(locale) ?? translations.get('zh-CN') ?? translations.get('en-US') ?? null,
),
}));
import { MatchesService } from './matches.service';
describe('MatchesService publish/unpublish', () => {
@@ -11,6 +20,7 @@ describe('MatchesService publish/unpublish', () => {
settlementBatch: { deleteMany: jest.Mock };
};
let outright: { syncWithLeaguePublished: jest.Mock };
let matchBetStats: { betStatsForMatches: jest.Mock };
let service: MatchesService;
beforeEach(() => {
@@ -28,7 +38,8 @@ describe('MatchesService publish/unpublish', () => {
settlementBatch: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) },
};
outright = { syncWithLeaguePublished: jest.fn().mockResolvedValue(undefined) };
service = new MatchesService(prisma as never, outright as never);
matchBetStats = { betStatsForMatches: jest.fn().mockResolvedValue(new Map()) };
service = new MatchesService(prisma as never, outright as never, matchBetStats as never);
});
describe('updatePlatformLeague unpublish', () => {

View File

@@ -1,16 +1,20 @@
import { Injectable } from '@nestjs/common';
import { isPreMatchKickoff, MarketType, resolveTranslationFallback } from '@thebet365/shared';
import {
defaultMarketName,
defaultSelectionName,
isPreMatchKickoff,
isSettlementSupportedMarketType,
PARLAY_MARKET_TYPES,
resolveMarketText,
resolveTranslationFallback,
sanitizeLocalizedText,
} from '@thebet365/shared';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Prisma } from '@prisma/client';
import { Decimal } from '@prisma/client/runtime/library';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
export type MatchBetStatsSummary = {
betCount: number;
totalStake: string;
pendingCount: number;
};
import { MatchBetStatsService, type MatchBetStatsSummary } from './match-bet-stats.service';
import type { ZhiboLeagueExport, ZhiboMatchExport, ZhiboMatchesBundleExport, ZhiboTeamExport } from './zhibo-match.types';
import {
leagueCodeFromExport,
@@ -25,17 +29,11 @@ import {
import { syncWc2026OutrightMarket } from './wc2026-outright.sync';
import { OutrightService } from './outright.service';
export type { MatchBetStatsSummary } from './match-bet-stats.service';
const OUTRIGHT_PLACEHOLDER_CODE = 'OUT';
const PLAYER_PARLAY_MARKET_TYPES = [
MarketType.FT_HANDICAP,
MarketType.FT_OVER_UNDER,
MarketType.FT_1X2,
MarketType.FT_ODD_EVEN,
MarketType.HT_HANDICAP,
MarketType.HT_OVER_UNDER,
MarketType.HT_1X2,
] as const;
const PLAYER_PARLAY_MARKET_TYPES = PARLAY_MARKET_TYPES;
export type ListPublishedOptions = {
/** 响应中是否包含 markets列表页默认 false仅摘要 */
@@ -49,6 +47,7 @@ export class MatchesService {
constructor(
private prisma: PrismaService,
private outright: OutrightService,
private matchBetStats: MatchBetStatsService,
) {}
async createLeague(code: string, translations: Record<string, string>) {
@@ -89,7 +88,6 @@ export class MatchesService {
awayTeamId: bigint;
startTime: Date;
isHot?: boolean;
correctScoreEnabled?: boolean;
displayOrder?: number;
createdBy?: bigint;
status?: string;
@@ -115,7 +113,6 @@ export class MatchesService {
awayTeamId: data.awayTeamId,
startTime: data.startTime,
isHot: data.isHot ?? false,
correctScoreEnabled: data.correctScoreEnabled ?? true,
displayOrder: data.displayOrder ?? 0,
createdBy: data.createdBy,
status,
@@ -624,59 +621,7 @@ export class MatchesService {
async betStatsForMatches(
matchIds: bigint[],
): Promise<Map<string, MatchBetStatsSummary>> {
const result = new Map<string, MatchBetStatsSummary>();
if (!matchIds.length) return result;
const legs = await this.prisma.betSelection.findMany({
where: { matchId: { in: matchIds } },
select: {
matchId: true,
betId: true,
bet: { select: { stake: true, status: true } },
},
});
const byMatch = new Map<
string,
Map<string, { stake: Decimal; status: string }>
>();
for (const leg of legs) {
if (leg.matchId == null) continue;
const mid = leg.matchId.toString();
if (!byMatch.has(mid)) byMatch.set(mid, new Map());
const bets = byMatch.get(mid)!;
if (!bets.has(leg.betId.toString())) {
bets.set(leg.betId.toString(), {
stake: leg.bet.stake,
status: leg.bet.status,
});
}
}
for (const id of matchIds) {
const mid = id.toString();
const bets = byMatch.get(mid);
if (!bets) {
result.set(mid, {
betCount: 0,
totalStake: '0',
pendingCount: 0,
});
continue;
}
let totalStake = new Decimal(0);
let pendingCount = 0;
for (const b of bets.values()) {
totalStake = totalStake.add(b.stake);
if (b.status === 'PENDING') pendingCount += 1;
}
result.set(mid, {
betCount: bets.size,
totalStake: totalStake.toString(),
pendingCount,
});
}
return result;
return this.matchBetStats.betStatsForMatches(matchIds);
}
private async upsertTeamByCode(data: {
@@ -718,7 +663,6 @@ export class MatchesService {
awayTeamMs?: string;
startTime: Date;
isHot?: boolean;
correctScoreEnabled?: boolean;
displayOrder?: number;
matchName?: string;
stage?: string;
@@ -833,7 +777,6 @@ export class MatchesService {
awayTeamId: awayTeam.id,
startTime: data.startTime,
isHot: data.isHot ?? false,
correctScoreEnabled: data.correctScoreEnabled ?? true,
displayOrder: data.displayOrder ?? 0,
createdBy: data.createdBy,
status: 'DRAFT',
@@ -881,7 +824,6 @@ export class MatchesService {
status: match.status,
isOutright: match.isOutright,
isHot: match.isHot,
correctScoreEnabled: match.correctScoreEnabled,
displayOrder: match.displayOrder,
startTime: match.startTime.toISOString(),
leagueId: match.leagueId.toString(),
@@ -909,21 +851,36 @@ export class MatchesService {
htAway: scoreRow.htAwayScore ?? 0,
ftHome: scoreRow.ftHomeScore ?? 0,
ftAway: scoreRow.ftAwayScore ?? 0,
homeCorners: scoreRow.homeCorners ?? null,
awayCorners: scoreRow.awayCorners ?? null,
homeYellowCards: scoreRow.homeYellowCards ?? null,
awayYellowCards: scoreRow.awayYellowCards ?? null,
homeRedCards: scoreRow.homeRedCards ?? null,
awayRedCards: scoreRow.awayRedCards ?? null,
homeCards: scoreRow.homeCards ?? null,
awayCards: scoreRow.awayCards ?? null,
winnerTeamId: scoreRow.winnerTeamId?.toString() ?? null,
}
: null,
markets: markets.map((m) => ({
id: m.id.toString(),
marketType: m.marketType,
marketKey: m.marketKey ?? m.marketType,
lineKey: m.lineKey ?? null,
period: m.period,
lineValue: m.lineValue != null ? Number(m.lineValue) : null,
paramsJson: m.paramsJson ?? null,
status: m.status,
showOnPlayer: m.showOnPlayer,
promoLabel: m.promoLabel ?? '',
promoLabelI18n: sanitizeLocalizedText(m.promoLabelI18n),
nameI18n: sanitizeLocalizedText(m.nameI18n),
sortOrder: m.sortOrder,
selections: m.selections.map((s) => ({
id: s.id.toString(),
selectionCode: s.selectionCode,
selectionName: s.selectionName,
nameI18n: sanitizeLocalizedText(s.nameI18n),
odds: Number(s.odds),
status: s.status,
sortOrder: s.sortOrder,
@@ -949,7 +906,6 @@ export class MatchesService {
groupName?: string;
homeTeamLogoUrl?: string;
awayTeamLogoUrl?: string;
correctScoreEnabled?: boolean;
updatedBy?: bigint;
},
) {
@@ -1006,7 +962,6 @@ export class MatchesService {
matchName,
stage: data.stage !== undefined ? data.stage.trim() || null : match.stage,
groupName: data.groupName !== undefined ? data.groupName.trim() || null : match.groupName,
correctScoreEnabled: data.correctScoreEnabled ?? match.correctScoreEnabled,
updatedBy: data.updatedBy,
},
});
@@ -1261,7 +1216,6 @@ export class MatchesService {
startTime: Date;
status?: string;
isHot?: boolean;
correctScoreEnabled?: boolean;
displayOrder?: number;
matchName?: string | null;
stage?: string | null;
@@ -1270,10 +1224,18 @@ export class MatchesService {
awayTeam?: { code: string; logoUrl?: string | null };
league?: { logoUrl?: string | null };
score?: {
htHomeScore: number;
htAwayScore: number;
ftHomeScore: number;
ftAwayScore: number;
htHomeScore: number | null;
htAwayScore: number | null;
ftHomeScore: number | null;
ftAwayScore: number | null;
homeCorners?: number | null;
awayCorners?: number | null;
homeYellowCards?: number | null;
awayYellowCards?: number | null;
homeRedCards?: number | null;
awayRedCards?: number | null;
homeCards?: number | null;
awayCards?: number | null;
} | null;
markets?: Array<Record<string, unknown>>;
};
@@ -1295,7 +1257,6 @@ export class MatchesService {
awayTeamLogoUrl: m.awayTeam?.logoUrl ?? null,
startTime: m.startTime.toISOString(),
isHot: m.isHot ?? false,
correctScoreEnabled: m.correctScoreEnabled ?? true,
displayOrder: m.displayOrder ?? 0,
matchName: m.matchName ?? null,
stage: m.stage ?? null,
@@ -1303,10 +1264,18 @@ export class MatchesService {
status: m.status ?? 'PUBLISHED',
score: m.score
? {
htHome: m.score.htHomeScore,
htAway: m.score.htAwayScore,
ftHome: m.score.ftHomeScore,
ftAway: m.score.ftAwayScore,
htHome: m.score.htHomeScore ?? null,
htAway: m.score.htAwayScore ?? null,
ftHome: m.score.ftHomeScore ?? null,
ftAway: m.score.ftAwayScore ?? null,
homeCorners: m.score.homeCorners ?? null,
awayCorners: m.score.awayCorners ?? null,
homeYellowCards: m.score.homeYellowCards ?? null,
awayYellowCards: m.score.awayYellowCards ?? null,
homeRedCards: m.score.homeRedCards ?? null,
awayRedCards: m.score.awayRedCards ?? null,
homeCards: m.score.homeCards ?? null,
awayCards: m.score.awayCards ?? null,
}
: null,
bettingOpen: this.isMatchBettingOpen({
@@ -1327,29 +1296,49 @@ export class MatchesService {
}),
};
if (m.markets && !options?.omitMarkets) {
const csEnabled = m.correctScoreEnabled ?? true;
const CORRECT_SCORE_TYPES = ['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE'];
return {
...base,
markets: m.markets
.filter((market) => csEnabled || !CORRECT_SCORE_TYPES.includes(market.marketType as string))
.filter(
(market) =>
((market.showOnPlayer as boolean | undefined) ?? true) &&
isSettlementSupportedMarketType(market.marketType as string),
)
.map((market) => ({
id: (market.id as bigint).toString(),
marketType: market.marketType as string,
period: market.period as string,
status: (market.status as string) ?? 'OPEN',
lineValue: market.lineValue != null ? Number(market.lineValue) : null,
allowParlay: (market.allowParlay as boolean | undefined) ?? true,
promoLabel: (market.promoLabel as string | null | undefined) ?? null,
selections: ((market.selections as Array<Record<string, unknown>>) ?? []).map((s) => ({
id: (s.id as bigint).toString(),
selectionCode: s.selectionCode as string,
selectionName: s.selectionName as string,
status: (s.status as string) ?? 'OPEN',
odds: Number(s.odds),
oddsVersion: (s.oddsVersion as bigint).toString(),
id: (market.id as bigint).toString(),
marketType: market.marketType as string,
marketKey: (market.marketKey as string | null | undefined) ?? (market.marketType as string),
lineKey: (market.lineKey as string | null | undefined) ?? null,
period: market.period as string,
status: (market.status as string) ?? 'OPEN',
lineValue: market.lineValue != null ? Number(market.lineValue) : null,
allowSingle: (market.allowSingle as boolean | undefined) ?? true,
allowParlay: (market.allowParlay as boolean | undefined) ?? true,
marketDisplayName:
resolveMarketText(market.nameI18n, locale, defaultMarketName(market.marketType as string, locale)) ||
(market.marketType as string),
promoLabel:
resolveMarketText(
market.promoLabelI18n,
locale,
(market.promoLabel as string | null | undefined) ?? '',
) || null,
selections: ((market.selections as Array<Record<string, unknown>>) ?? []).map((s) => ({
id: (s.id as bigint).toString(),
selectionCode: s.selectionCode as string,
selectionName: s.selectionName as string,
selectionDisplayName:
resolveMarketText(
s.nameI18n,
locale,
defaultSelectionName(market.marketType as string, s.selectionCode as string, locale) ||
(s.selectionName as string),
) || (s.selectionName as string),
status: (s.status as string) ?? 'OPEN',
odds: Number(s.odds),
oddsVersion: (s.oddsVersion as bigint).toString(),
})),
})),
})),
};
}
return base;
@@ -1392,7 +1381,7 @@ export class MatchesService {
}
private playerMarketInclude = {
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] }, showOnPlayer: true },
include: {
selections: {
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
@@ -1404,7 +1393,7 @@ export class MatchesService {
/** 仅 status 字段,用于列表页计算 bettingOpen不返回给客户端 */
private playerMarketStatusInclude = {
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] }, showOnPlayer: true },
select: {
status: true,
selections: { select: { status: true } },
@@ -1421,6 +1410,7 @@ export class MatchesService {
const marketWhere = {
status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] },
showOnPlayer: true,
...(parlayMarketsOnly ? { marketType: { in: [...PLAYER_PARLAY_MARKET_TYPES] } } : {}),
};
@@ -1576,8 +1566,8 @@ export class MatchesService {
SH_CORRECT_SCORE: { 'zh-CN': '下半场波胆', 'en-US': '2H Correct Score', 'ms-MY': 'Skor Tepat PB2' },
};
const entry = labels[marketType];
if (!entry) return marketType;
return entry[locale] ?? entry['en-US'] ?? marketType;
if (!entry) return defaultMarketName(marketType, locale);
return entry[locale] ?? entry['en-US'] ?? defaultMarketName(marketType, locale);
}
async enrichBetsForHistory(
@@ -1594,6 +1584,7 @@ export class MatchesService {
selections: Array<{
matchId: bigint | null;
marketType: string;
marketNameSnapshot?: string | null;
selectionNameSnapshot: string;
odds: unknown;
resultStatus?: string | null;
@@ -1662,7 +1653,7 @@ export class MatchesService {
const m = mid ? matchMeta.get(mid) : undefined;
return {
marketType: sel.marketType,
marketLabel: this.marketLabelKey(sel.marketType, locale),
marketLabel: sel.marketNameSnapshot || this.marketLabelKey(sel.marketType, locale),
selectionName: sel.selectionNameSnapshot,
odds: sel.odds,
resultStatus: sel.resultStatus,

View File

@@ -92,19 +92,30 @@ export async function syncWc2026OutrightMarket(
startTime: new Date('2027-07-01T00:00:00Z'),
status: 'PUBLISHED',
publishTime: new Date(),
isHot: true,
isHot: false,
displayOrder: 0,
},
});
} else if (match.status === 'DRAFT' || match.status === 'SETTLED' || match.status === 'CLOSED') {
match = await prisma.match.update({
where: { id: match.id },
data: {
status: 'PUBLISHED',
publishTime: match.publishTime ?? new Date(),
closeTime: null,
},
});
} else {
const shouldPublish =
match.status === 'DRAFT' || match.status === 'SETTLED' || match.status === 'CLOSED';
if (match.isHot || shouldPublish) {
const updateData: {
isHot: boolean;
status?: string;
publishTime?: Date;
closeTime?: null;
} = { isHot: false };
if (shouldPublish) {
updateData.status = 'PUBLISHED';
updateData.publishTime = match.publishTime ?? new Date();
updateData.closeTime = null;
}
match = await prisma.match.update({
where: { id: match.id },
data: updateData,
});
}
}
let market = await prisma.market.findFirst({

View File

@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { DepositService } from './deposit.service';
import { WalletModule } from '../ledger/wallet.module';
import { AgentsModule } from '../agent/agents.module';
@Module({
imports: [WalletModule],
imports: [WalletModule, AgentsModule],
providers: [DepositService],
exports: [DepositService],
})

View File

@@ -0,0 +1,119 @@
import { Decimal } from '@prisma/client/runtime/library';
import { DepositService } from './deposit.service';
import { expectAppError } from '../../testing/prisma-mock';
describe('DepositService', () => {
const tx = {
$queryRaw: jest.fn(),
depositOrder: {
findUnique: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
bet: {
findMany: jest.fn(),
},
user: {
findFirst: jest.fn(),
},
agentProfile: {
findUnique: jest.fn(),
},
};
const prisma = {
...tx,
$transaction: jest.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
};
const funds = {
deposit: jest.fn(),
withdraw: jest.fn(),
};
const credit = {
recalculateUsedCredit: jest.fn(),
};
let service: DepositService;
beforeEach(() => {
jest.clearAllMocks();
service = new DepositService(prisma as never, funds as never, credit as never);
tx.$queryRaw.mockResolvedValue([{ id: 1n }]);
tx.depositOrder.findUnique.mockResolvedValue({
id: 1n,
orderNo: 'DEP-1',
playerId: 7n,
amount: new Decimal(100),
approvedAmount: new Decimal(100),
status: 'APPROVED',
reviewedAt: new Date(),
screenshotUrl: '/uploads/a.png',
});
tx.bet.findMany.mockResolvedValue([]);
tx.user.findFirst.mockResolvedValue(null);
tx.agentProfile.findUnique.mockResolvedValue(null);
credit.recalculateUsedCredit.mockResolvedValue(undefined);
});
it('posts approved deposits as player wallet transactions and refreshes parent credit', async () => {
tx.depositOrder.findUnique.mockResolvedValue({
id: 1n,
orderNo: 'DEP-1',
playerId: 7n,
amount: new Decimal(100),
approvedAmount: null,
status: 'PENDING',
reviewedAt: null,
screenshotUrl: '/uploads/a.png',
});
tx.user.findFirst.mockResolvedValue({ parentId: 3n });
tx.agentProfile.findUnique.mockResolvedValue({
creditLimit: new Decimal(1000),
usedCredit: new Decimal(100),
});
await service.approveDepositOrder(1n, 2n, 100, 'Bank receipt checked');
expect(tx.depositOrder.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'APPROVED',
approvedAmount: new Decimal(100),
}),
}),
);
expect(funds.deposit).toHaveBeenCalledWith(
expect.objectContaining({
userId: 7n,
amount: new Decimal(100),
operatorId: 2n,
remark: 'Bank receipt checked',
referenceId: 'DEP-1',
transactionType: 'PLAYER_DEPOSIT',
businessKey: 'deposit:DEP-1:approve',
tx,
}),
);
expect(credit.recalculateUsedCredit).toHaveBeenCalledTimes(2);
expect(credit.recalculateUsedCredit).toHaveBeenNthCalledWith(1, 3n, tx);
expect(credit.recalculateUsedCredit).toHaveBeenNthCalledWith(2, 3n, tx);
});
it('blocks approved deposit revoke when bets exist after approval', async () => {
tx.bet.findMany.mockResolvedValue([{ id: 99n }]);
await expect(
service.reopenDepositOrderForReview(1n, 2n),
).rejects.toMatchObject(expectAppError('DEPOSIT_REVOKE_SETTLED_BETS'));
expect(funds.withdraw).not.toHaveBeenCalled();
expect(tx.depositOrder.update).not.toHaveBeenCalled();
});
it('does not delete a funded deposit order', async () => {
await expect(
service.deleteDepositOrder(1n, 2n),
).rejects.toMatchObject(expectAppError('DEPOSIT_ORDER_FUNDED_DELETE_FORBIDDEN'));
expect(tx.depositOrder.delete).not.toHaveBeenCalled();
});
});

View File

@@ -3,7 +3,8 @@ import { Prisma } from '@prisma/client';
import { Decimal } from '@prisma/client/runtime/library';
import { resolveTranslationFallback } from '@thebet365/shared';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { WalletService } from '../ledger/wallet.service';
import { FundsPostingService } from '../ledger/funds-posting.service';
import { AgentCreditService } from '../agent/agent-credit.service';
import { appBadRequest } from '../../shared/common/app-error';
import { deleteUploadFileByUrl } from '../../shared/uploads/delete-upload-file';
@@ -20,7 +21,8 @@ const DEPOSIT_REVOKE_WINDOW_MS = 5 * 60 * 1000;
export class DepositService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
private funds: FundsPostingService,
private credit: AgentCreditService,
) {}
// ============ Payment Methods (Admin CRUD) ============
@@ -413,6 +415,7 @@ export class DepositService {
if (order.status !== 'PENDING') throw appBadRequest('ORDER_NOT_PENDING');
const creditAmount = approvedAmount != null ? new Decimal(approvedAmount) : order.amount;
const parentAgentId = await this.assertParentCreditForDeposit(tx, order.playerId, creditAmount);
await tx.depositOrder.update({
where: { id: orderId },
@@ -426,14 +429,19 @@ export class DepositService {
});
// Credit player wallet
await this.wallet.deposit(
order.playerId,
creditAmount,
await this.funds.deposit({
userId: order.playerId,
amount: creditAmount,
operatorId,
remark ?? `Deposit order ${order.orderNo}`,
order.orderNo,
'PLAYER_DEPOSIT',
);
remark: remark ?? `Deposit order ${order.orderNo}`,
referenceId: order.orderNo,
transactionType: 'PLAYER_DEPOSIT',
tx,
businessKey: `deposit:${order.orderNo}:approve`,
});
if (parentAgentId) {
await this.credit.recalculateUsedCredit(parentAgentId, tx);
}
return { success: true };
});
@@ -469,86 +477,76 @@ export class DepositService {
remark: string,
) {
const credit = order.approvedAmount ?? order.amount;
await this.wallet.withdraw(
order.playerId,
credit,
await this.funds.withdraw({
userId: order.playerId,
amount: credit,
operatorId,
remark,
order.orderNo,
'PLAYER_DEPOSIT_REVERSAL',
);
referenceId: order.orderNo,
transactionType: 'PLAYER_DEPOSIT_REVERSAL',
businessKey: `deposit:${order.orderNo}:reverse`,
});
}
/** 已拒绝恢复待审核已通过5 分钟内):作废期间待结算注单并扣回入账 */
/** 已拒绝恢复待审核已通过5 分钟内且未产生下注):扣回入账并恢复待审核 */
async reopenDepositOrderForReview(orderId: bigint, operatorId: bigint) {
const order = await this.prisma.depositOrder.findUnique({ where: { id: orderId } });
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
if (order.status === 'PENDING') throw appBadRequest('ORDER_ALREADY_PENDING');
if (order.status === 'REJECTED') {
await this.prisma.depositOrder.update({
where: { id: orderId },
data: {
status: 'PENDING',
approvedAmount: null,
reviewerId: null,
reviewedAt: null,
rejectReason: null,
remark: null,
},
});
return { success: true };
}
if (order.status !== 'APPROVED') {
throw appBadRequest('ORDER_NOT_APPROVED');
}
if (!order.reviewedAt || Date.now() - order.reviewedAt.getTime() > DEPOSIT_REVOKE_WINDOW_MS) {
throw appBadRequest('DEPOSIT_REVOKE_WINDOW_EXPIRED');
}
const reviewedAt = order.reviewedAt;
return this.prisma.$transaction(async (tx) => {
const order = await this.lockDepositOrder(tx, orderId);
if (order.status === 'PENDING') throw appBadRequest('ORDER_ALREADY_PENDING');
if (order.status === 'REJECTED') {
await tx.depositOrder.update({
where: { id: orderId },
data: {
status: 'PENDING',
approvedAmount: null,
reviewerId: null,
reviewedAt: null,
rejectReason: null,
remark: null,
},
});
return { success: true };
}
if (order.status !== 'APPROVED') {
throw appBadRequest('ORDER_NOT_APPROVED');
}
if (!order.reviewedAt || Date.now() - order.reviewedAt.getTime() > DEPOSIT_REVOKE_WINDOW_MS) {
throw appBadRequest('DEPOSIT_REVOKE_WINDOW_EXPIRED');
}
await this.lockPlayerWallet(tx, order.playerId);
const reviewedAt = order.reviewedAt;
const betsAfterReview = await tx.bet.findMany({
where: {
userId: order.playerId,
placedAt: { gte: reviewedAt },
status: { not: 'VOID' },
},
select: { id: true },
});
const settled = betsAfterReview.filter((b) => b.status !== 'PENDING');
if (settled.length > 0) {
if (betsAfterReview.length > 0) {
throw appBadRequest('DEPOSIT_REVOKE_SETTLED_BETS');
}
for (const bet of betsAfterReview) {
await this.wallet.settleBet(
bet.userId,
bet.stake,
bet.stake,
bet.betNo,
'VOID',
tx,
);
await tx.bet.update({
where: { id: bet.id },
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
});
}
const credit = order.approvedAmount ?? order.amount;
await this.wallet.withdraw(
order.playerId,
credit,
const parentAgentId = await this.findPlayerParentAgentId(tx, order.playerId);
await this.funds.withdraw({
userId: order.playerId,
amount: credit,
operatorId,
`Revoke approved deposit ${order.orderNo}`,
order.orderNo,
'PLAYER_DEPOSIT_REVERSAL',
remark: `Revoke approved deposit ${order.orderNo}`,
referenceId: order.orderNo,
transactionType: 'PLAYER_DEPOSIT_REVERSAL',
tx,
);
businessKey: `deposit:${order.orderNo}:reopen-reverse`,
});
if (parentAgentId) {
await this.credit.recalculateUsedCredit(parentAgentId, tx);
}
await tx.depositOrder.update({
where: { id: orderId },
@@ -562,14 +560,17 @@ export class DepositService {
},
});
return { success: true, voidedBets: betsAfterReview.length };
return { success: true, voidedBets: 0 };
});
}
/** 删除充值订单记录及截图(不调整玩家钱包或注单,与撤销无关) */
/** 删除充值订单记录及截图,仅允许删除未资金化订单。 */
async deleteDepositOrder(orderId: bigint, _operatorId: bigint) {
const order = await this.prisma.depositOrder.findUnique({ where: { id: orderId } });
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
if (order.status === 'APPROVED') {
throw appBadRequest('DEPOSIT_ORDER_FUNDED_DELETE_FORBIDDEN');
}
const screenshotUrl = order.screenshotUrl;
await this.prisma.depositOrder.delete({ where: { id: orderId } });
@@ -577,4 +578,51 @@ export class DepositService {
return { success: true };
}
private async lockDepositOrder(tx: Prisma.TransactionClient, orderId: bigint) {
const rows = await tx.$queryRaw<Array<{ id: bigint }>>`
SELECT id FROM deposit_orders WHERE id = ${orderId} FOR UPDATE
`;
if (!rows.length) throw appBadRequest('ORDER_NOT_FOUND');
const order = await tx.depositOrder.findUnique({ where: { id: orderId } });
if (!order) throw appBadRequest('ORDER_NOT_FOUND');
return order;
}
private async lockPlayerWallet(tx: Prisma.TransactionClient, playerId: bigint) {
const rows = await tx.$queryRaw<Array<{ id: bigint }>>`
SELECT id FROM wallets WHERE user_id = ${playerId} FOR UPDATE
`;
if (!rows.length) throw appBadRequest('WALLET_NOT_FOUND');
}
private async findPlayerParentAgentId(tx: Prisma.TransactionClient, playerId: bigint) {
const player = await tx.user.findFirst({
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
select: { parentId: true },
});
return player?.parentId ?? null;
}
private async assertParentCreditForDeposit(
tx: Prisma.TransactionClient,
playerId: bigint,
amount: Decimal,
) {
const parentAgentId = await this.findPlayerParentAgentId(tx, playerId);
if (!parentAgentId) return null;
await this.credit.recalculateUsedCredit(parentAgentId, tx);
const profile = await tx.agentProfile.findUnique({
where: { userId: parentAgentId },
select: { creditLimit: true, usedCredit: true },
});
if (!profile) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
if (available.lt(amount)) {
throw appBadRequest('CREDIT_TOPUP_EXCEEDED');
}
return parentAgentId;
}
}

View File

@@ -0,0 +1,78 @@
import { Decimal } from '@prisma/client/runtime/library';
import { FundsPostingService } from './funds-posting.service';
describe('FundsPostingService', () => {
const wallet = {
deposit: jest.fn(),
freezeForBet: jest.fn(),
settleBet: jest.fn(),
applyResettleDelta: jest.fn(),
};
let service: FundsPostingService;
beforeEach(() => {
jest.clearAllMocks();
service = new FundsPostingService(wallet as never);
});
it('freezes bet funds with a stable business key', async () => {
await service.freezeBet({
userId: 1n,
stake: new Decimal(50),
betNo: 'BET-001',
tx: {} as never,
});
expect(wallet.freezeForBet).toHaveBeenCalledWith(
1n,
expect.any(Decimal),
'BET-001',
expect.anything(),
'bet:BET-001:freeze',
);
});
it('settles bets with batch and bet identity in the business key', async () => {
await service.settleBet({
userId: 2n,
stake: new Decimal(100),
payout: new Decimal(180),
betNo: 'BET-002',
batchNo: 'SETTLE-001',
result: 'WIN',
tx: {} as never,
});
expect(wallet.settleBet).toHaveBeenCalledWith(
2n,
expect.any(Decimal),
expect.any(Decimal),
'BET-002',
'WIN',
expect.anything(),
'settle:SETTLE-001:BET-002',
);
});
it('passes caller-provided business keys for transfer postings', async () => {
await service.deposit({
userId: 3n,
amount: 25,
operatorId: 9n,
referenceId: 'REQ-1',
transactionType: 'ADMIN_DEPOSIT',
businessKey: 'admin-deposit:9:REQ-1',
});
expect(wallet.deposit).toHaveBeenCalledWith(
3n,
25,
9n,
undefined,
'REQ-1',
'ADMIN_DEPOSIT',
undefined,
'admin-deposit:9:REQ-1',
);
});
});

View File

@@ -0,0 +1,124 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Decimal } from '@prisma/client/runtime/library';
import { WalletService } from './wallet.service';
type TxClient = Prisma.TransactionClient;
@Injectable()
export class FundsPostingService {
constructor(private wallet: WalletService) {}
deposit(command: {
userId: bigint;
amount: Decimal | number;
operatorId: bigint;
remark?: string;
referenceId?: string;
transactionType?: string;
businessKey?: string;
tx?: TxClient;
}) {
return this.wallet.deposit(
command.userId,
command.amount,
command.operatorId,
command.remark,
command.referenceId,
command.transactionType ?? 'MANUAL_DEPOSIT',
command.tx,
command.businessKey,
);
}
withdraw(command: {
userId: bigint;
amount: Decimal | number;
operatorId: bigint;
remark?: string;
referenceId?: string;
transactionType?: string;
businessKey?: string;
tx?: TxClient;
}) {
return this.wallet.withdraw(
command.userId,
command.amount,
command.operatorId,
command.remark,
command.referenceId,
command.transactionType ?? 'MANUAL_WITHDRAW',
command.tx,
command.businessKey,
);
}
freezeBet(command: {
userId: bigint;
stake: Decimal | number;
betNo: string;
tx?: TxClient;
}) {
return this.wallet.freezeForBet(
command.userId,
command.stake,
command.betNo,
command.tx,
`bet:${command.betNo}:freeze`,
);
}
settleBet(command: {
userId: bigint;
stake: Decimal;
payout: Decimal;
betNo: string;
batchNo: string;
result: 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE';
tx?: TxClient;
}) {
return this.wallet.settleBet(
command.userId,
command.stake,
command.payout,
command.betNo,
command.result,
command.tx,
`settle:${command.batchNo}:${command.betNo}`,
);
}
voidBet(command: {
userId: bigint;
stake: Decimal;
betNo: string;
businessKey: string;
tx?: TxClient;
}) {
return this.wallet.settleBet(
command.userId,
command.stake,
command.stake,
command.betNo,
'VOID',
command.tx,
command.businessKey,
);
}
applyResettleDelta(command: {
userId: bigint;
delta: Decimal;
betNo: string;
batchNo: string;
tx?: TxClient;
}) {
return this.wallet.applyResettleDelta(
command.userId,
command.delta,
command.betNo,
command.tx,
`resettle:${command.batchNo}:${command.betNo}`,
);
}
}

View File

@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';
import { WalletService } from './wallet.service';
import { FundsPostingService } from './funds-posting.service';
@Module({
providers: [WalletService],
exports: [WalletService],
providers: [WalletService, FundsPostingService],
exports: [WalletService, FundsPostingService],
})
export class WalletModule {}

View File

@@ -0,0 +1,75 @@
import { Decimal } from '@prisma/client/runtime/library';
import { WalletService } from './wallet.service';
import { expectAppError } from '../../testing/prisma-mock';
describe('WalletService', () => {
const tx = {
$queryRaw: jest.fn(),
wallet: {
update: jest.fn(),
},
walletTransaction: {
findUnique: jest.fn(),
create: jest.fn(),
},
};
const prisma = {
$transaction: jest.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
};
let service: WalletService;
beforeEach(() => {
jest.clearAllMocks();
service = new WalletService(prisma as never);
tx.walletTransaction.findUnique.mockResolvedValue(null);
});
it('rejects settlement when frozen funds are lower than the stake', async () => {
tx.$queryRaw.mockResolvedValue([
{
id: 1n,
available_balance: new Decimal(10),
frozen_balance: new Decimal(40),
version: 1,
},
]);
await expect(
service.settleBet(
99n,
new Decimal(50),
new Decimal(0),
'BET-LOW-FROZEN',
'LOSE',
undefined,
'settle:BATCH:BET-LOW-FROZEN',
),
).rejects.toMatchObject(expectAppError('WALLET_FROZEN_INSUFFICIENT'));
expect(tx.wallet.update).not.toHaveBeenCalled();
expect(tx.walletTransaction.create).not.toHaveBeenCalled();
});
it('lists player-approved deposits in transfer transactions', async () => {
const listPrisma = {
walletTransaction: {
findMany: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
};
const listService = new WalletService(listPrisma as never);
await listService.listTransferTransactions({});
expect(listPrisma.walletTransaction.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
transactionType: expect.objectContaining({
in: expect.arrayContaining(['PLAYER_DEPOSIT']),
}),
}),
}),
);
});
});

View File

@@ -7,6 +7,11 @@ import { generateTransactionId } from '../../shared/common/decorators';
type TxClient = Prisma.TransactionClient;
type WalletPostingOptions = {
tx?: TxClient;
businessKey?: string;
};
@Injectable()
export class WalletService {
constructor(private prisma: PrismaService) {}
@@ -31,6 +36,11 @@ export class WalletService {
return wallets[0];
}
private async findPosting(client: TxClient, businessKey?: string | null) {
if (!businessKey) return null;
return client.walletTransaction.findUnique({ where: { businessKey } });
}
async deposit(
userId: bigint,
amount: Decimal | number,
@@ -38,16 +48,24 @@ export class WalletService {
remark?: string,
referenceId?: string,
transactionType = 'MANUAL_DEPOSIT',
tx?: TxClient,
businessKey?: string,
) {
const amt = new Decimal(amount);
if (amt.lte(0)) throw appBadRequest('AMOUNT_MUST_BE_POSITIVE');
return this.prisma.$transaction(async (tx) => {
const w = await this.lockWallet(tx, userId);
const run = async (client: TxClient) => {
const existing = await this.findPosting(client, businessKey);
if (existing) return { balanceAfter: existing.balanceAfter };
const w = await this.lockWallet(client, userId);
const afterLockExisting = await this.findPosting(client, businessKey);
if (afterLockExisting) return { balanceAfter: afterLockExisting.balanceAfter };
const balanceBefore = new Decimal(w.available_balance);
const balanceAfter = balanceBefore.add(amt);
await tx.wallet.update({
await client.wallet.update({
where: { id: w.id },
data: {
availableBalance: balanceAfter,
@@ -55,7 +73,7 @@ export class WalletService {
},
});
await tx.walletTransaction.create({
await client.walletTransaction.create({
data: {
transactionId: generateTransactionId(),
userId,
@@ -68,13 +86,17 @@ export class WalletService {
frozenAfter: w.frozen_balance,
referenceType: 'DEPOSIT',
referenceId,
businessKey,
operatorId,
remark,
},
});
return { balanceAfter };
});
};
if (tx) return run(tx);
return this.prisma.$transaction(run);
}
async withdraw(
@@ -85,12 +107,19 @@ export class WalletService {
referenceId?: string,
transactionType = 'MANUAL_WITHDRAW',
tx?: TxClient,
businessKey?: string,
) {
const amt = new Decimal(amount);
if (amt.lte(0)) throw appBadRequest('AMOUNT_MUST_BE_POSITIVE');
const run = async (client: TxClient) => {
const existing = await this.findPosting(client, businessKey);
if (existing) return { balanceAfter: existing.balanceAfter };
const w = await this.lockWallet(client, userId);
const afterLockExisting = await this.findPosting(client, businessKey);
if (afterLockExisting) return { balanceAfter: afterLockExisting.balanceAfter };
const balanceBefore = new Decimal(w.available_balance);
if (balanceBefore.lt(amt)) throw appBadRequest('INSUFFICIENT_BALANCE');
const balanceAfter = balanceBefore.sub(amt);
@@ -116,6 +145,7 @@ export class WalletService {
frozenAfter: w.frozen_balance,
referenceType: 'WITHDRAW',
referenceId,
businessKey,
operatorId,
remark,
},
@@ -128,18 +158,30 @@ export class WalletService {
return this.prisma.$transaction(run);
}
async freezeForBet(userId: bigint, stake: Decimal | number, betId: string) {
async freezeForBet(
userId: bigint,
stake: Decimal | number,
betId: string,
tx?: TxClient,
businessKey = `bet:${betId}:freeze`,
) {
const amt = new Decimal(stake);
return this.prisma.$transaction(async (tx) => {
const w = await this.lockWallet(tx, userId);
const run = async (client: TxClient) => {
const existing = await this.findPosting(client, businessKey);
if (existing) return;
const w = await this.lockWallet(client, userId);
const afterLockExisting = await this.findPosting(client, businessKey);
if (afterLockExisting) return;
const avail = new Decimal(w.available_balance);
if (avail.lt(amt)) throw appBadRequest('INSUFFICIENT_BALANCE');
const balanceAfter = avail.sub(amt);
const frozenAfter = new Decimal(w.frozen_balance).add(amt);
await tx.wallet.update({
await client.wallet.update({
where: { id: w.id },
data: {
availableBalance: balanceAfter,
@@ -148,7 +190,7 @@ export class WalletService {
},
});
await tx.walletTransaction.create({
await client.walletTransaction.create({
data: {
transactionId: generateTransactionId(),
userId,
@@ -161,9 +203,13 @@ export class WalletService {
frozenAfter,
referenceType: 'BET',
referenceId: betId,
businessKey,
},
});
});
};
if (tx) return run(tx);
return this.prisma.$transaction(run);
}
async settleBet(
@@ -173,8 +219,12 @@ export class WalletService {
betId: string,
result: 'WIN' | 'LOSE' | 'PUSH' | 'VOID' | 'HALF_WIN' | 'HALF_LOSE',
tx?: TxClient,
businessKey?: string,
) {
const run = async (client: TxClient) => {
const existing = await this.findPosting(client, businessKey);
if (existing) return;
const txTypeMap: Record<string, string> = {
WIN: 'BET_SETTLE_WIN',
LOSE: 'BET_SETTLE_LOSE',
@@ -185,16 +235,22 @@ export class WalletService {
};
const w = await this.lockWallet(client, userId);
const afterLockExisting = await this.findPosting(client, businessKey);
if (afterLockExisting) return;
const avail = new Decimal(w.available_balance);
const frozen = new Decimal(w.frozen_balance);
const frozenAfter = frozen.sub(stake);
if (frozenAfter.lt(0)) {
throw appBadRequest('WALLET_FROZEN_INSUFFICIENT');
}
const balanceAfter = avail.add(payout);
await client.wallet.update({
where: { id: w.id },
data: {
availableBalance: balanceAfter,
frozenBalance: frozenAfter.lt(0) ? new Decimal(0) : frozenAfter,
frozenBalance: frozenAfter,
version: { increment: 1 },
},
});
@@ -209,9 +265,10 @@ export class WalletService {
balanceBefore: avail,
balanceAfter,
frozenBefore: frozen,
frozenAfter: frozenAfter.lt(0) ? new Decimal(0) : frozenAfter,
frozenAfter,
referenceType: 'BET',
referenceId: betId,
businessKey,
},
});
};
@@ -226,11 +283,18 @@ export class WalletService {
delta: Decimal,
betNo: string,
tx?: TxClient,
businessKey?: string,
) {
if (delta.eq(0)) return;
const run = async (client: TxClient) => {
const existing = await this.findPosting(client, businessKey);
if (existing) return;
const w = await this.lockWallet(client, userId);
const afterLockExisting = await this.findPosting(client, businessKey);
if (afterLockExisting) return;
const avail = new Decimal(w.available_balance);
const balanceAfter = avail.add(delta);
const txType = delta.gt(0) ? 'BET_SETTLE_WIN' : 'RESETTLE_REVERSE';
@@ -256,6 +320,7 @@ export class WalletService {
frozenAfter: w.frozen_balance,
referenceType: 'BET',
referenceId: betNo,
businessKey,
remark: 'Resettlement adjustment',
},
});
@@ -817,6 +882,7 @@ export class WalletService {
'AGENT_DEPOSIT',
'AGENT_WITHDRAW',
'INITIAL_DEPOSIT',
'PLAYER_DEPOSIT',
];
const where: Prisma.WalletTransactionWhereInput = {
transactionType: params.transactionType?.trim()

View File

@@ -0,0 +1,137 @@
import { MarketsService } from './markets.service';
function selection(selectionCode: string, odds = 1.9) {
return {
selectionCode,
selectionName: selectionCode,
odds,
status: 'OPEN',
};
}
function errorCode(error: unknown) {
const response = (error as { getResponse?: () => unknown }).getResponse?.();
return (response as { code?: string } | undefined)?.code;
}
function createPrismaMock() {
let marketId = 100n;
let selectionId = 1000n;
return {
match: {
findUnique: jest.fn().mockResolvedValue({ id: 1n }),
},
market: {
findFirst: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(async ({ data }) => ({ id: marketId++, ...data })),
update: jest.fn().mockImplementation(async ({ where, data }) => ({ id: where.id, ...data })),
findUnique: jest.fn().mockImplementation(async ({ where }) => ({ id: where.id, selections: [] })),
findMany: jest.fn().mockResolvedValue([]),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
marketSelection: {
findMany: jest.fn().mockResolvedValue([]),
create: jest.fn().mockImplementation(async ({ data }) => ({ id: selectionId++, ...data })),
update: jest.fn(),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
oddsChangeLog: {
create: jest.fn(),
},
};
}
describe('MarketsService line value rules', () => {
it('exposes usesLineValue in market definitions', () => {
const service = new MarketsService(createPrismaMock() as never);
const definitions = service.listMarketDefinitions();
expect(definitions.find((d) => d.marketType === 'FT_HANDICAP')?.usesLineValue).toBe(true);
expect(definitions.find((d) => d.marketType === 'FT_1X2')?.usesLineValue).toBe(false);
});
it('rejects line markets without a numeric line value', async () => {
const service = new MarketsService(createPrismaMock() as never);
let caughtCode: string | undefined;
try {
await service.saveMatchMarkets(1n, [
{
marketType: 'FT_HANDICAP',
lineValue: null,
selections: [selection('HOME'), selection('AWAY')],
},
]);
} catch (error) {
caughtCode = errorCode(error);
}
expect(caughtCode).toBe('MARKET_LINE_REQUIRED');
});
it('rejects non-line markets with a line value', async () => {
const service = new MarketsService(createPrismaMock() as never);
let caughtCode: string | undefined;
try {
await service.saveMatchMarkets(1n, [
{
marketType: 'FT_1X2',
lineValue: 1,
selections: [selection('HOME'), selection('DRAW'), selection('AWAY')],
},
]);
} catch (error) {
caughtCode = errorCode(error);
}
expect(caughtCode).toBe('MARKET_LINE_NOT_ALLOWED');
});
it('allows non-line markets with null line value', async () => {
const prisma = createPrismaMock();
const service = new MarketsService(prisma as never);
const result = await service.saveMatchMarkets(1n, [
{
marketType: 'FT_1X2',
lineValue: null,
selections: [selection('HOME'), selection('DRAW'), selection('AWAY')],
},
]);
expect(result).toEqual({ updated: 1, closed: 0 });
expect(prisma.market.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
marketType: 'FT_1X2',
lineKey: 'FT_1X2:none',
lineValue: null,
}),
}),
);
});
it('keeps distinct line keys for copied line markets', async () => {
const prisma = createPrismaMock();
const service = new MarketsService(prisma as never);
const result = await service.saveMatchMarkets(1n, [
{
marketType: 'FT_HANDICAP',
lineValue: -0.5,
selections: [selection('HOME'), selection('AWAY')],
},
{
marketType: 'FT_HANDICAP',
lineValue: -0.25,
selections: [selection('HOME'), selection('AWAY')],
},
]);
expect(result).toEqual({ updated: 2, closed: 0 });
expect(prisma.market.create.mock.calls.map(([arg]) => arg.data.lineKey)).toEqual([
'FT_HANDICAP:-0.50',
'FT_HANDICAP:-0.25',
]);
});
});

View File

@@ -1,189 +1,291 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { Decimal } from '@prisma/client/runtime/library';
import {
FT_CORRECT_SCORE_TEMPLATE,
HT_CORRECT_SCORE_TEMPLATE,
} from '../settlement/domain/settlement-calculator';
DEFAULT_MARKET_TYPES,
FOOTBALL_MARKET_CATALOG,
buildMarketLineKey,
buildMarketTemplate,
defaultSelectionName,
isSettlementSupportedMarketType,
marketUsesLineValue,
resolveMarketText,
sanitizeLocalizedText,
type LocalizedText,
} from '@thebet365/shared';
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
type LocalizedInput = Record<string, string | undefined | null>;
type SelectionDraft = {
id?: string;
selectionCode: string;
selectionName?: string;
nameI18n?: LocalizedInput | null;
odds: number;
status?: string;
sortOrder?: number;
};
type MarketDraft = {
id?: string;
marketType: string;
marketKey?: string | null;
lineKey?: string | null;
period?: string;
lineValue?: number | null;
paramsJson?: Record<string, unknown> | null;
status?: string;
allowSingle?: boolean;
allowParlay?: boolean;
showOnPlayer?: boolean;
sortOrder?: number;
promoLabel?: string | null;
promoLabelI18n?: LocalizedInput | null;
nameI18n?: LocalizedInput | null;
selections?: SelectionDraft[];
};
type TemplateDraft = {
name?: string;
nameI18n?: LocalizedInput | null;
description?: string | null;
isDefault?: boolean;
status?: string;
sortOrder?: number;
items?: MarketDraft[];
};
@Injectable()
export class MarketsService {
constructor(private prisma: PrismaService) {}
async generateTemplates(matchId: bigint, marketTypes: string[]) {
listMarketDefinitions() {
return Object.entries(FOOTBALL_MARKET_CATALOG).map(([marketType, entry]) => ({
marketType,
marketKey: entry.marketKey,
period: entry.period,
sortOrder: entry.sortOrder,
defaultLineValue: entry.defaultLineValue,
allowSingle: entry.allowSingle,
allowParlay: entry.allowParlay,
showOnPlayer: entry.showOnPlayer,
settlementSupported: entry.settlementSupported,
settlementKind: entry.settlementKind,
renderType: entry.renderType,
usesLineValue: marketUsesLineValue(marketType),
nameI18n: entry.nameI18n,
selectionTemplate: entry.selectionTemplate,
}));
}
async listTemplates() {
await this.ensureDefaultTemplate();
const templates = await this.prisma.marketTemplate.findMany({
where: { sportType: 'FOOTBALL' },
include: { items: { include: { selections: true } } },
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { id: 'asc' }],
});
return templates.map((t) => this.mapTemplate(t));
}
async getTemplate(templateId: bigint) {
await this.ensureDefaultTemplate();
const template = await this.prisma.marketTemplate.findUnique({
where: { id: templateId },
include: {
items: {
include: { selections: { orderBy: { sortOrder: 'asc' } } },
orderBy: { sortOrder: 'asc' },
},
},
});
if (!template) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
return this.mapTemplate(template);
}
async createTemplate(data: TemplateDraft) {
const nameI18n = sanitizeLocalizedText(data.nameI18n);
const name = data.name?.trim() || resolveMarketText(nameI18n, 'zh-CN', '足球盘口模板');
const template = await this.prisma.marketTemplate.create({
data: {
sportType: 'FOOTBALL',
name,
nameI18n: this.jsonOrNull(nameI18n),
description: data.description?.trim() || null,
isDefault: data.isDefault ?? false,
status: data.status ?? 'ACTIVE',
sortOrder: data.sortOrder ?? 0,
},
});
if (data.items?.length) {
await this.replaceTemplateItems(template.id, data.items);
}
if (data.isDefault) await this.setDefaultTemplate(template.id);
return this.getTemplate(template.id);
}
async updateTemplate(templateId: bigint, data: TemplateDraft) {
const existing = await this.prisma.marketTemplate.findUnique({ where: { id: templateId } });
if (!existing) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
const nameI18n =
data.nameI18n !== undefined
? sanitizeLocalizedText(data.nameI18n)
: this.asLocalizedText(existing.nameI18n);
await this.prisma.marketTemplate.update({
where: { id: templateId },
data: {
...(data.name !== undefined
? { name: data.name.trim() || resolveMarketText(nameI18n, 'zh-CN', existing.name) }
: {}),
...(data.nameI18n !== undefined ? { nameI18n: this.jsonOrNull(nameI18n) } : {}),
...(data.description !== undefined ? { description: data.description?.trim() || null } : {}),
...(data.status !== undefined ? { status: data.status } : {}),
...(data.sortOrder !== undefined ? { sortOrder: data.sortOrder } : {}),
...(data.isDefault !== undefined ? { isDefault: data.isDefault } : {}),
},
});
if (data.items) await this.replaceTemplateItems(templateId, data.items);
if (data.isDefault) await this.setDefaultTemplate(templateId);
return this.getTemplate(templateId);
}
async duplicateTemplate(templateId: bigint) {
const source = await this.prisma.marketTemplate.findUnique({
where: { id: templateId },
include: { items: { include: { selections: true }, orderBy: { sortOrder: 'asc' } } },
});
if (!source) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
const copy = await this.prisma.marketTemplate.create({
data: {
sportType: source.sportType,
name: `${source.name} Copy`,
nameI18n: source.nameI18n ?? undefined,
description: source.description,
isDefault: false,
status: source.status,
sortOrder: source.sortOrder + 1,
},
});
await this.replaceTemplateItems(
copy.id,
source.items.map((item) => ({
marketType: item.marketType,
marketKey: item.marketKey,
lineKey: item.lineKey,
period: item.period,
lineValue: item.lineValue == null ? null : Number(item.lineValue),
paramsJson: (item.paramsJson as Record<string, unknown> | null) ?? null,
status: item.status,
allowSingle: item.allowSingle,
allowParlay: item.allowParlay,
showOnPlayer: item.showOnPlayer,
sortOrder: item.sortOrder,
promoLabel: item.promoLabel,
promoLabelI18n: this.asLocalizedText(item.promoLabelI18n),
nameI18n: this.asLocalizedText(item.nameI18n),
selections: item.selections.map((s) => ({
selectionCode: s.selectionCode,
selectionName: s.selectionName,
nameI18n: this.asLocalizedText(s.nameI18n),
odds: Number(s.odds),
status: s.status,
sortOrder: s.sortOrder,
})),
})),
);
return this.getTemplate(copy.id);
}
async setDefaultTemplate(templateId: bigint) {
const existing = await this.prisma.marketTemplate.findUnique({ where: { id: templateId } });
if (!existing) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
await this.prisma.$transaction([
this.prisma.marketTemplate.updateMany({
where: { sportType: 'FOOTBALL', id: { not: templateId } },
data: { isDefault: false },
}),
this.prisma.marketTemplate.update({
where: { id: templateId },
data: { isDefault: true, status: 'ACTIVE' },
}),
]);
return this.getTemplate(templateId);
}
async applyTemplateToMatch(matchId: bigint, templateId?: bigint | null) {
const match = await this.prisma.match.findUnique({ where: { id: matchId } });
if (!match) throw appNotFound('MATCH_NOT_FOUND');
const template = templateId
? await this.prisma.marketTemplate.findUnique({
where: { id: templateId },
include: { items: { include: { selections: true }, orderBy: { sortOrder: 'asc' } } },
})
: await this.getDefaultTemplateRecord();
if (!template) throw appNotFound('MARKET_TEMPLATE_NOT_FOUND');
const created = [];
const results = [];
for (const item of template.items) {
if (item.status === 'DELETED') continue;
const draft: MarketDraft = {
marketType: item.marketType,
marketKey: item.marketKey,
lineKey: item.lineKey,
period: item.period,
lineValue: item.lineValue == null ? null : Number(item.lineValue),
paramsJson: (item.paramsJson as Record<string, unknown> | null) ?? null,
status: item.status,
allowSingle: item.allowSingle,
allowParlay: item.allowParlay,
showOnPlayer: item.showOnPlayer,
sortOrder: item.sortOrder,
promoLabel: item.promoLabel,
promoLabelI18n: this.asLocalizedText(item.promoLabelI18n),
nameI18n: this.asLocalizedText(item.nameI18n),
selections: item.selections.map((s) => ({
selectionCode: s.selectionCode,
selectionName: s.selectionName,
nameI18n: this.asLocalizedText(s.nameI18n),
odds: Number(s.odds),
status: s.status,
sortOrder: s.sortOrder,
})),
};
results.push(await this.upsertMatchMarket(matchId, draft, undefined, item.id));
}
return { templateId: template.id.toString(), applied: results.length };
}
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);
async saveMatchMarkets(matchId: bigint, items: MarketDraft[], operatorId?: bigint) {
const match = await this.prisma.match.findUnique({ where: { id: matchId } });
if (!match) throw appNotFound('MATCH_NOT_FOUND');
const saved = [];
const activeIds = new Set<string>();
for (const item of items) {
const market = await this.upsertMatchMarket(matchId, item, operatorId);
if (!market) throw appNotFound('MARKET_NOT_FOUND');
activeIds.add(market.id.toString());
saved.push(market);
}
return created;
const existing = await this.prisma.market.findMany({ where: { matchId }, select: { id: true } });
const missing = existing.filter((m) => !activeIds.has(m.id.toString())).map((m) => m.id);
if (missing.length) {
await this.prisma.market.updateMany({
where: { id: { in: missing } },
data: { status: 'CLOSED', showOnPlayer: false },
});
}
return { updated: saved.length, closed: missing.length };
}
private formatHandicapName(side: 'home' | 'away', line: number, half = false) {
const sideLabel = side === 'home' ? '主队' : '客队';
const value = side === 'home' ? line : -line;
const lineText = value > 0 ? `+${value}` : `${value}`;
return half ? `半场${sideLabel} ${lineText}` : `${sideLabel} ${lineText}`;
}
private formatOuName(side: 'over' | 'under', line: number, half = false) {
const sideLabel = side === 'over' ? '大' : '小';
return half ? `半场${sideLabel} ${line}` : `${sideLabel} ${line}`;
}
private formatScoreName(code: string) {
return code.replace('SCORE_', '').replace('_', '-');
}
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: '主胜', odds: 2.5 },
{ code: 'DRAW', name: '和', odds: 3.2 },
{ code: 'AWAY', name: '客胜', odds: 2.8 },
],
},
HT_1X2: {
period: 'HT',
allowParlay: true,
sortOrder: 5,
selections: [
{ code: 'HOME', name: '半场主胜', odds: 3.0 },
{ code: 'DRAW', name: '半场和', odds: 2.0 },
{ code: 'AWAY', name: '半场客胜', odds: 3.5 },
],
},
FT_HANDICAP: {
period: 'FT',
lineValue: -0.5,
allowParlay: true,
sortOrder: 2,
selections: [
{ code: 'HOME', name: this.formatHandicapName('home', -0.5), odds: 1.9 },
{ code: 'AWAY', name: this.formatHandicapName('away', -0.5), odds: 1.9 },
],
},
HT_HANDICAP: {
period: 'HT',
lineValue: -0.5,
allowParlay: true,
sortOrder: 6,
selections: [
{ code: 'HOME', name: this.formatHandicapName('home', -0.5, true), odds: 1.9 },
{ code: 'AWAY', name: this.formatHandicapName('away', -0.5, true), odds: 1.9 },
],
},
FT_OVER_UNDER: {
period: 'FT',
lineValue: 2.5,
allowParlay: true,
sortOrder: 3,
selections: [
{ code: 'OVER', name: this.formatOuName('over', 2.5), odds: 1.85 },
{ code: 'UNDER', name: this.formatOuName('under', 2.5), odds: 1.95 },
],
},
HT_OVER_UNDER: {
period: 'HT',
lineValue: 1.5,
allowParlay: true,
sortOrder: 7,
selections: [
{ code: 'OVER', name: this.formatOuName('over', 1.5, true), odds: 2.0 },
{ code: 'UNDER', name: this.formatOuName('under', 1.5, true), odds: 1.75 },
],
},
FT_ODD_EVEN: {
period: 'FT',
allowParlay: true,
sortOrder: 4,
selections: [
{ code: 'ODD', name: '单', odds: 1.9 },
{ code: 'EVEN', name: '双', odds: 1.9 },
],
},
FT_CORRECT_SCORE: {
period: 'FT',
allowParlay: true,
sortOrder: 8,
selections: FT_CORRECT_SCORE_TEMPLATE.map((code) => ({
code,
name: this.formatScoreName(code),
odds: 8.0,
})),
},
HT_CORRECT_SCORE: {
period: 'HT',
allowParlay: true,
sortOrder: 9,
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
code,
name: this.formatScoreName(code),
odds: 6.0,
})),
},
SH_CORRECT_SCORE: {
period: 'SH',
allowParlay: true,
sortOrder: 10,
selections: HT_CORRECT_SCORE_TEMPLATE.map((code) => ({
code,
name: this.formatScoreName(code),
odds: 6.0,
})),
},
OUTRIGHT_WINNER: {
period: 'OUTRIGHT',
allowParlay: false,
sortOrder: 1,
selections: [],
},
};
const config = configs[marketType];
if (!config) throw appBadRequest('UNKNOWN_MARKET_TYPE', { marketType });
return config;
async generateTemplates(matchId: bigint, marketTypes: string[]) {
const items = marketTypes.map((marketType, i) => {
const config = this.getMarketConfig(marketType);
return this.defaultDraftFromConfig(marketType, config.defaultLineValue, i);
});
const result = await this.saveMatchMarkets(matchId, items);
return { created: result.updated };
}
async updateOdds(selectionId: bigint, newOdds: number, operatorId: bigint) {
@@ -226,24 +328,42 @@ export class MarketsService {
async updateMarket(
marketId: bigint,
data: { promoLabel?: string | null; status?: string; lineValue?: number | null },
data: {
promoLabel?: string | null;
promoLabelI18n?: LocalizedInput | null;
nameI18n?: LocalizedInput | null;
status?: string;
lineValue?: number | null;
showOnPlayer?: boolean;
},
) {
const market = await this.prisma.market.findUnique({ where: { id: marketId } });
if (!market) throw appNotFound('MARKET_NOT_FOUND');
const lineValue = data.lineValue !== undefined ? data.lineValue : market.lineValue == null ? null : Number(market.lineValue);
if (data.lineValue !== undefined) {
this.assertLineValueAllowed(market.marketType, lineValue);
}
const lineKey =
data.lineValue !== undefined
? buildMarketLineKey(market.marketType, lineValue, (market.paramsJson as Record<string, unknown> | null) ?? null)
: market.lineKey;
return this.prisma.market.update({
where: { id: marketId },
data: {
...(data.promoLabel !== undefined ? { promoLabel: data.promoLabel?.trim() || null } : {}),
...(data.promoLabelI18n !== undefined ? { promoLabelI18n: this.jsonOrNull(sanitizeLocalizedText(data.promoLabelI18n)) } : {}),
...(data.nameI18n !== undefined ? { nameI18n: this.jsonOrNull(sanitizeLocalizedText(data.nameI18n)) } : {}),
...(data.status !== undefined ? { status: data.status } : {}),
...(data.lineValue !== undefined ? { lineValue: data.lineValue } : {}),
...(data.lineValue !== undefined ? { lineValue, lineKey } : {}),
...(data.showOnPlayer !== undefined ? { showOnPlayer: data.showOnPlayer } : {}),
},
});
}
async updateSelection(
selectionId: bigint,
data: { selectionName?: string; odds?: number; status?: string },
data: { selectionName?: string; nameI18n?: LocalizedInput | null; odds?: number; status?: string },
operatorId?: bigint,
) {
const selection = await this.prisma.marketSelection.findUnique({
@@ -253,15 +373,355 @@ export class MarketsService {
if (data.odds != null) {
if (!operatorId) throw appBadRequest('OPERATOR_REQUIRED');
return this.updateOdds(selectionId, data.odds, operatorId);
await this.updateOdds(selectionId, data.odds, operatorId);
}
return this.prisma.marketSelection.update({
where: { id: selectionId },
data: {
...(data.selectionName !== undefined ? { selectionName: data.selectionName.trim() } : {}),
...(data.nameI18n !== undefined ? { nameI18n: this.jsonOrNull(sanitizeLocalizedText(data.nameI18n)) } : {}),
...(data.status !== undefined ? { status: data.status } : {}),
},
});
}
private async ensureDefaultTemplate() {
const existing = await this.prisma.marketTemplate.findFirst({
where: { sportType: 'FOOTBALL', isDefault: true, status: 'ACTIVE' },
});
if (existing) return existing;
const template = await this.prisma.marketTemplate.create({
data: {
sportType: 'FOOTBALL',
name: '默认足球盘口模板',
nameI18n: this.jsonOrNull({
'zh-CN': '默认足球盘口模板',
'en-US': 'Default Football Market Template',
'ms-MY': 'Templat Pasaran Bola Sepak Lalai',
}),
isDefault: true,
status: 'ACTIVE',
sortOrder: 0,
},
});
await this.replaceTemplateItems(
template.id,
DEFAULT_MARKET_TYPES.map((marketType, i) => {
const config = this.getMarketConfig(marketType);
return this.defaultDraftFromConfig(marketType, config.defaultLineValue, i);
}),
);
return template;
}
private async getDefaultTemplateRecord() {
await this.ensureDefaultTemplate();
return this.prisma.marketTemplate.findFirst({
where: { sportType: 'FOOTBALL', isDefault: true, status: 'ACTIVE' },
include: { items: { include: { selections: true }, orderBy: { sortOrder: 'asc' } } },
});
}
private async replaceTemplateItems(templateId: bigint, items: MarketDraft[]) {
await this.prisma.marketTemplateItem.deleteMany({ where: { templateId } });
for (const [index, item] of items.entries()) {
const normalized = this.normalizeDraft(item, index);
await this.prisma.marketTemplateItem.create({
data: {
templateId,
marketType: normalized.marketType,
marketKey: normalized.marketKey,
lineKey: normalized.lineKey,
period: normalized.period,
lineValue: normalized.lineValue,
paramsJson: this.jsonValueOrNull(normalized.paramsJson),
status: normalized.status,
allowSingle: normalized.allowSingle,
allowParlay: normalized.allowParlay,
showOnPlayer: normalized.showOnPlayer,
sortOrder: normalized.sortOrder,
promoLabel: normalized.promoLabel,
promoLabelI18n: this.jsonOrNull(normalized.promoLabelI18n),
nameI18n: this.jsonOrNull(normalized.nameI18n),
selections: {
create: normalized.selections.map((s) => ({
selectionCode: s.selectionCode,
selectionName: s.selectionName,
nameI18n: this.jsonOrNull(s.nameI18n),
odds: s.odds,
status: s.status,
sortOrder: s.sortOrder,
})),
},
},
});
}
}
private async upsertMatchMarket(
matchId: bigint,
draft: MarketDraft,
operatorId?: bigint,
templateItemId?: bigint,
) {
const normalized = this.normalizeDraft(draft, draft.sortOrder ?? 0);
const existing = draft.id
? await this.prisma.market.findFirst({ where: { id: BigInt(draft.id), matchId }, include: { selections: true } })
: await this.prisma.market.findFirst({ where: { matchId, lineKey: normalized.lineKey }, include: { selections: true } });
const showOnPlayer = normalized.showOnPlayer && isSettlementSupportedMarketType(normalized.marketType);
const data = {
marketType: normalized.marketType,
marketKey: normalized.marketKey,
lineKey: normalized.lineKey,
period: normalized.period,
lineValue: normalized.lineValue,
paramsJson: this.jsonValueOrNull(normalized.paramsJson),
status: normalized.status,
allowSingle: normalized.allowSingle,
allowParlay: normalized.allowParlay,
showOnPlayer,
sortOrder: normalized.sortOrder,
promoLabel: normalized.promoLabel,
promoLabelI18n: this.jsonOrNull(normalized.promoLabelI18n),
nameI18n: this.jsonOrNull(normalized.nameI18n),
templateItemId: templateItemId ?? undefined,
};
const market = existing
? await this.prisma.market.update({ where: { id: existing.id }, data })
: await this.prisma.market.create({ data: { matchId, ...data } });
await this.syncMarketSelections(market.id, normalized.selections, operatorId);
return this.prisma.market.findUnique({
where: { id: market.id },
include: { selections: { orderBy: { sortOrder: 'asc' } } },
});
}
private async syncMarketSelections(
marketId: bigint,
selections: Array<SelectionDraft & { selectionName: string; nameI18n: LocalizedText; status: string; sortOrder: number }>,
operatorId?: bigint,
) {
const existing = await this.prisma.marketSelection.findMany({ where: { marketId } });
const seen = new Set<string>();
for (const sel of selections) {
if (!sel.odds || sel.odds <= 1) throw appBadRequest('ODDS_MIN');
const selectionId = sel.id;
const current = selectionId
? existing.find((s) => s.id === BigInt(selectionId))
: existing.find((s) => s.selectionCode === sel.selectionCode);
if (current) {
seen.add(current.id.toString());
const oddsChanged = Number(current.odds) !== Number(sel.odds);
const newVersion = oddsChanged ? current.oddsVersion + BigInt(1) : current.oddsVersion;
if (oddsChanged) {
await this.prisma.oddsChangeLog.create({
data: {
selectionId: current.id,
oldOdds: current.odds,
newOdds: sel.odds,
oddsVersion: newVersion,
changedBy: operatorId,
},
});
}
await this.prisma.marketSelection.update({
where: { id: current.id },
data: {
selectionCode: sel.selectionCode,
selectionName: sel.selectionName,
nameI18n: this.jsonOrNull(sel.nameI18n),
odds: sel.odds,
oddsVersion: newVersion,
status: sel.status,
sortOrder: sel.sortOrder,
},
});
} else {
const created = await this.prisma.marketSelection.create({
data: {
marketId,
selectionCode: sel.selectionCode,
selectionName: sel.selectionName,
nameI18n: this.jsonOrNull(sel.nameI18n),
odds: sel.odds,
status: sel.status,
sortOrder: sel.sortOrder,
},
});
seen.add(created.id.toString());
}
}
const toClose = existing.filter((s) => !seen.has(s.id.toString())).map((s) => s.id);
if (toClose.length) {
await this.prisma.marketSelection.updateMany({
where: { id: { in: toClose } },
data: { status: 'CLOSED' },
});
}
}
private normalizeDraft(item: MarketDraft, fallbackOrder: number) {
const config = this.getMarketConfig(item.marketType);
const lineValue = item.lineValue !== undefined ? item.lineValue : config.defaultLineValue;
this.assertLineValueAllowed(item.marketType, lineValue);
const paramsJson = item.paramsJson ?? config.defaultParams ?? null;
const lineKey = item.lineKey?.trim() || buildMarketLineKey(item.marketType, lineValue, paramsJson);
const nameI18n = {
...config.nameI18n,
...sanitizeLocalizedText(item.nameI18n),
};
const promoLabelI18n = sanitizeLocalizedText(item.promoLabelI18n);
const sourceSelections = item.selections?.length
? item.selections
: config.selectionTemplate.map((s, index) => ({
selectionCode: s.code,
selectionName: s.name,
nameI18n: s.nameI18n,
odds: s.odds,
status: 'OPEN',
sortOrder: index,
}));
return {
marketType: item.marketType,
marketKey: item.marketKey?.trim() || config.marketKey,
lineKey,
period: item.period || config.period,
lineValue,
paramsJson,
status: item.status || 'OPEN',
allowSingle: item.allowSingle ?? config.allowSingle,
allowParlay: item.allowParlay ?? config.allowParlay,
showOnPlayer: item.showOnPlayer ?? config.showOnPlayer,
sortOrder: item.sortOrder ?? config.sortOrder ?? fallbackOrder,
promoLabel: item.promoLabel?.trim() || null,
promoLabelI18n,
nameI18n,
selections: sourceSelections.map((s, index) => {
const defaultName = defaultSelectionName(item.marketType, s.selectionCode, 'zh-CN');
const rawNameI18n = sanitizeLocalizedText(s.nameI18n);
const nameI18n = Object.keys(rawNameI18n).length
? rawNameI18n
: { 'zh-CN': s.selectionName?.trim() || defaultName };
return {
id: 'id' in s ? s.id : undefined,
selectionCode: s.selectionCode,
selectionName: s.selectionName?.trim() || resolveMarketText(nameI18n, 'zh-CN', defaultName),
nameI18n,
odds: Number(s.odds),
status: s.status || 'OPEN',
sortOrder: s.sortOrder ?? index,
};
}),
};
}
private defaultDraftFromConfig(marketType: string, lineValue: number | null, index: number): MarketDraft {
const config = this.getMarketConfig(marketType);
this.assertLineValueAllowed(marketType, lineValue);
return {
marketType,
marketKey: config.marketKey,
lineKey: buildMarketLineKey(marketType, lineValue, null),
period: config.period,
lineValue,
status: 'OPEN',
allowSingle: config.allowSingle,
allowParlay: config.allowParlay,
showOnPlayer: config.showOnPlayer,
sortOrder: config.sortOrder ?? index,
nameI18n: config.nameI18n,
selections: config.selectionTemplate.map((s, i) => ({
selectionCode: s.code,
selectionName: s.name,
nameI18n: s.nameI18n,
odds: s.odds,
status: 'OPEN',
sortOrder: i,
})),
};
}
private mapTemplate(template: Prisma.MarketTemplateGetPayload<{ include: { items: { include: { selections: true } } } }>) {
return {
id: template.id.toString(),
sportType: template.sportType,
name: template.name,
nameI18n: this.asLocalizedText(template.nameI18n),
description: template.description,
isDefault: template.isDefault,
status: template.status,
sortOrder: template.sortOrder,
items: template.items
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((item) => ({
id: item.id.toString(),
marketType: item.marketType,
marketKey: item.marketKey,
lineKey: item.lineKey,
period: item.period,
lineValue: item.lineValue == null ? null : Number(item.lineValue),
paramsJson: item.paramsJson,
status: item.status,
allowSingle: item.allowSingle,
allowParlay: item.allowParlay,
showOnPlayer: item.showOnPlayer,
sortOrder: item.sortOrder,
promoLabel: item.promoLabel ?? '',
promoLabelI18n: this.asLocalizedText(item.promoLabelI18n),
nameI18n: this.asLocalizedText(item.nameI18n),
selections: item.selections
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((s) => ({
id: s.id.toString(),
selectionCode: s.selectionCode,
selectionName: s.selectionName,
nameI18n: this.asLocalizedText(s.nameI18n),
odds: Number(s.odds),
status: s.status,
sortOrder: s.sortOrder,
})),
})),
};
}
private getMarketConfig(marketType: string) {
try {
return buildMarketTemplate(marketType);
} catch {
throw appBadRequest('UNKNOWN_MARKET_TYPE', { marketType });
}
}
private assertLineValueAllowed(marketType: string, lineValue: number | null | undefined) {
if (marketUsesLineValue(marketType)) {
if (typeof lineValue !== 'number' || !Number.isFinite(lineValue)) {
throw appBadRequest('MARKET_LINE_REQUIRED');
}
return;
}
if (lineValue != null) {
throw appBadRequest('MARKET_LINE_NOT_ALLOWED');
}
}
private jsonOrNull(value: LocalizedText | Record<string, unknown> | null | undefined) {
if (!value || !Object.keys(value).length) return Prisma.JsonNull;
return value as Prisma.InputJsonValue;
}
private jsonValueOrNull(value: Record<string, unknown> | null | undefined) {
if (value == null) return Prisma.JsonNull;
return value as Prisma.InputJsonValue;
}
private asLocalizedText(value: unknown): LocalizedText {
return sanitizeLocalizedText(value);
}
}

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../../shared/prisma/prisma.service';
import { WalletService } from '../../ledger/wallet.service';
import { FundsPostingService } from '../../ledger/funds-posting.service';
import { SystemConfigService } from '../../../shared/config/system-config.service';
import { Decimal } from '@prisma/client/runtime/library';
import { generateBatchNo } from '../../../shared/common/decorators';
@@ -33,7 +33,7 @@ type BetCashbackLine = {
export class CashbackService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
private funds: FundsPostingService,
private systemConfig: SystemConfigService,
) {}
@@ -498,29 +498,33 @@ export class CashbackService {
}
}
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,
'CASHBACK_DEPOSIT',
);
await this.prisma.$transaction(async (tx) => {
for (const item of batch.items) {
if (item.amount.gt(0)) {
await this.funds.deposit({
userId: item.userId,
amount: item.amount,
operatorId,
remark: `Cashback batch ${batch.batchNo}`,
referenceId: batch.batchNo,
transactionType: 'CASHBACK_DEPOSIT',
tx,
businessKey: `cashback:${batch.batchNo}:${item.userId}`,
});
}
}
}
if (betIds.length > 0) {
await this.prisma.bet.updateMany({
where: { id: { in: betIds } },
data: { isCashbacked: true },
if (betIds.length > 0) {
await tx.bet.updateMany({
where: { id: { in: betIds } },
data: { isCashbacked: true },
});
}
await tx.cashbackBatch.update({
where: { id: batchId },
data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId },
});
}
await this.prisma.cashbackBatch.update({
where: { id: batchId },
data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId },
});
return { success: true };

View File

@@ -1,7 +1,6 @@
import { Decimal } from '@prisma/client/runtime/library';
import {
canSelectForParlay,
hasDuplicateParlayMatch,
isQuarterHandicapOrTotal,
PARLAY_MAX_LEGS,
PARLAY_MIN_LEGS,
@@ -460,13 +459,13 @@ export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [
{
id: 'B007',
suite: 'betting',
name: '同场串关应拒绝',
name: '同场串关允许',
uatRef: 'B007',
run: () => {
expectTrue('same match blocked', hasDuplicateParlayMatch(['1', '1']), { matchIds: ['1', '1'] });
expectFalse('different matches ok', hasDuplicateParlayMatch(['1', '2', '3']), {
matchIds: ['1', '2', '3'],
});
const first = canSelectForParlay({ marketType: 'FT_1X2', allowParlay: true });
const second = canSelectForParlay({ marketType: 'FT_1X2', allowParlay: true });
expectTrue('first same-match leg ok', first.ok, { matchId: '1' });
expectTrue('second same-match leg ok', second.ok, { matchId: '1' });
},
},
{

View File

@@ -169,6 +169,97 @@ describe('SettlementCalculator', () => {
});
});
describe('Additional score-derived markets', () => {
it('settles home and away team total goals', () => {
const s = { htHome: 1, htAway: 0, ftHome: 2, ftAway: 1 };
expect(
settleSelection({
marketType: 'FT_TEAM_TOTAL_HOME',
selectionCode: 'OVER',
totalLine: 1.5,
score: s,
}),
).toBe('WIN');
expect(
settleSelection({
marketType: 'FT_TEAM_TOTAL_AWAY',
selectionCode: 'UNDER',
totalLine: 1.5,
score: s,
}),
).toBe('WIN');
});
it('settles half time / full time combinations', () => {
expect(
settleSelection({
marketType: 'HT_FT',
selectionCode: 'HOME_HOME',
score,
}),
).toBe('WIN');
expect(
settleSelection({
marketType: 'HT_FT',
selectionCode: 'DRAW_HOME',
score,
}),
).toBe('LOSE');
});
it('settles total goals ranges', () => {
expect(
settleSelection({
marketType: 'FT_TOTAL_GOALS',
selectionCode: 'TG_2_3',
score,
}),
).toBe('WIN');
expect(
settleSelection({
marketType: 'FT_TOTAL_GOALS',
selectionCode: 'TG_7_PLUS',
score,
}),
).toBe('LOSE');
});
it('settles corners handicap and totals from match stats', () => {
const s = { htHome: 0, htAway: 0, ftHome: 1, ftAway: 1 };
const stats = { homeCorners: 7, awayCorners: 4 };
expect(
settleSelection({
marketType: 'FT_CORNERS_HANDICAP',
selectionCode: 'HOME',
handicapLine: -2.5,
score: s,
stats,
}),
).toBe('WIN');
expect(
settleSelection({
marketType: 'FT_CORNERS_OVER_UNDER',
selectionCode: 'OVER',
totalLine: 10.5,
score: s,
stats,
}),
).toBe('WIN');
});
it('settles cards total from match stats', () => {
expect(
settleSelection({
marketType: 'FT_CARDS_OVER_UNDER',
selectionCode: 'UNDER',
totalLine: 5.5,
score,
stats: { homeCards: 2, awayCards: 3 },
}),
).toBe('WIN');
});
});
describe('Parlay', () => {
it('S016: all win', () => {
const result = calculateParlayPayout(100, [

View File

@@ -9,12 +9,24 @@ export interface ScoreInput {
ftAway: number;
}
export interface MatchStatsInput {
homeCorners?: number | null;
awayCorners?: number | null;
homeYellowCards?: number | null;
awayYellowCards?: number | null;
homeRedCards?: number | null;
awayRedCards?: number | null;
homeCards?: number | null;
awayCards?: number | null;
}
export interface SettlementInput {
marketType: string;
selectionCode: string;
handicapLine?: number | null;
totalLine?: number | null;
score: ScoreInput;
stats?: MatchStatsInput | null;
templateScores?: string[];
/** 冠军盘:获胜球队 code如 FRA、BRA */
winnerTeamCode?: string | null;
@@ -143,8 +155,41 @@ function settleCorrectScore(
return 'LOSE';
}
function resultSide(home: number, away: number): 'HOME' | 'DRAW' | 'AWAY' {
if (home > away) return 'HOME';
if (home < away) return 'AWAY';
return 'DRAW';
}
function settleHtFt(score: ScoreInput, selectionCode: string): SelectionResult {
const expected = `${resultSide(score.htHome, score.htAway)}_${resultSide(score.ftHome, score.ftAway)}`;
return selectionCode === expected ? 'WIN' : 'LOSE';
}
function settleTotalGoalsRange(totalGoals: number, selectionCode: string): SelectionResult {
if (selectionCode === 'TG_0_1') return totalGoals <= 1 ? 'WIN' : 'LOSE';
if (selectionCode === 'TG_2_3') return totalGoals >= 2 && totalGoals <= 3 ? 'WIN' : 'LOSE';
if (selectionCode === 'TG_4_6') return totalGoals >= 4 && totalGoals <= 6 ? 'WIN' : 'LOSE';
if (selectionCode === 'TG_7_PLUS') return totalGoals >= 7 ? 'WIN' : 'LOSE';
const exact = selectionCode.match(/^GOALS_(\d+)$/);
if (exact) return totalGoals === Number(exact[1]) ? 'WIN' : 'LOSE';
const exactPlus = selectionCode.match(/^GOALS_(\d+)_PLUS$/);
if (exactPlus) return totalGoals >= Number(exactPlus[1]) ? 'WIN' : 'LOSE';
return 'LOSE';
}
function requireStat(value: number | null | undefined, field: keyof MatchStatsInput): number {
if (value == null || Number.isNaN(value)) {
throw new Error(`SETTLEMENT_STAT_MISSING:${field}`);
}
return value;
}
export function settleSelection(input: SettlementInput): SelectionResult {
const { marketType, selectionCode, handicapLine, totalLine, score } = input;
const stats = input.stats ?? {};
const templates = input.templateScores ?? [];
switch (marketType) {
@@ -189,6 +234,35 @@ export function settleSelection(input: SettlementInput): SelectionResult {
const total = score.htHome + score.htAway;
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
}
case 'FT_TEAM_TOTAL_HOME':
return settleOverUnder(score.ftHome, totalLine ?? 0, selectionCode === 'OVER');
case 'FT_TEAM_TOTAL_AWAY':
return settleOverUnder(score.ftAway, totalLine ?? 0, selectionCode === 'OVER');
case 'HT_FT':
return settleHtFt(score, selectionCode);
case 'FT_TOTAL_GOALS':
return settleTotalGoalsRange(score.ftHome + score.ftAway, selectionCode);
case 'FT_CORNERS_HANDICAP': {
const homeCorners = requireStat(stats.homeCorners, 'homeCorners');
const awayCorners = requireStat(stats.awayCorners, 'awayCorners');
const line = handicapLine ?? 0;
const isHome = selectionCode === 'HOME';
const corners = isHome ? homeCorners : awayCorners;
const opp = isHome ? awayCorners : homeCorners;
return settleHandicap(corners, opp, isHome ? line : -line, isHome);
}
case 'FT_CORNERS_OVER_UNDER': {
const total =
requireStat(stats.homeCorners, 'homeCorners') +
requireStat(stats.awayCorners, 'awayCorners');
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
}
case 'FT_CARDS_OVER_UNDER': {
const total =
requireStat(stats.homeCards, 'homeCards') +
requireStat(stats.awayCards, 'awayCards');
return settleOverUnder(total, totalLine ?? 0, selectionCode === 'OVER');
}
case 'FT_CORRECT_SCORE':
return settleCorrectScore(score.ftHome, score.ftAway, selectionCode, templates);
case 'HT_CORRECT_SCORE':
@@ -262,18 +336,8 @@ export function calculateParlayPayout(
return { betResult: 'WON', payout: s.mul(combinedOdds), effectiveOdds: combinedOdds };
}
export { isQuarterHandicapOrTotal } from '@thebet365/shared';
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',
];
export {
FT_CORRECT_SCORE_TEMPLATE,
HT_CORRECT_SCORE_TEMPLATE,
isQuarterHandicapOrTotal,
} from '@thebet365/shared';

View File

@@ -1,7 +1,7 @@
import {
FT_CORRECT_SCORE_TEMPLATE,
HT_CORRECT_SCORE_TEMPLATE,
} from './settlement-calculator';
} from '@thebet365/shared';
const SNAPSHOT_1X2: Record<string, string> = {
: 'HOME',
@@ -31,7 +31,7 @@ export function resolveSelectionCode(
}
export function templateScoresForMarket(marketType: string): string[] {
if (marketType === 'FT_CORRECT_SCORE') return FT_CORRECT_SCORE_TEMPLATE;
if (marketType.includes('CORRECT_SCORE')) return HT_CORRECT_SCORE_TEMPLATE;
if (marketType === 'FT_CORRECT_SCORE') return [...FT_CORRECT_SCORE_TEMPLATE];
if (marketType.includes('CORRECT_SCORE')) return [...HT_CORRECT_SCORE_TEMPLATE];
return [];
}

View File

@@ -74,6 +74,7 @@ describe('SettlementService outright winner flow', () => {
let matchFindFirst: jest.Mock;
let matchUpdate: jest.Mock;
let teamFindUnique: jest.Mock;
let marketFindMany: jest.Mock;
let marketSelectionFindFirst: jest.Mock;
let marketSelectionFindMany: jest.Mock;
let matchScoreFindUnique: jest.Mock;
@@ -90,6 +91,7 @@ describe('SettlementService outright winner flow', () => {
matchFindFirst = jest.fn().mockResolvedValue(outrightMatch);
matchUpdate = jest.fn().mockResolvedValue({});
teamFindUnique = jest.fn().mockResolvedValue(winnerTeam);
marketFindMany = jest.fn().mockResolvedValue([]);
marketSelectionFindFirst = jest
.fn()
.mockResolvedValue({ id: winningSelId, selectionCode: 'BRA' });
@@ -111,18 +113,30 @@ describe('SettlementService outright winner flow', () => {
betFindMany = jest.fn();
transaction = jest.fn(async (fn: (client: unknown) => Promise<void>) =>
fn({
matchScore: { upsert: matchScoreUpsert },
bet: { update: jest.fn().mockResolvedValue({}) },
team: { findUnique: teamFindUnique },
market: { findMany: marketFindMany },
marketSelection: { findMany: marketSelectionFindMany },
matchScore: { upsert: matchScoreUpsert, findUnique: matchScoreFindUnique },
bet: {
findMany: betFindMany,
update: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
betSelection: { update: jest.fn().mockResolvedValue({}) },
settlementItem: { create: jest.fn().mockResolvedValue({}) },
settlementBatch: { update: jest.fn().mockResolvedValue({}) },
settlementBatch: {
findUnique: settlementBatchFindUnique,
update: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
match: { update: jest.fn().mockResolvedValue({}) },
}),
);
const prisma = {
match: { findFirst: matchFindFirst, update: matchUpdate },
match: { count: jest.fn().mockResolvedValue(0), findFirst: matchFindFirst, update: matchUpdate },
team: { findUnique: teamFindUnique },
market: { findMany: marketFindMany },
marketSelection: {
findFirst: marketSelectionFindFirst,
findMany: marketSelectionFindMany,
@@ -169,20 +183,233 @@ describe('SettlementService outright winner flow', () => {
);
});
it('previewSettlement requires stats for visible stats markets', async () => {
matchFindFirst.mockResolvedValue({
id: matchId,
isOutright: false,
status: 'CLOSED',
deletedAt: null,
});
marketFindMany.mockResolvedValue([{ marketType: 'FT_CORNERS_OVER_UNDER' }]);
betFindMany.mockResolvedValue([]);
try {
await service.previewSettlement(matchId, operatorId, {
htHome: 0,
htAway: 0,
ftHome: 1,
ftAway: 1,
});
throw new Error('Expected previewSettlement to reject');
} catch (err) {
const response = (err as { getResponse?: () => unknown }).getResponse?.();
expect(response).toEqual(
expect.objectContaining({
code: 'SETTLEMENT_FACTS_REQUIRED',
params: expect.objectContaining({ fields: 'homeCorners,awayCorners' }),
}),
);
}
});
it('derives total cards from yellow and red cards for settlement previews', async () => {
matchFindFirst.mockResolvedValue({
id: matchId,
isOutright: false,
status: 'CLOSED',
deletedAt: null,
});
marketFindMany.mockResolvedValue([{ marketType: 'FT_CARDS_OVER_UNDER' }]);
betFindMany.mockResolvedValue([]);
await service.previewSettlement(matchId, operatorId, {
htHome: 0,
htAway: 0,
ftHome: 2,
ftAway: 1,
homeYellowCards: 2,
homeRedCards: 1,
awayYellowCards: 4,
awayRedCards: 0,
});
expect(settlementBatchCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
homeYellowCards: 2,
homeRedCards: 1,
awayYellowCards: 4,
awayRedCards: 0,
homeCards: 3,
awayCards: 4,
}),
}),
);
});
it('keeps a parlay pending until every match in the ticket is settled', async () => {
matchFindFirst.mockResolvedValue({
id: matchId,
isOutright: false,
status: 'CLOSED',
deletedAt: null,
});
marketSelectionFindMany.mockResolvedValue([
{ id: BigInt(501), selectionCode: 'HOME' },
{ id: BigInt(502), selectionCode: 'AWAY' },
]);
betFindMany.mockResolvedValue([
{
id: BigInt(2001),
betNo: 'PARLAY-PENDING',
betType: 'PARLAY',
status: 'PENDING',
stake: new Decimal(100),
agentId: null,
userId: BigInt(60),
user: { id: BigInt(60) },
selections: [
{
id: BigInt(3001),
matchId,
marketType: 'FT_1X2',
selectionId: BigInt(501),
selectionNameSnapshot: 'Home',
handicapLine: null,
totalLine: null,
odds: new Decimal(2),
resultStatus: null,
sortOrder: 0,
},
{
id: BigInt(3002),
matchId: BigInt(101),
marketType: 'FT_1X2',
selectionId: BigInt(502),
selectionNameSnapshot: 'Away',
handicapLine: null,
totalLine: null,
odds: new Decimal(2),
resultStatus: null,
sortOrder: 1,
},
],
},
]);
const preview = await service.previewSettlement(matchId, operatorId, {
htHome: 0,
htAway: 0,
ftHome: 0,
ftAway: 1,
});
expect(preview.pendingOtherMatches).toBe(1);
expect(preview.lostOnThisMatch).toBe(0);
expect(preview.items.items).toEqual([
expect.objectContaining({
betNo: 'PARLAY-PENDING',
result: 'PENDING_OTHER_MATCHES',
payout: '0',
note: '本场腿已出结果,待其他场次结算后统一结算',
}),
]);
});
it('previews a parlay result only after the other legs already have results', async () => {
matchFindFirst.mockResolvedValue({
id: matchId,
isOutright: false,
status: 'CLOSED',
deletedAt: null,
});
marketSelectionFindMany.mockResolvedValue([
{ id: BigInt(501), selectionCode: 'HOME' },
{ id: BigInt(502), selectionCode: 'AWAY' },
]);
betFindMany.mockResolvedValue([
{
id: BigInt(2002),
betNo: 'PARLAY-READY',
betType: 'PARLAY',
status: 'PENDING',
stake: new Decimal(100),
agentId: null,
userId: BigInt(61),
user: { id: BigInt(61) },
selections: [
{
id: BigInt(3003),
matchId,
marketType: 'FT_1X2',
selectionId: BigInt(501),
selectionNameSnapshot: 'Home',
handicapLine: null,
totalLine: null,
odds: new Decimal(2),
resultStatus: null,
sortOrder: 0,
},
{
id: BigInt(3004),
matchId: BigInt(101),
marketType: 'FT_1X2',
selectionId: BigInt(502),
selectionNameSnapshot: 'Away',
handicapLine: null,
totalLine: null,
odds: new Decimal(2),
resultStatus: 'WIN',
sortOrder: 1,
},
],
},
]);
const preview = await service.previewSettlement(matchId, operatorId, {
htHome: 0,
htAway: 0,
ftHome: 0,
ftAway: 1,
});
expect(preview.pendingOtherMatches).toBe(0);
expect(preview.items.items).toEqual([
expect.objectContaining({
betNo: 'PARLAY-READY',
result: 'LOST',
payout: '0',
}),
]);
});
it('confirmSettlement settles outright bets as WON/LOST using stored winnerTeamId', async () => {
const txBetUpdate = jest.fn().mockResolvedValue({});
const txBetUpdateMany = jest.fn().mockResolvedValue({ count: 1 });
transaction.mockImplementation(async (fn: (client: unknown) => Promise<void>) => {
await fn({
matchScore: { upsert: matchScoreUpsert },
bet: { update: txBetUpdate },
team: { findUnique: teamFindUnique },
market: { findMany: marketFindMany },
marketSelection: { findMany: marketSelectionFindMany },
matchScore: { upsert: matchScoreUpsert, findUnique: matchScoreFindUnique },
bet: {
findMany: betFindMany,
update: txBetUpdate,
updateMany: txBetUpdateMany,
},
betSelection: { update: jest.fn().mockResolvedValue({}) },
settlementItem: { create: jest.fn().mockResolvedValue({}) },
settlementBatch: { update: jest.fn().mockResolvedValue({}) },
settlementBatch: {
findUnique: settlementBatchFindUnique,
update: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
match: { update: jest.fn().mockResolvedValue({}) },
});
});
settlementBatchFindUnique.mockResolvedValue({
id: batchId,
batchNo: 'SETTLE-001',
matchId,
status: 'PREVIEW',
htHomeScore: 0,
@@ -209,31 +436,31 @@ describe('SettlementService outright winner flow', () => {
}),
);
expect(wallet.settleBet).toHaveBeenCalledTimes(2);
expect(wallet.settleBet.mock.calls[0]).toEqual([
winningBet.userId,
expect.anything(),
expect.anything(),
'BET-WIN',
'WIN',
expect.anything(),
]);
expect(wallet.settleBet.mock.calls[1]).toEqual([
losingBet.userId,
expect.anything(),
expect.anything(),
'BET-LOSE',
'LOSE',
expect.anything(),
]);
expect(txBetUpdate).toHaveBeenCalledWith(
expect(wallet.settleBet.mock.calls[0][0]).toEqual(
expect.objectContaining({
where: { id: winningBetId },
userId: winningBet.userId,
betNo: 'BET-WIN',
batchNo: 'SETTLE-001',
result: 'WIN',
}),
);
expect(wallet.settleBet.mock.calls[1][0]).toEqual(
expect.objectContaining({
userId: losingBet.userId,
betNo: 'BET-LOSE',
batchNo: 'SETTLE-001',
result: 'LOSE',
}),
);
expect(txBetUpdateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: winningBetId, status: 'PENDING' },
data: expect.objectContaining({ status: 'WON' }),
}),
);
expect(txBetUpdate).toHaveBeenCalledWith(
expect(txBetUpdateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: losingBetId },
where: { id: losingBetId, status: 'PENDING' },
data: expect.objectContaining({ status: 'LOST' }),
}),
);

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { WalletService } from '../ledger/wallet.service';
import { FundsPostingService } from '../ledger/funds-posting.service';
import { AgentsService } from '../agent/agents.service';
import { Decimal } from '@prisma/client/runtime/library';
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
@@ -10,6 +11,7 @@ import {
calculatePayout,
calculateParlayPayout,
ScoreInput,
MatchStatsInput,
SelectionResult,
} from './domain/settlement-calculator';
import {
@@ -18,6 +20,15 @@ import {
} from './domain/settlement-helpers';
const SETTLEMENT_ENTRY_STATUSES = new Set(['CLOSED', 'PENDING_SETTLEMENT', 'SETTLED']);
const STAT_MARKET_REQUIREMENTS = {
FT_CORNERS_HANDICAP: ['homeCorners', 'awayCorners'],
FT_CORNERS_OVER_UNDER: ['homeCorners', 'awayCorners'],
FT_CARDS_OVER_UNDER: ['homeCards', 'awayCards'],
} as const satisfies Record<string, readonly (keyof MatchStatsInput)[]>;
type TxClient = Prisma.TransactionClient;
type PrismaClientLike = PrismaService | TxClient;
type SettlementScoreSource = ScoreInput & MatchStatsInput & { winnerTeamId?: bigint | null };
type BetSelectionLeg = {
id: bigint;
@@ -36,13 +47,17 @@ type BetSelectionLeg = {
export class SettlementService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
private funds: FundsPostingService,
private agents: AgentsService,
) {}
private async resolveWinnerTeamCode(winnerTeamId: bigint | null | undefined): Promise<string | null> {
private async resolveWinnerTeamCode(
winnerTeamId: bigint | null | undefined,
tx?: TxClient,
): Promise<string | null> {
if (!winnerTeamId) return null;
const team = await this.prisma.team.findUnique({ where: { id: winnerTeamId } });
const client: PrismaClientLike = tx ?? this.prisma;
const team = await client.team.findUnique({ where: { id: winnerTeamId } });
return team?.code ?? null;
}
@@ -68,6 +83,7 @@ export class SettlementService {
sel: BetSelectionLeg,
selectionCode: string,
scoreInput: ScoreInput,
statsInput: MatchStatsInput,
winnerTeamCode: string | null,
) {
return {
@@ -76,6 +92,7 @@ export class SettlementService {
handicapLine: sel.handicapLine != null ? Number(sel.handicapLine) : null,
totalLine: sel.totalLine != null ? Number(sel.totalLine) : null,
score: scoreInput,
stats: statsInput,
templateScores: templateScoresForMarket(sel.marketType),
winnerTeamCode,
};
@@ -85,9 +102,71 @@ export class SettlementService {
sel: BetSelectionLeg,
selectionCode: string,
scoreInput: ScoreInput,
statsInput: MatchStatsInput,
winnerTeamCode: string | null,
): SelectionResult {
return settleSelection(this.buildSettleInput(sel, selectionCode, scoreInput, winnerTeamCode));
return settleSelection(this.buildSettleInput(sel, selectionCode, scoreInput, statsInput, winnerTeamCode));
}
private cardTotalFromBreakdown(
total: number | null | undefined,
yellow: number | null | undefined,
red: number | null | undefined,
): number | null {
const hasBreakdown = yellow != null || red != null;
if (!hasBreakdown) return total ?? null;
if (yellow == null || red == null) return null;
return yellow + red;
}
private statsInputFromSource(source: MatchStatsInput): MatchStatsInput {
const homeYellowCards = source.homeYellowCards ?? null;
const awayYellowCards = source.awayYellowCards ?? null;
const homeRedCards = source.homeRedCards ?? null;
const awayRedCards = source.awayRedCards ?? null;
return {
homeCorners: source.homeCorners ?? null,
awayCorners: source.awayCorners ?? null,
homeYellowCards,
awayYellowCards,
homeRedCards,
awayRedCards,
homeCards: this.cardTotalFromBreakdown(source.homeCards, homeYellowCards, homeRedCards),
awayCards: this.cardTotalFromBreakdown(source.awayCards, awayYellowCards, awayRedCards),
};
}
private collectRequiredStatFields(marketTypes: Iterable<string>) {
const fields = new Set<keyof MatchStatsInput>();
for (const marketType of marketTypes) {
const required = STAT_MARKET_REQUIREMENTS[marketType as keyof typeof STAT_MARKET_REQUIREMENTS];
if (!required) continue;
for (const field of required) fields.add(field);
}
return fields;
}
private async assertRequiredStatsAvailable(
matchId: bigint,
statsInput: MatchStatsInput,
betMarketTypes: Iterable<string>,
client?: PrismaClientLike,
) {
const db = client ?? this.prisma;
const visibleMarkets = await db.market.findMany({
where: { matchId, showOnPlayer: true },
select: { marketType: true },
});
const required = this.collectRequiredStatFields([
...visibleMarkets.map((market) => market.marketType),
...betMarketTypes,
]);
const missing = [...required].filter((field) => statsInput[field] == null);
if (missing.length) {
throw appBadRequest('SETTLEMENT_FACTS_REQUIRED', {
fields: missing.join(','),
});
}
}
private walletResultFromSelection(
@@ -118,6 +197,7 @@ export class SettlementService {
ftAway: number,
operatorId: bigint,
winnerTeamId?: bigint,
statsInput: MatchStatsInput = {},
) {
const match = await this.prisma.match.findFirst({
where: { id: matchId, deletedAt: null },
@@ -146,6 +226,7 @@ export class SettlementService {
}
}
const stats = this.statsInputFromSource(statsInput);
await this.prisma.matchScore.upsert({
where: { matchId },
create: {
@@ -154,6 +235,14 @@ export class SettlementService {
htAwayScore: match.isOutright ? 0 : htAway,
ftHomeScore: match.isOutright ? 0 : ftHome,
ftAwayScore: match.isOutright ? 0 : ftAway,
homeCorners: match.isOutright ? null : stats.homeCorners,
awayCorners: match.isOutright ? null : stats.awayCorners,
homeYellowCards: match.isOutright ? null : stats.homeYellowCards,
awayYellowCards: match.isOutright ? null : stats.awayYellowCards,
homeRedCards: match.isOutright ? null : stats.homeRedCards,
awayRedCards: match.isOutright ? null : stats.awayRedCards,
homeCards: match.isOutright ? null : stats.homeCards,
awayCards: match.isOutright ? null : stats.awayCards,
winnerTeamId: match.isOutright ? winnerTeamId : null,
recordedBy: operatorId,
},
@@ -162,6 +251,14 @@ export class SettlementService {
htAwayScore: match.isOutright ? 0 : htAway,
ftHomeScore: match.isOutright ? 0 : ftHome,
ftAwayScore: match.isOutright ? 0 : ftAway,
homeCorners: match.isOutright ? null : stats.homeCorners,
awayCorners: match.isOutright ? null : stats.awayCorners,
homeYellowCards: match.isOutright ? null : stats.homeYellowCards,
awayYellowCards: match.isOutright ? null : stats.awayYellowCards,
homeRedCards: match.isOutright ? null : stats.homeRedCards,
awayRedCards: match.isOutright ? null : stats.awayRedCards,
homeCards: match.isOutright ? null : stats.homeCards,
awayCards: match.isOutright ? null : stats.awayCards,
winnerTeamId: match.isOutright ? winnerTeamId : null,
recordedBy: operatorId,
},
@@ -172,7 +269,15 @@ export class SettlementService {
data: { status: 'PENDING_SETTLEMENT' },
});
return { matchId, htHome, htAway, ftHome, ftAway, winnerTeamId: winnerTeamId?.toString() ?? null };
return {
matchId,
htHome,
htAway,
ftHome,
ftAway,
...stats,
winnerTeamId: winnerTeamId?.toString() ?? null,
};
}
async previewSettlement(
@@ -185,6 +290,14 @@ export class SettlementService {
htAway?: number;
ftHome?: number;
ftAway?: number;
homeCorners?: number | null;
awayCorners?: number | null;
homeYellowCards?: number | null;
awayYellowCards?: number | null;
homeRedCards?: number | null;
awayRedCards?: number | null;
homeCards?: number | null;
awayCards?: number | null;
winnerTeamId?: bigint;
},
) {
@@ -208,6 +321,14 @@ export class SettlementService {
htAwayScore: scoreSource.htAway,
ftHomeScore: scoreSource.ftHome,
ftAwayScore: scoreSource.ftAway,
homeCorners: scoreSource.homeCorners ?? null,
awayCorners: scoreSource.awayCorners ?? null,
homeYellowCards: scoreSource.homeYellowCards ?? null,
awayYellowCards: scoreSource.awayYellowCards ?? null,
homeRedCards: scoreSource.homeRedCards ?? null,
awayRedCards: scoreSource.awayRedCards ?? null,
homeCards: scoreSource.homeCards ?? null,
awayCards: scoreSource.awayCards ?? null,
status: 'PREVIEW',
totalBets: computation.pendingBets.length,
totalPayout: computation.totalPayout,
@@ -248,6 +369,14 @@ export class SettlementService {
htAway: batch.htAwayScore ?? 0,
ftHome: batch.ftHomeScore ?? 0,
ftAway: batch.ftAwayScore ?? 0,
homeCorners: batch.homeCorners,
awayCorners: batch.awayCorners,
homeYellowCards: batch.homeYellowCards,
awayYellowCards: batch.awayYellowCards,
homeRedCards: batch.homeRedCards,
awayRedCards: batch.awayRedCards,
homeCards: batch.homeCards,
awayCards: batch.awayCards,
winnerTeamId: existingScore?.winnerTeamId ?? null,
});
const itemsPage = this.paginatePreviewItems(computation.items, opts);
@@ -343,12 +472,21 @@ export class SettlementService {
htAway: number;
ftHome: number;
ftAway: number;
homeCorners?: number | null;
awayCorners?: number | null;
homeYellowCards?: number | null;
awayYellowCards?: number | null;
homeRedCards?: number | null;
awayRedCards?: number | null;
homeCards?: number | null;
awayCards?: number | null;
winnerTeamId?: bigint | null;
},
operatorId: bigint,
tx?: Parameters<Parameters<PrismaService['$transaction']>[0]>[0],
) {
const client = tx ?? this.prisma;
const stats = this.statsInputFromSource(scoreSource);
await client.matchScore.upsert({
where: { matchId },
create: {
@@ -357,6 +495,14 @@ export class SettlementService {
htAwayScore: scoreSource.htAway,
ftHomeScore: scoreSource.ftHome,
ftAwayScore: scoreSource.ftAway,
homeCorners: stats.homeCorners,
awayCorners: stats.awayCorners,
homeYellowCards: stats.homeYellowCards,
awayYellowCards: stats.awayYellowCards,
homeRedCards: stats.homeRedCards,
awayRedCards: stats.awayRedCards,
homeCards: stats.homeCards,
awayCards: stats.awayCards,
winnerTeamId: scoreSource.winnerTeamId ?? null,
recordedBy: operatorId,
},
@@ -365,6 +511,14 @@ export class SettlementService {
htAwayScore: scoreSource.htAway,
ftHomeScore: scoreSource.ftHome,
ftAwayScore: scoreSource.ftAway,
homeCorners: stats.homeCorners,
awayCorners: stats.awayCorners,
homeYellowCards: stats.homeYellowCards,
awayYellowCards: stats.awayYellowCards,
homeRedCards: stats.homeRedCards,
awayRedCards: stats.awayRedCards,
homeCards: stats.homeCards,
awayCards: stats.awayCards,
winnerTeamId: scoreSource.winnerTeamId ?? null,
recordedBy: operatorId,
},
@@ -379,6 +533,14 @@ export class SettlementService {
htAway?: number;
ftHome?: number;
ftAway?: number;
homeCorners?: number | null;
awayCorners?: number | null;
homeYellowCards?: number | null;
awayYellowCards?: number | null;
homeRedCards?: number | null;
awayRedCards?: number | null;
homeCards?: number | null;
awayCards?: number | null;
winnerTeamId?: bigint;
},
) {
@@ -387,6 +549,14 @@ export class SettlementService {
opts?.htAway !== undefined ||
opts?.ftHome !== undefined ||
opts?.ftAway !== undefined ||
opts?.homeCorners !== undefined ||
opts?.awayCorners !== undefined ||
opts?.homeYellowCards !== undefined ||
opts?.awayYellowCards !== undefined ||
opts?.homeRedCards !== undefined ||
opts?.awayRedCards !== undefined ||
opts?.homeCards !== undefined ||
opts?.awayCards !== undefined ||
opts?.winnerTeamId !== undefined;
if (hasRequestScore) {
@@ -402,11 +572,22 @@ export class SettlementService {
});
if (!outrightSel) throw appBadRequest('SETTLEMENT_WINNER_NOT_IN_MARKET');
}
const stats = this.statsInputFromSource({
homeCorners: opts?.homeCorners ?? null,
awayCorners: opts?.awayCorners ?? null,
homeYellowCards: opts?.homeYellowCards ?? null,
awayYellowCards: opts?.awayYellowCards ?? null,
homeRedCards: opts?.homeRedCards ?? null,
awayRedCards: opts?.awayRedCards ?? null,
homeCards: opts?.homeCards ?? null,
awayCards: opts?.awayCards ?? null,
});
return {
htHome: opts?.htHome ?? 0,
htAway: opts?.htAway ?? 0,
ftHome: opts?.ftHome ?? 0,
ftAway: opts?.ftAway ?? 0,
...stats,
winnerTeamId: isOutright ? (opts?.winnerTeamId ?? null) : null,
};
}
@@ -419,21 +600,24 @@ export class SettlementService {
htAway: score.htAwayScore ?? 0,
ftHome: score.ftHomeScore ?? 0,
ftAway: score.ftAwayScore ?? 0,
homeCorners: score.homeCorners,
awayCorners: score.awayCorners,
homeYellowCards: score.homeYellowCards,
awayYellowCards: score.awayYellowCards,
homeRedCards: score.homeRedCards,
awayRedCards: score.awayRedCards,
homeCards: score.homeCards,
awayCards: score.awayCards,
winnerTeamId: score.winnerTeamId,
};
}
private async computePreviewComputation(
matchId: bigint,
scoreSource?: {
htHome: number;
htAway: number;
ftHome: number;
ftAway: number;
winnerTeamId?: bigint | null;
},
scoreSource?: SettlementScoreSource,
) {
let scoreInput: ScoreInput;
let statsInput: MatchStatsInput;
let winnerTeamCode: string | null;
if (scoreSource) {
@@ -443,6 +627,7 @@ export class SettlementService {
ftHome: scoreSource.ftHome,
ftAway: scoreSource.ftAway,
};
statsInput = this.statsInputFromSource(scoreSource);
winnerTeamCode = await this.resolveWinnerTeamCode(scoreSource.winnerTeamId ?? null);
} else {
const score = await this.prisma.matchScore.findUnique({ where: { matchId } });
@@ -453,6 +638,7 @@ export class SettlementService {
ftHome: score.ftHomeScore ?? 0,
ftAway: score.ftAwayScore ?? 0,
};
statsInput = this.statsInputFromSource(score);
winnerTeamCode = await this.resolveWinnerTeamCode(score.winnerTeamId);
}
@@ -465,6 +651,11 @@ export class SettlementService {
});
const selectionCodes = await this.loadSelectionCodes(pendingBets);
await this.assertRequiredStatsAvailable(
matchId,
statsInput,
pendingBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
);
let totalPayout = new Decimal(0);
let totalRefund = new Decimal(0);
@@ -487,7 +678,7 @@ export class SettlementService {
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
const payout = calculatePayout(bet.stake, sel.odds, result);
if (result === 'WIN' || result === 'HALF_WIN') wonLegsOnMatch += 1;
items.push({ betId: bet.id, betNo: bet.betNo, betType: 'SINGLE', result, payout });
@@ -504,7 +695,7 @@ export class SettlementService {
);
return {
odds: sel.odds,
result: this.settleLegResult(sel, code, scoreInput, winnerTeamCode),
result: this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode),
};
});
const parlay = calculateParlayPayout(bet.stake, legResults);
@@ -528,7 +719,7 @@ export class SettlementService {
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const legResult = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
const legResult = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
if (legResult === 'WIN' || legResult === 'HALF_WIN') wonLegsOnMatch += 1;
}
@@ -536,6 +727,7 @@ export class SettlementService {
bet,
matchId,
scoreInput,
statsInput,
winnerTeamCode,
selectionCodes,
);
@@ -552,16 +744,6 @@ export class SettlementService {
} else if (preview.betResult === 'WON') {
totalPayout = totalPayout.add(preview.payout);
}
} else if (preview.kind === 'LOST_ON_THIS_MATCH') {
lostOnThisMatch += 1;
items.push({
betId: bet.id,
betNo: bet.betNo,
betType: 'PARLAY',
result: 'LOST',
payout: preview.payout,
note: '本场已有输腿,串关整单作废',
});
} else {
pendingOtherMatches += 1;
items.push({
@@ -570,7 +752,7 @@ export class SettlementService {
betType: 'PARLAY',
result: 'PENDING_OTHER_MATCHES',
payout: new Decimal(0),
note: '本场腿已出结果,待其他场次结算后派彩',
note: '本场腿已出结果,待其他场次结算后统一结算',
});
}
}
@@ -593,24 +775,12 @@ export class SettlementService {
bet: { stake: Decimal; selections: BetSelectionLeg[] },
matchId: bigint,
scoreInput: ScoreInput,
statsInput: MatchStatsInput,
winnerTeamCode: string | null,
selectionCodes: Map<string, string | null>,
):
| { kind: 'SETTLED'; betResult: 'WON' | 'LOST' | 'PUSH'; payout: Decimal }
| { kind: 'LOST_ON_THIS_MATCH'; payout: Decimal }
| { kind: 'PENDING_OTHER_MATCHES' } {
for (const sel of bet.selections) {
if (sel.matchId?.toString() !== matchId.toString()) continue;
const code = resolveSelectionCode(
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
if (result === 'LOSE' || result === 'HALF_LOSE') {
return { kind: 'LOST_ON_THIS_MATCH', payout: new Decimal(0) };
}
}
const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
for (const sel of bet.selections) {
if (sel.matchId?.toString() === matchId.toString()) {
@@ -620,7 +790,7 @@ export class SettlementService {
);
legResults.push({
odds: sel.odds,
result: this.settleLegResult(sel, code, scoreInput, winnerTeamCode),
result: this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode),
});
} else if (sel.resultStatus) {
legResults.push({
@@ -641,6 +811,7 @@ export class SettlementService {
private async loadSelectionCodes(
bets: Array<{ selections: Array<{ selectionId: bigint }> }>,
tx?: TxClient,
): Promise<Map<string, string | null>> {
const ids = new Set<bigint>();
for (const bet of bets) {
@@ -648,7 +819,8 @@ export class SettlementService {
}
if (!ids.size) return new Map();
const rows = await this.prisma.marketSelection.findMany({
const client: PrismaClientLike = tx ?? this.prisma;
const rows = await client.marketSelection.findMany({
where: { id: { in: [...ids] } },
select: { id: true, selectionCode: true },
});
@@ -670,36 +842,63 @@ export class SettlementService {
await this.assertOutrightLeagueFixturesSettled(batch.match);
}
const scoreInput: ScoreInput = {
htHome: batch.htHomeScore ?? 0,
htAway: batch.htAwayScore ?? 0,
ftHome: batch.ftHomeScore ?? 0,
ftAway: batch.ftAwayScore ?? 0,
};
const existingScore = await this.prisma.matchScore.findUnique({
where: { matchId: batch.matchId },
});
const winnerTeamCode = await this.resolveWinnerTeamCode(existingScore?.winnerTeamId ?? null);
const pendingBets = await this.prisma.bet.findMany({
where: {
status: 'PENDING',
selections: { some: { matchId: batch.matchId } },
},
include: { selections: { orderBy: { sortOrder: 'asc' } }, user: true },
});
const selectionCodes = await this.loadSelectionCodes(pendingBets);
const agentIds = new Set<bigint>();
await this.prisma.$transaction(async (tx) => {
const claimed = await tx.settlementBatch.updateMany({
where: { id: batchId, status: 'PREVIEW' },
data: { status: 'CONFIRMING', operatorId },
});
if (claimed.count !== 1) throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED');
const currentBatch = await tx.settlementBatch.findUnique({
where: { id: batchId },
include: { match: true },
});
if (!currentBatch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND');
if (currentBatch.match.status !== 'PENDING_SETTLEMENT') {
throw appBadRequest('MATCH_NOT_SETTLEABLE');
}
const scoreInput: ScoreInput = {
htHome: currentBatch.htHomeScore ?? 0,
htAway: currentBatch.htAwayScore ?? 0,
ftHome: currentBatch.ftHomeScore ?? 0,
ftAway: currentBatch.ftAwayScore ?? 0,
};
const statsInput = this.statsInputFromSource(currentBatch);
const existingScore = await tx.matchScore.findUnique({
where: { matchId: currentBatch.matchId },
});
const winnerTeamCode = await this.resolveWinnerTeamCode(
existingScore?.winnerTeamId ?? null,
tx,
);
const pendingBets = await tx.bet.findMany({
where: {
status: 'PENDING',
selections: { some: { matchId: currentBatch.matchId } },
},
include: { selections: { orderBy: { sortOrder: 'asc' } }, user: true },
});
const selectionCodes = await this.loadSelectionCodes(pendingBets, tx);
await this.assertRequiredStatsAvailable(
currentBatch.matchId,
statsInput,
pendingBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
tx,
);
let settledCount = 0;
await this.upsertMatchScoreRecord(
batch.matchId,
currentBatch.matchId,
{
htHome: scoreInput.htHome,
htAway: scoreInput.htAway,
ftHome: scoreInput.ftHome,
ftAway: scoreInput.ftAway,
...statsInput,
winnerTeamId: existingScore?.winnerTeamId ?? null,
},
operatorId,
@@ -713,34 +912,37 @@ export class SettlementService {
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
const payout = calculatePayout(bet.stake, sel.odds, result);
const betStatus = this.betStatusFromSelection(result);
await tx.bet.update({
where: { id: bet.id },
const updatedBet = await tx.bet.updateMany({
where: { id: bet.id, status: 'PENDING' },
data: {
status: betStatus,
actualReturn: payout,
settledAt: new Date(),
},
});
if (updatedBet.count !== 1) continue;
await tx.betSelection.update({
where: { id: sel.id },
data: { resultStatus: result, effectiveOdds: sel.odds },
});
await this.wallet.settleBet(
bet.userId,
bet.stake,
await this.funds.settleBet({
userId: bet.userId,
stake: bet.stake,
payout,
bet.betNo,
this.walletResultFromSelection(result),
betNo: bet.betNo,
batchNo: batch.batchNo,
result: this.walletResultFromSelection(result),
tx,
);
});
if (bet.agentId) agentIds.add(bet.agentId);
settledCount += 1;
await tx.settlementItem.create({
data: {
@@ -758,7 +960,7 @@ export class SettlementService {
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
legResults.push({ odds: sel.odds, result });
await tx.betSelection.update({
where: { id: sel.id },
@@ -773,29 +975,33 @@ export class SettlementService {
? 'PUSH'
: 'WON';
await tx.bet.update({
where: { id: bet.id },
const updatedBet = await tx.bet.updateMany({
where: { id: bet.id, status: 'PENDING' },
data: {
status: betStatus,
actualReturn: parlayResult.payout,
settledAt: new Date(),
},
});
if (updatedBet.count !== 1) continue;
await this.wallet.settleBet(
bet.userId,
bet.stake,
parlayResult.payout,
bet.betNo,
parlayResult.betResult === 'LOST'
? 'LOSE'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WIN',
await this.funds.settleBet({
userId: bet.userId,
stake: bet.stake,
payout: parlayResult.payout,
betNo: bet.betNo,
batchNo: batch.batchNo,
result:
parlayResult.betResult === 'LOST'
? 'LOSE'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WIN',
tx,
);
});
if (bet.agentId) agentIds.add(bet.agentId);
settledCount += 1;
await tx.settlementItem.create({
data: {
@@ -813,7 +1019,7 @@ export class SettlementService {
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
await tx.betSelection.update({
where: { id: sel.id },
data: { resultStatus: result },
@@ -840,29 +1046,33 @@ export class SettlementService {
? 'PUSH'
: 'WON';
await tx.bet.update({
where: { id: bet.id },
const updatedBet = await tx.bet.updateMany({
where: { id: bet.id, status: 'PENDING' },
data: {
status: betStatus,
actualReturn: parlayResult.payout,
settledAt: new Date(),
},
});
if (updatedBet.count !== 1) continue;
await this.wallet.settleBet(
bet.userId,
bet.stake,
parlayResult.payout,
bet.betNo,
parlayResult.betResult === 'LOST'
? 'LOSE'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WIN',
await this.funds.settleBet({
userId: bet.userId,
stake: bet.stake,
payout: parlayResult.payout,
betNo: bet.betNo,
batchNo: batch.batchNo,
result:
parlayResult.betResult === 'LOST'
? 'LOSE'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WIN',
tx,
);
});
if (bet.agentId) agentIds.add(bet.agentId);
settledCount += 1;
await tx.settlementItem.create({
data: {
@@ -879,11 +1089,16 @@ export class SettlementService {
await tx.settlementBatch.update({
where: { id: batchId },
data: { status: 'CONFIRMED', confirmedAt: new Date() },
data: {
status: 'CONFIRMED',
confirmedAt: new Date(),
operatorId,
totalBets: settledCount,
},
});
await tx.match.update({
where: { id: batch.matchId },
where: { id: currentBatch.matchId },
data: { status: 'SETTLED' },
});
});
@@ -1090,6 +1305,7 @@ export class SettlementService {
},
matchId: bigint,
scoreInput: ScoreInput,
statsInput: MatchStatsInput,
winnerTeamCode: string | null,
selectionCodes: Map<string, string | null>,
) {
@@ -1099,7 +1315,7 @@ export class SettlementService {
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
const payout = calculatePayout(bet.stake, sel.odds, result);
return {
payout,
@@ -1118,7 +1334,7 @@ export class SettlementService {
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
result = this.settleLegResult(sel, code, scoreInput, winnerTeamCode);
result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
legUpdates.set(sel.id.toString(), result);
} else {
if (!sel.resultStatus) {
@@ -1142,7 +1358,7 @@ export class SettlementService {
async previewResettlement(
matchId: bigint,
scoreInput: ScoreInput,
scoreInput: ScoreInput & MatchStatsInput,
operatorId: bigint,
reason?: string,
winnerTeamId?: bigint,
@@ -1158,6 +1374,7 @@ export class SettlementService {
const winnerTeamCode = winnerTeamId
? await this.resolveWinnerTeamCode(winnerTeamId)
: null;
const statsInput = this.statsInputFromSource(scoreInput);
const settledBets = await this.prisma.bet.findMany({
where: {
@@ -1168,6 +1385,11 @@ export class SettlementService {
});
const selectionCodes = await this.loadSelectionCodes(settledBets);
await this.assertRequiredStatsAvailable(
matchId,
statsInput,
settledBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
);
const items: Array<{
betId: bigint;
betNo: string;
@@ -1187,6 +1409,7 @@ export class SettlementService {
bet,
matchId,
scoreInput,
statsInput,
winnerTeamCode,
selectionCodes,
);
@@ -1215,6 +1438,14 @@ export class SettlementService {
htAwayScore: scoreInput.htAway,
ftHomeScore: scoreInput.ftHome,
ftAwayScore: scoreInput.ftAway,
homeCorners: statsInput.homeCorners ?? null,
awayCorners: statsInput.awayCorners ?? null,
homeYellowCards: statsInput.homeYellowCards ?? null,
awayYellowCards: statsInput.awayYellowCards ?? null,
homeRedCards: statsInput.homeRedCards ?? null,
awayRedCards: statsInput.awayRedCards ?? null,
homeCards: statsInput.homeCards ?? null,
awayCards: statsInput.awayCards ?? null,
status: 'PREVIEW',
totalBets: items.length,
totalPayout: totalTopup,
@@ -1233,6 +1464,7 @@ export class SettlementService {
htAway: scoreInput.htAway,
ftHome: scoreInput.ftHome,
ftAway: scoreInput.ftAway,
...statsInput,
winnerTeamId,
},
operatorId,
@@ -1259,36 +1491,60 @@ export class SettlementService {
if (!batch.isResettle) throw appBadRequest('RESETTLE_BATCH_ONLY');
if (batch.status !== 'PREVIEW') throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED');
const scoreInput: ScoreInput = {
htHome: batch.htHomeScore ?? 0,
htAway: batch.htAwayScore ?? 0,
ftHome: batch.ftHomeScore ?? 0,
ftAway: batch.ftAwayScore ?? 0,
};
const existingScore = await this.prisma.matchScore.findUnique({
where: { matchId: batch.matchId },
});
const winnerTeamCode = await this.resolveWinnerTeamCode(existingScore?.winnerTeamId ?? null);
const settledBets = await this.prisma.bet.findMany({
where: {
status: { in: ['WON', 'LOST', 'PUSH', 'VOID'] },
selections: { some: { matchId: batch.matchId } },
},
include: { selections: { orderBy: { sortOrder: 'asc' } } },
});
const selectionCodes = await this.loadSelectionCodes(settledBets);
const agentIds = new Set<bigint>();
await this.prisma.$transaction(async (tx) => {
const claimed = await tx.settlementBatch.updateMany({
where: { id: batchId, status: 'PREVIEW' },
data: { status: 'CONFIRMING', operatorId },
});
if (claimed.count !== 1) throw appBadRequest('SETTLEMENT_BATCH_ALREADY_CONFIRMED');
const currentBatch = await tx.settlementBatch.findUnique({
where: { id: batchId },
include: { match: true },
});
if (!currentBatch) throw appNotFound('SETTLEMENT_BATCH_NOT_FOUND');
const scoreInput: ScoreInput = {
htHome: currentBatch.htHomeScore ?? 0,
htAway: currentBatch.htAwayScore ?? 0,
ftHome: currentBatch.ftHomeScore ?? 0,
ftAway: currentBatch.ftAwayScore ?? 0,
};
const statsInput = this.statsInputFromSource(currentBatch);
const existingScore = await tx.matchScore.findUnique({
where: { matchId: currentBatch.matchId },
});
const winnerTeamCode = await this.resolveWinnerTeamCode(
existingScore?.winnerTeamId ?? null,
tx,
);
const settledBets = await tx.bet.findMany({
where: {
status: { in: ['WON', 'LOST', 'PUSH', 'VOID'] },
selections: { some: { matchId: currentBatch.matchId } },
},
include: { selections: { orderBy: { sortOrder: 'asc' } } },
});
const selectionCodes = await this.loadSelectionCodes(settledBets, tx);
await this.assertRequiredStatsAvailable(
currentBatch.matchId,
statsInput,
settledBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
tx,
);
let affectedCount = 0;
await this.upsertMatchScoreRecord(
batch.matchId,
currentBatch.matchId,
{
htHome: scoreInput.htHome,
htAway: scoreInput.htAway,
ftHome: scoreInput.ftHome,
ftAway: scoreInput.ftAway,
...statsInput,
winnerTeamId: existingScore?.winnerTeamId ?? null,
},
operatorId,
@@ -1299,8 +1555,9 @@ export class SettlementService {
const oldPayout = new Decimal(bet.actualReturn);
const outcome = await this.computeBetOutcome(
bet,
batch.matchId,
currentBatch.matchId,
scoreInput,
statsInput,
winnerTeamCode,
selectionCodes,
);
@@ -1328,9 +1585,16 @@ export class SettlementService {
},
});
await this.wallet.applyResettleDelta(bet.userId, delta, bet.betNo, tx);
await this.funds.applyResettleDelta({
userId: bet.userId,
delta,
betNo: bet.betNo,
batchNo: currentBatch.batchNo,
tx,
});
if (bet.agentId) agentIds.add(bet.agentId);
affectedCount += 1;
await tx.settlementItem.create({
data: {
@@ -1345,7 +1609,12 @@ export class SettlementService {
await tx.settlementBatch.update({
where: { id: batchId },
data: { status: 'CONFIRMED', confirmedAt: new Date(), operatorId },
data: {
status: 'CONFIRMED',
confirmedAt: new Date(),
operatorId,
totalBets: affectedCount,
},
});
});
@@ -1357,18 +1626,57 @@ export class SettlementService {
}
async voidMatchBets(matchId: bigint) {
const bets = await this.prisma.bet.findMany({
where: { status: 'PENDING', selections: { some: { matchId } } },
return this.voidMatchBetsInTransaction(matchId);
}
async cancelMatchAndVoidBets(matchId: bigint) {
return this.voidMatchBetsInTransaction(matchId, { cancelMatch: true });
}
private async voidMatchBetsInTransaction(
matchId: bigint,
options: { cancelMatch?: boolean } = {},
) {
const agentIds = new Set<bigint>();
const voidedCount = await this.prisma.$transaction(async (tx) => {
if (options.cancelMatch) {
await tx.match.update({
where: { id: matchId },
data: { status: 'CANCELLED' },
});
}
const bets = await tx.bet.findMany({
where: { status: 'PENDING', selections: { some: { matchId } } },
});
let count = 0;
for (const bet of bets) {
const updated = await tx.bet.updateMany({
where: { id: bet.id, status: 'PENDING' },
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
});
if (updated.count !== 1) continue;
await this.funds.voidBet({
userId: bet.userId,
stake: bet.stake,
betNo: bet.betNo,
businessKey: `void:${matchId}:${bet.betNo}`,
tx,
});
if (bet.agentId) agentIds.add(bet.agentId);
count += 1;
}
return count;
});
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() },
});
for (const agentId of agentIds) {
await this.agents.recalculateUsedCredit(agentId);
}
return { voidedCount: bets.length };
return { voidedCount };
}
}

View File

@@ -2,10 +2,12 @@ import Decimal from 'decimal.js';
import {
calculatePayout,
settleSelection,
FT_CORRECT_SCORE_TEMPLATE,
HT_CORRECT_SCORE_TEMPLATE,
type ScoreInput,
} from './domain/settlement-calculator';
import {
FT_CORRECT_SCORE_TEMPLATE,
HT_CORRECT_SCORE_TEMPLATE,
} from '@thebet365/shared';
export type SmartScoreStrategy =
| 'MIN_PAYOUT'
@@ -39,8 +41,8 @@ export type ScoreEvaluation = {
};
function templateForMarket(marketType: string): string[] {
if (marketType === 'FT_CORRECT_SCORE') return FT_CORRECT_SCORE_TEMPLATE;
if (marketType.includes('CORRECT_SCORE')) return HT_CORRECT_SCORE_TEMPLATE;
if (marketType === 'FT_CORRECT_SCORE') return [...FT_CORRECT_SCORE_TEMPLATE];
if (marketType.includes('CORRECT_SCORE')) return [...HT_CORRECT_SCORE_TEMPLATE];
return [];
}

View File

@@ -8,7 +8,6 @@ import type { ZhiboMatchExport, ZhiboTeamExport } from '../../domains/catalog/zh
import {
leagueCodeFromExport,
resolveInternalStatus,
resolveIsHot,
resolveStartTime,
teamCodeFromExport,
toKickoffJson,
@@ -263,7 +262,7 @@ async function upsertWc2026GroupMatch(prisma: PrismaClient, item: ZhiboMatchExpo
homeTeamId: homeTeam.id,
awayTeamId: awayTeam.id,
startTime,
isHot: resolveIsHot(item),
isHot: false,
displayOrder: item.sortOrder,
status,
publishTime: status === 'PUBLISHED' ? new Date() : undefined,

View File

@@ -33,7 +33,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018908829.png"
"image": "https://flagcdn.com/mx.svg"
},
"awayTeam": {
"id": 11173,
@@ -46,11 +46,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018843635.png"
"image": "https://flagcdn.com/za.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -105,7 +105,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164974972753.png"
"image": "https://flagcdn.com/kr.svg"
},
"awayTeam": {
"id": 50467,
@@ -122,7 +122,7 @@
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -190,11 +190,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018843635.png"
"image": "https://flagcdn.com/za.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -249,7 +249,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018908829.png"
"image": "https://flagcdn.com/mx.svg"
},
"awayTeam": {
"id": 11261,
@@ -262,11 +262,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164974972753.png"
"image": "https://flagcdn.com/kr.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -334,11 +334,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018908829.png"
"image": "https://flagcdn.com/mx.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -393,7 +393,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018843635.png"
"image": "https://flagcdn.com/za.svg"
},
"awayTeam": {
"id": 11261,
@@ -406,11 +406,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164974972753.png"
"image": "https://flagcdn.com/kr.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -465,7 +465,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018903917.png"
"image": "https://flagcdn.com/ca.svg"
},
"awayTeam": {
"id": 11153,
@@ -478,11 +478,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983517667.png"
"image": "https://flagcdn.com/ba.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -537,7 +537,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166968529845.png"
"image": "https://flagcdn.com/qa.svg"
},
"awayTeam": {
"id": 11036,
@@ -550,11 +550,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983533378.png"
"image": "https://flagcdn.com/ch.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -609,7 +609,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983533378.png"
"image": "https://flagcdn.com/ch.svg"
},
"awayTeam": {
"id": 11153,
@@ -622,11 +622,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983517667.png"
"image": "https://flagcdn.com/ba.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -681,7 +681,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018903917.png"
"image": "https://flagcdn.com/ca.svg"
},
"awayTeam": {
"id": 11267,
@@ -694,11 +694,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166968529845.png"
"image": "https://flagcdn.com/qa.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -753,7 +753,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983533378.png"
"image": "https://flagcdn.com/ch.svg"
},
"awayTeam": {
"id": 11166,
@@ -766,11 +766,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018903917.png"
"image": "https://flagcdn.com/ca.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -825,7 +825,7 @@
"km": null,
"ms": null
},
"image": ""
"image": "https://flagcdn.com/ba.svg"
},
"awayTeam": {
"id": null,
@@ -838,11 +838,11 @@
"km": null,
"ms": null
},
"image": ""
"image": "https://flagcdn.com/qa.svg"
},
"status": {
"state": "off",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -897,7 +897,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018879430.png"
"image": "https://flagcdn.com/br.svg"
},
"awayTeam": {
"id": 11182,
@@ -910,11 +910,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1hkv9bnt8sv.png"
"image": "https://flagcdn.com/ma.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -969,7 +969,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018900888.png"
"image": "https://flagcdn.com/ht.svg"
},
"awayTeam": {
"id": 11030,
@@ -982,11 +982,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164984175759.png"
"image": "https://flagcdn.com/gb-sct.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -1041,7 +1041,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164984175759.png"
"image": "https://flagcdn.com/gb-sct.svg"
},
"awayTeam": {
"id": 11182,
@@ -1054,11 +1054,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1hkv9bnt8sv.png"
"image": "https://flagcdn.com/ma.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -1113,7 +1113,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018879430.png"
"image": "https://flagcdn.com/br.svg"
},
"awayTeam": {
"id": 11270,
@@ -1126,11 +1126,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018900888.png"
"image": "https://flagcdn.com/ht.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -1185,7 +1185,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164984175759.png"
"image": "https://flagcdn.com/gb-sct.svg"
},
"awayTeam": {
"id": 11150,
@@ -1198,11 +1198,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018879430.png"
"image": "https://flagcdn.com/br.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -1257,7 +1257,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1hkv9bnt8sv.png"
"image": "https://flagcdn.com/ma.svg"
},
"awayTeam": {
"id": 11270,
@@ -1270,11 +1270,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018900888.png"
"image": "https://flagcdn.com/ht.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -1329,7 +1329,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165102965258.png"
"image": "https://flagcdn.com/us.svg"
},
"awayTeam": {
"id": 11148,
@@ -1342,11 +1342,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018876382.png"
"image": "https://flagcdn.com/py.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -1383,11 +1383,11 @@
"zh": "2026 世界杯"
},
"kickoff": {
"utcTimeStart": 1781323200,
"utcTimeStop": 1781330400,
"utcIso": "2026-06-13T04:00:00Z",
"chinaTime": "2026-06-13 12:00:00 Asia/Shanghai",
"venueTime": "2026-06-12 21:00:00 America/Los_Angeles",
"utcTimeStart": 1781409600,
"utcTimeStop": 1781416800,
"utcIso": "2026-06-14T04:00:00Z",
"chinaTime": "2026-06-14 12:00:00 Asia/Shanghai",
"venueTime": "2026-06-13 21:00:00 America/Los_Angeles",
"venueTimezone": "America/Los_Angeles"
},
"homeTeam": {
@@ -1401,7 +1401,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018939680.png"
"image": "https://flagcdn.com/au.svg"
},
"awayTeam": {
"id": 50468,
@@ -1418,7 +1418,7 @@
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -1473,7 +1473,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165102965258.png"
"image": "https://flagcdn.com/us.svg"
},
"awayTeam": {
"id": 11273,
@@ -1486,11 +1486,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018939680.png"
"image": "https://flagcdn.com/au.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -1558,11 +1558,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018876382.png"
"image": "https://flagcdn.com/py.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -1630,11 +1630,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165102965258.png"
"image": "https://flagcdn.com/us.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -1689,7 +1689,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018876382.png"
"image": "https://flagcdn.com/py.svg"
},
"awayTeam": {
"id": 11273,
@@ -1702,11 +1702,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018939680.png"
"image": "https://flagcdn.com/au.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -1761,7 +1761,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983677229.png"
"image": "https://flagcdn.com/de.svg"
},
"awayTeam": {
"id": 19979,
@@ -1774,11 +1774,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1gwbryn6pn1.png"
"image": "https://flagcdn.com/cw.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -1846,11 +1846,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018888622.png"
"image": "https://flagcdn.com/ec.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -1905,7 +1905,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983677229.png"
"image": "https://flagcdn.com/de.svg"
},
"awayTeam": {
"id": 50469,
@@ -1922,7 +1922,7 @@
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -1977,7 +1977,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018888622.png"
"image": "https://flagcdn.com/ec.svg"
},
"awayTeam": {
"id": 19979,
@@ -1990,11 +1990,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1gwbryn6pn1.png"
"image": "https://flagcdn.com/cw.svg"
},
"status": {
"state": "off",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -2049,7 +2049,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018888622.png"
"image": "https://flagcdn.com/ec.svg"
},
"awayTeam": {
"id": 11038,
@@ -2062,11 +2062,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983677229.png"
"image": "https://flagcdn.com/de.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -2121,7 +2121,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1gwbryn6pn1.png"
"image": "https://flagcdn.com/cw.svg"
},
"awayTeam": {
"id": 50469,
@@ -2138,7 +2138,7 @@
},
"status": {
"state": "off",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -2193,7 +2193,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/16498334962.png"
"image": "https://flagcdn.com/nl.svg"
},
"awayTeam": {
"id": 11266,
@@ -2206,11 +2206,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166919982535.png"
"image": "https://flagcdn.com/jp.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -2265,7 +2265,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983505795.png"
"image": "https://flagcdn.com/se.svg"
},
"awayTeam": {
"id": 11190,
@@ -2278,11 +2278,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018860211.png"
"image": "https://flagcdn.com/tn.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -2337,7 +2337,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/16498334962.png"
"image": "https://flagcdn.com/nl.svg"
},
"awayTeam": {
"id": 11032,
@@ -2350,11 +2350,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983505795.png"
"image": "https://flagcdn.com/se.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -2409,7 +2409,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018860211.png"
"image": "https://flagcdn.com/tn.svg"
},
"awayTeam": {
"id": 11266,
@@ -2422,11 +2422,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166919982535.png"
"image": "https://flagcdn.com/jp.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -2481,7 +2481,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166919982535.png"
"image": "https://flagcdn.com/jp.svg"
},
"awayTeam": {
"id": 11032,
@@ -2494,11 +2494,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983505795.png"
"image": "https://flagcdn.com/se.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -2553,7 +2553,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018860211.png"
"image": "https://flagcdn.com/tn.svg"
},
"awayTeam": {
"id": 11034,
@@ -2566,11 +2566,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/16498334962.png"
"image": "https://flagcdn.com/nl.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -2625,7 +2625,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1j0aw46xvtk.png"
"image": "https://flagcdn.com/be.svg"
},
"awayTeam": {
"id": 11109,
@@ -2638,11 +2638,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018770975.png"
"image": "https://flagcdn.com/eg.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -2697,7 +2697,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018993893.png"
"image": "https://flagcdn.com/ir.svg"
},
"awayTeam": {
"id": 12310,
@@ -2710,11 +2710,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018693635.png"
"image": "https://flagcdn.com/nz.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -2769,7 +2769,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1j0aw46xvtk.png"
"image": "https://flagcdn.com/be.svg"
},
"awayTeam": {
"id": 11154,
@@ -2782,11 +2782,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018993893.png"
"image": "https://flagcdn.com/ir.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -2841,7 +2841,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018693635.png"
"image": "https://flagcdn.com/nz.svg"
},
"awayTeam": {
"id": 11109,
@@ -2854,11 +2854,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018770975.png"
"image": "https://flagcdn.com/eg.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -2913,7 +2913,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018770975.png"
"image": "https://flagcdn.com/eg.svg"
},
"awayTeam": {
"id": 11154,
@@ -2926,11 +2926,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018993893.png"
"image": "https://flagcdn.com/ir.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -2985,7 +2985,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018693635.png"
"image": "https://flagcdn.com/nz.svg"
},
"awayTeam": {
"id": 11033,
@@ -2998,11 +2998,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1j0aw46xvtk.png"
"image": "https://flagcdn.com/be.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -3057,7 +3057,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983321668.png"
"image": "https://flagcdn.com/es.svg"
},
"awayTeam": {
"id": 11161,
@@ -3070,11 +3070,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018791043.png"
"image": "https://flagcdn.com/cv.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -3129,7 +3129,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018978353.png"
"image": "https://flagcdn.com/sa.svg"
},
"awayTeam": {
"id": 11139,
@@ -3142,11 +3142,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h1kc21h0g2r.png"
"image": "https://flagcdn.com/uy.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -3201,7 +3201,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983321668.png"
"image": "https://flagcdn.com/es.svg"
},
"awayTeam": {
"id": 11254,
@@ -3214,11 +3214,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018978353.png"
"image": "https://flagcdn.com/sa.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -3273,7 +3273,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h1kc21h0g2r.png"
"image": "https://flagcdn.com/uy.svg"
},
"awayTeam": {
"id": 11161,
@@ -3286,11 +3286,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018791043.png"
"image": "https://flagcdn.com/cv.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -3345,7 +3345,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018791043.png"
"image": "https://flagcdn.com/cv.svg"
},
"awayTeam": {
"id": 11254,
@@ -3358,11 +3358,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018978353.png"
"image": "https://flagcdn.com/sa.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -3417,7 +3417,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h1kc21h0g2r.png"
"image": "https://flagcdn.com/uy.svg"
},
"awayTeam": {
"id": 11144,
@@ -3430,11 +3430,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983321668.png"
"image": "https://flagcdn.com/es.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -3489,7 +3489,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983446440.png"
"image": "https://flagcdn.com/fr.svg"
},
"awayTeam": {
"id": 11184,
@@ -3502,11 +3502,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018850327.png"
"image": "https://flagcdn.com/sn.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -3561,7 +3561,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018992317.png"
"image": "https://flagcdn.com/iq.svg"
},
"awayTeam": {
"id": 11029,
@@ -3574,11 +3574,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983671192.png"
"image": "https://flagcdn.com/no.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -3633,7 +3633,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983446440.png"
"image": "https://flagcdn.com/fr.svg"
},
"awayTeam": {
"id": 11238,
@@ -3646,11 +3646,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018992317.png"
"image": "https://flagcdn.com/iq.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -3705,7 +3705,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983671192.png"
"image": "https://flagcdn.com/no.svg"
},
"awayTeam": {
"id": 11184,
@@ -3718,11 +3718,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018850327.png"
"image": "https://flagcdn.com/sn.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -3777,7 +3777,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983671192.png"
"image": "https://flagcdn.com/no.svg"
},
"awayTeam": {
"id": 11037,
@@ -3790,11 +3790,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983446440.png"
"image": "https://flagcdn.com/fr.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -3849,7 +3849,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018850327.png"
"image": "https://flagcdn.com/sn.svg"
},
"awayTeam": {
"id": 11238,
@@ -3862,11 +3862,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018992317.png"
"image": "https://flagcdn.com/iq.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -3921,7 +3921,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018869978.png"
"image": "https://flagcdn.com/ar.svg"
},
"awayTeam": {
"id": 20195,
@@ -3934,11 +3934,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018769444.png"
"image": "https://flagcdn.com/dz.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -3993,7 +3993,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h3eak1vzg7.png"
"image": "https://flagcdn.com/at.svg"
},
"awayTeam": {
"id": 11244,
@@ -4006,11 +4006,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018997867.png"
"image": "https://flagcdn.com/jo.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -4065,7 +4065,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018869978.png"
"image": "https://flagcdn.com/ar.svg"
},
"awayTeam": {
"id": 11035,
@@ -4078,11 +4078,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h3eak1vzg7.png"
"image": "https://flagcdn.com/at.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -4137,7 +4137,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018997867.png"
"image": "https://flagcdn.com/jo.svg"
},
"awayTeam": {
"id": 20195,
@@ -4150,11 +4150,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018769444.png"
"image": "https://flagcdn.com/dz.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -4209,7 +4209,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018769444.png"
"image": "https://flagcdn.com/dz.svg"
},
"awayTeam": {
"id": 11035,
@@ -4222,11 +4222,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h3eak1vzg7.png"
"image": "https://flagcdn.com/at.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -4281,7 +4281,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018997867.png"
"image": "https://flagcdn.com/jo.svg"
},
"awayTeam": {
"id": 11138,
@@ -4294,11 +4294,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018869978.png"
"image": "https://flagcdn.com/ar.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -4353,7 +4353,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983333830.png"
"image": "https://flagcdn.com/pt.svg"
},
"awayTeam": {
"id": 50470,
@@ -4370,7 +4370,7 @@
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -4425,7 +4425,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166261269158.png"
"image": "https://flagcdn.com/uz.svg"
},
"awayTeam": {
"id": 11147,
@@ -4438,11 +4438,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018896247.png"
"image": "https://flagcdn.com/co.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -4497,7 +4497,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983333830.png"
"image": "https://flagcdn.com/pt.svg"
},
"awayTeam": {
"id": 11239,
@@ -4510,11 +4510,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166261269158.png"
"image": "https://flagcdn.com/uz.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -4569,7 +4569,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018896247.png"
"image": "https://flagcdn.com/co.svg"
},
"awayTeam": {
"id": 50470,
@@ -4586,7 +4586,7 @@
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -4641,7 +4641,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018896247.png"
"image": "https://flagcdn.com/co.svg"
},
"awayTeam": {
"id": 11137,
@@ -4654,11 +4654,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983333830.png"
"image": "https://flagcdn.com/pt.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -4726,11 +4726,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166261269158.png"
"image": "https://flagcdn.com/uz.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -4785,7 +4785,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983421453.png"
"image": "https://flagcdn.com/gb-eng.svg"
},
"awayTeam": {
"id": 11140,
@@ -4798,11 +4798,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983770149.png"
"image": "https://flagcdn.com/hr.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -4857,7 +4857,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018802248.png"
"image": "https://flagcdn.com/gh.svg"
},
"awayTeam": {
"id": 11169,
@@ -4870,11 +4870,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018878036.png"
"image": "https://flagcdn.com/pa.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -4929,7 +4929,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983421453.png"
"image": "https://flagcdn.com/gb-eng.svg"
},
"awayTeam": {
"id": 11179,
@@ -4942,11 +4942,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018802248.png"
"image": "https://flagcdn.com/gh.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -5001,7 +5001,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018878036.png"
"image": "https://flagcdn.com/pa.svg"
},
"awayTeam": {
"id": 11140,
@@ -5014,11 +5014,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983770149.png"
"image": "https://flagcdn.com/hr.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -5073,7 +5073,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018878036.png"
"image": "https://flagcdn.com/pa.svg"
},
"awayTeam": {
"id": 11116,
@@ -5086,11 +5086,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983421453.png"
"image": "https://flagcdn.com/gb-eng.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {
@@ -5145,7 +5145,7 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983770149.png"
"image": "https://flagcdn.com/hr.svg"
},
"awayTeam": {
"id": 11179,
@@ -5158,11 +5158,11 @@
"km": null,
"ms": null
},
"image": "https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018802248.png"
"image": "https://flagcdn.com/gh.svg"
},
"status": {
"state": "scheduled",
"isHot": 1000
"isHot": 0
},
"venue": {
"names": {

View File

@@ -50,54 +50,61 @@ export const WC2026_ZIBO_ID_TO_CODE: Record<number, string> = {
50470: 'COD',
};
/** zhibo 球队 logoseed 时写入 teams.logo_url */
export const WC2026_TEAM_LOGO_BY_CODE: Record<string, string> = {
ALG: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018769444.png',
ARG: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018869978.png',
AUS: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018939680.png',
AUT: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h3eak1vzg7.png',
BEL: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1j0aw46xvtk.png',
BIH: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983517667.png',
BRA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018879430.png',
CAN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018903917.png',
CIV: 'https://flagcdn.com/ci.svg',
COD: 'https://flagcdn.com/cd.svg',
COL: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018896247.png',
CPV: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018791043.png',
CRO: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983770149.png',
CUW: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1gwbryn6pn1.png',
CZE: 'https://flagcdn.com/cz.svg',
ECU: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018888622.png',
EGY: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018770975.png',
ENG: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983421453.png',
ESP: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983321668.png',
FRA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983446440.png',
GER: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983677229.png',
GHA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018802248.png',
HAI: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018900888.png',
IRN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018993893.png',
IRQ: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018992317.png',
JOR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018997867.png',
JPN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166919982535.png',
KOR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164974972753.png',
KSA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018978353.png',
MAR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1hkv9bnt8sv.png',
MEX: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018908829.png',
NED: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/16498334962.png',
NOR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983671192.png',
NZL: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018693635.png',
PAN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018878036.png',
PAR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018876382.png',
POR: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983333830.png',
QAT: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166968529845.png',
RSA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018843635.png',
SCO: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164984175759.png',
SEN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018850327.png',
SUI: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983533378.png',
SWE: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/164983505795.png',
TUN: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165018860211.png',
TUR: 'https://flagcdn.com/tr.svg',
URU: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/1h1kc21h0g2r.png',
USA: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/165102965258.png',
UZB: 'https://static.bab3live.com/matchesEventPhotos/images/football/team/images/166261269158.png',
/** WC2026 默认国家/地区国旗FlagCDNseed 时写入 teams.logo_url */
export const WC2026_FLAG_ISO_BY_CODE: Record<string, string> = {
ALG: 'dz',
ARG: 'ar',
AUS: 'au',
AUT: 'at',
BEL: 'be',
BIH: 'ba',
BRA: 'br',
CAN: 'ca',
CIV: 'ci',
COD: 'cd',
COL: 'co',
CPV: 'cv',
CRO: 'hr',
CUW: 'cw',
CZE: 'cz',
ECU: 'ec',
EGY: 'eg',
ENG: 'gb-eng',
ESP: 'es',
FRA: 'fr',
GER: 'de',
GHA: 'gh',
HAI: 'ht',
IRN: 'ir',
IRQ: 'iq',
JOR: 'jo',
JPN: 'jp',
KOR: 'kr',
KSA: 'sa',
MAR: 'ma',
MEX: 'mx',
NED: 'nl',
NOR: 'no',
NZL: 'nz',
PAN: 'pa',
PAR: 'py',
POR: 'pt',
QAT: 'qa',
RSA: 'za',
SCO: 'gb-sct',
SEN: 'sn',
SUI: 'ch',
SWE: 'se',
TUN: 'tn',
TUR: 'tr',
URU: 'uy',
USA: 'us',
UZB: 'uz',
};
export const WC2026_TEAM_LOGO_BY_CODE: Record<string, string> = Object.fromEntries(
Object.entries(WC2026_FLAG_ISO_BY_CODE).map(([code, iso]) => [
code,
`https://flagcdn.com/${iso}.svg`,
]),
);

View File

@@ -1,129 +1,12 @@
import type { PrismaClient } from '@prisma/client';
import { DEFAULT_MARKET_TYPES, buildMarketTemplate } from '@thebet365/shared';
/** 为演示赛事补齐详情页玩法(与后台 markets 模板一致) */
export async function seedDemoMarkets(prisma: PrismaClient, matchId: bigint) {
const configs: Array<{
marketType: string;
period: string;
lineValue?: number;
sortOrder: number;
selections: Array<{ code: string; name: string; odds: number }>;
}> = [
{
marketType: 'FT_1X2',
period: 'FT',
sortOrder: 1,
selections: [
{ code: 'HOME', name: '主胜', odds: 2.5 },
{ code: 'DRAW', name: '和', odds: 3.2 },
{ code: 'AWAY', name: '客胜', odds: 2.8 },
],
},
{
marketType: 'FT_HANDICAP',
period: 'FT',
lineValue: -0.5,
sortOrder: 2,
selections: [
{ code: 'HOME', name: '主 -0.5', odds: 1.9 },
{ code: 'AWAY', name: '客 +0.5', odds: 1.9 },
],
},
{
marketType: 'FT_OVER_UNDER',
period: 'FT',
lineValue: 2.5,
sortOrder: 3,
selections: [
{ code: 'OVER', name: '大 2.5', odds: 1.85 },
{ code: 'UNDER', name: '小 2.5', odds: 1.95 },
],
},
{
marketType: 'FT_ODD_EVEN',
period: 'FT',
sortOrder: 4,
selections: [
{ code: 'ODD', name: '单', odds: 1.9 },
{ code: 'EVEN', name: '双', odds: 1.9 },
],
},
{
marketType: 'HT_1X2',
period: 'HT',
sortOrder: 5,
selections: [
{ code: 'HOME', name: '半场主', odds: 3.0 },
{ code: 'DRAW', name: '半场和', odds: 2.0 },
{ code: 'AWAY', name: '半场客', odds: 3.5 },
],
},
{
marketType: 'HT_HANDICAP',
period: 'HT',
lineValue: -0.5,
sortOrder: 6,
selections: [
{ code: 'HOME', name: '半场主 -0.5', odds: 1.9 },
{ code: 'AWAY', name: '半场客 +0.5', odds: 1.9 },
],
},
{
marketType: 'HT_OVER_UNDER',
period: 'HT',
lineValue: 1.5,
sortOrder: 7,
selections: [
{ code: 'OVER', name: '半场大 1.5', odds: 2.0 },
{ code: 'UNDER', name: '半场小 1.5', odds: 1.75 },
],
},
{
marketType: 'FT_CORRECT_SCORE',
period: 'FT',
sortOrder: 8,
selections: [
{ code: 'SCORE_1_0', name: '1-0', odds: 4.86 },
{ code: 'SCORE_2_0', name: '2-0', odds: 5.22 },
{ code: 'SCORE_2_1', name: '2-1', odds: 7.92 },
{ code: 'SCORE_3_0', name: '3-0', odds: 8.28 },
{ code: 'SCORE_0_0', name: '0-0', odds: 8.64 },
{ code: 'SCORE_1_1', name: '1-1', odds: 7.47 },
{ code: 'SCORE_2_2', name: '2-2', odds: 24.3 },
{ code: 'SCORE_3_3', name: '3-3', odds: 175.5 },
{ code: 'SCORE_0_1', name: '0-1', odds: 14.4 },
{ code: 'SCORE_0_2', name: '0-2', odds: 45.9 },
{ code: 'SCORE_1_2', name: '1-2', odds: 23.4 },
{ code: 'SCORE_0_3', name: '0-3', odds: 207 },
],
},
{
marketType: 'HT_CORRECT_SCORE',
period: 'HT',
sortOrder: 9,
selections: [
{ code: 'SCORE_1_0', name: '1-0', odds: 4.5 },
{ code: 'SCORE_2_0', name: '2-0', odds: 8.0 },
{ code: 'SCORE_0_0', name: '0-0', odds: 5.5 },
{ code: 'SCORE_1_1', name: '1-1', odds: 7.0 },
{ code: 'SCORE_0_1', name: '0-1', odds: 6.5 },
{ code: 'SCORE_0_2', name: '0-2', odds: 18.0 },
],
},
{
marketType: 'SH_CORRECT_SCORE',
period: 'SH',
sortOrder: 10,
selections: [
{ code: 'SCORE_1_0', name: '1-0', odds: 4.5 },
{ code: 'SCORE_2_0', name: '2-0', odds: 8.0 },
{ code: 'SCORE_0_0', name: '0-0', odds: 5.5 },
{ code: 'SCORE_1_1', name: '1-1', odds: 7.0 },
{ code: 'SCORE_0_1', name: '0-1', odds: 6.5 },
{ code: 'SCORE_0_2', name: '0-2', odds: 18.0 },
],
},
];
const configs = DEFAULT_MARKET_TYPES.map((marketType) => ({
marketType,
...buildMarketTemplate(marketType),
}));
for (const cfg of configs) {
const exists = await prisma.market.findFirst({
@@ -133,7 +16,7 @@ export async function seedDemoMarkets(prisma: PrismaClient, matchId: bigint) {
if (exists) {
const needRefresh =
cfg.marketType.includes('CORRECT_SCORE') &&
exists._count.selections < cfg.selections.length;
exists._count.selections < cfg.selectionTemplate.length;
if (needRefresh) {
await prisma.market.delete({ where: { id: exists.id } });
} else {
@@ -145,13 +28,13 @@ export async function seedDemoMarkets(prisma: PrismaClient, matchId: bigint) {
matchId,
marketType: cfg.marketType,
period: cfg.period,
lineValue: cfg.lineValue,
allowSingle: true,
allowParlay: true,
lineValue: cfg.defaultLineValue,
allowSingle: cfg.allowSingle,
allowParlay: cfg.allowParlay,
sortOrder: cfg.sortOrder,
status: 'OPEN',
selections: {
create: cfg.selections.map((s, i) => ({
create: cfg.selectionTemplate.map((s, i) => ({
selectionCode: s.code,
selectionName: s.name,
odds: s.odds,

View File

@@ -3,7 +3,7 @@ import {
calculatePayout,
isQuarterHandicapOrTotal,
} from './domains/settlement/domain/settlement-calculator';
import { hasDuplicateParlayMatch } from '@thebet365/shared';
import { canSelectForParlay } from '@thebet365/shared';
/**
* Agent credit & wallet integration scenarios (A001-A007)
@@ -83,9 +83,11 @@ describe('Bet Validation Rules (B001-B010)', () => {
expect(submitted === current).toBe(false);
});
it('B007: same match legs rejected in parlay', () => {
expect(hasDuplicateParlayMatch(['1', '1'])).toBe(true);
expect(hasDuplicateParlayMatch(['1', '2', '3'])).toBe(false);
it('B007: same match legs are allowed in parlay', () => {
const first = canSelectForParlay({ marketType: 'FT_1X2', allowParlay: true });
const second = canSelectForParlay({ marketType: 'FT_1X2', allowParlay: true });
expect(first.ok).toBe(true);
expect(second.ok).toBe(true);
});
it('B008: quarter line in parlay rejected', () => {

View File

@@ -6,6 +6,21 @@ import { AppModule } from './app.module';
import { GlobalExceptionFilter } from './shared/common/filters';
import { getUploadRoot } from './shared/uploads/upload-paths';
function envFlag(name: string) {
return ['1', 'true', 'yes', 'on'].includes((process.env[name] ?? '').toLowerCase());
}
function resolveCorsOrigin() {
const raw = process.env.CORS_ORIGINS?.trim();
if (raw) {
return raw
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
}
return process.env.NODE_ENV === 'production' ? false : true;
}
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.enableShutdownHooks();
@@ -14,7 +29,7 @@ async function bootstrap() {
app.useStaticAssets(getUploadRoot(), { prefix: '/uploads/' });
app.setGlobalPrefix('api');
app.enableCors({ origin: true, credentials: true });
app.enableCors({ origin: resolveCorsOrigin(), credentials: true });
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
@@ -23,18 +38,23 @@ async function bootstrap() {
}),
);
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 swaggerEnabled = process.env.NODE_ENV !== 'production' || envFlag('ENABLE_SWAGGER');
if (swaggerEnabled) {
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`);
if (swaggerEnabled) {
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
}
}
bootstrap();

View File

@@ -0,0 +1,41 @@
import {
formatLocalMatchDateTime,
isAfterLocalToday,
isInLocalToday,
isoToPlatformPickerDateTime,
platformPickerDateTimeToIso,
} from '@thebet365/shared';
describe('match time helpers', () => {
it('converts Malaysia picker time to UTC ISO and back', () => {
const iso = platformPickerDateTimeToIso('2026-06-11T19:00:00');
expect(iso).toBe('2026-06-11T11:00:00.000Z');
expect(isoToPlatformPickerDateTime(iso)).toBe('2026-06-11T19:00:00');
});
it('formats the same kickoff in different local time zones with GMT offsets', () => {
const malaysia = formatLocalMatchDateTime('2026-06-11T11:00:00.000Z', 'en-US', {
variant: 'full',
timeZone: 'Asia/Kuala_Lumpur',
});
const newYork = formatLocalMatchDateTime('2026-06-11T11:00:00.000Z', 'en-US', {
variant: 'full',
timeZone: 'America/New_York',
});
expect(malaysia).toContain('07:00');
expect(malaysia).toContain('GMT+8');
expect(newYork).toContain('07:00');
expect(newYork).toContain('GMT-4');
expect(malaysia).not.toBe(newYork);
});
it('uses the player local day for today and early buckets', () => {
const now = new Date('2026-06-11T15:00:00-04:00');
expect(isInLocalToday('2026-06-12T02:00:00.000Z', now, 'America/New_York')).toBe(true);
expect(isAfterLocalToday('2026-06-12T05:00:00.000Z', now, 'America/New_York')).toBe(true);
});
});

View File

@@ -0,0 +1,18 @@
import type { ApiErrorCode } from '@thebet365/shared';
export function transactionPassthrough<T extends object>(client: T) {
return jest.fn(async <R>(fn: (tx: T) => Promise<R>) => fn(client));
}
export function createPrismaMock<T extends object>(client: T): T & { $transaction: jest.Mock } {
return {
...client,
$transaction: transactionPassthrough(client),
};
}
export function expectAppError(code: ApiErrorCode) {
return expect.objectContaining({
response: expect.objectContaining({ code }),
});
}