Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Contribute to GitLab
Sign in
Toggle navigation
M
MBetterc
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Mbetter
MBetterc
Commits
74e4612f
Commit
74e4612f
authored
Jun 07, 2026
by
Your Name
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Fix late bet reconciliation
parent
1d11cc1e
Changes
9
Hide whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
474 additions
and
20 deletions
+474
-20
.gitignore
.gitignore
+2
-0
__init__.py
mbetterclient/__init__.py
+1
-1
client.py
mbetterclient/api_client/client.py
+12
-3
settings.py
mbetterclient/config/settings.py
+1
-1
games_thread.py
mbetterclient/core/games_thread.py
+23
-6
migrations.py
mbetterclient/database/migrations.py
+158
-0
models.py
mbetterclient/database/models.py
+6
-0
late_bet_reconciliation.py
mbetterclient/utils/late_bet_reconciliation.py
+214
-0
routes.py
mbetterclient/web_dashboard/routes.py
+57
-9
No files found.
.gitignore
View file @
74e4612f
...
...
@@ -145,7 +145,9 @@ ehthumbs.db
Thumbs.db
# Project specific
debug/
logs/
packages/
videos/
*.db
*.sqlite
...
...
mbetterclient/__init__.py
View file @
74e4612f
...
...
@@ -4,7 +4,7 @@ MbetterClient - Cross-platform multimedia client application
A multi-threaded application with video playback, web dashboard, and REST API integration.
"""
__version__
=
"1.0.3
5
"
__version__
=
"1.0.3
6
"
__author__
=
"MBetter Project"
__email__
=
"dev@mbetter.net"
__description__
=
"Cross-platform multimedia client with video overlay and web dashboard"
...
...
mbetterclient/api_client/client.py
View file @
74e4612f
...
...
@@ -1139,7 +1139,10 @@ class ReportsSyncResponseHandler(ResponseHandler):
for
bet
in
bets_to_sync
:
bet_details
=
session
.
query
(
BetDetailModel
)
.
filter_by
(
bet_id
=
bet
.
uuid
)
.
filter
(
BetDetailModel
.
result
!=
'cancelled'
)
.
all
()
)
.
filter
(
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
)
.
all
()
for
detail
in
bet_details
:
unique_match_ids
.
add
(
detail
.
match_id
)
...
...
@@ -1165,7 +1168,10 @@ class ReportsSyncResponseHandler(ResponseHandler):
for
bet
in
bets_to_sync
:
bet_details
=
session
.
query
(
BetDetailModel
)
.
filter_by
(
bet_id
=
bet
.
uuid
)
.
filter
(
BetDetailModel
.
result
!=
'cancelled'
)
.
all
()
)
.
filter
(
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
)
.
all
()
if
bet_details
:
# Only include bets with non-cancelled details
bet_data
=
{
...
...
@@ -1753,7 +1759,10 @@ class ReportsSyncResponseHandler(ResponseHandler):
# Ensure bet_details are loaded (handle lazy loading)
bet_details
=
[]
if
hasattr
(
bet
,
'bet_details'
)
and
bet
.
bet_details
:
bet_details
=
[
d
for
d
in
bet
.
bet_details
if
d
.
result
!=
'cancelled'
]
bet_details
=
[
d
for
d
in
bet
.
bet_details
if
d
.
result
!=
'cancelled'
and
not
getattr
(
d
,
'invalidated'
,
False
)
]
if
bet_details
:
total_payin
+=
sum
(
d
.
amount
for
d
in
bet_details
)
...
...
mbetterclient/config/settings.py
View file @
74e4612f
...
...
@@ -262,7 +262,7 @@ class ApiConfig:
# Request settings
verify_ssl
:
bool
=
True
user_agent
:
str
=
"MbetterClient/1.0r3
5
"
user_agent
:
str
=
"MbetterClient/1.0r3
6
"
max_response_size_mb
:
int
=
100
# Additional API client settings
...
...
mbetterclient/core/games_thread.py
View file @
74e4612f
...
...
@@ -1040,7 +1040,10 @@ class GamesThread(ThreadedComponent):
and `match.under_over_result`.
"""
try
:
query
=
session
.
query
(
BetDetailModel
)
.
filter
(
BetDetailModel
.
match_id
==
match
.
id
)
query
=
session
.
query
(
BetDetailModel
)
.
filter
(
BetDetailModel
.
match_id
==
match
.
id
,
BetDetailModel
.
invalidated
==
False
)
if
pending_only
:
query
=
query
.
filter
(
BetDetailModel
.
result
==
'pending'
)
else
:
...
...
@@ -1820,7 +1823,8 @@ class GamesThread(ThreadedComponent):
BetDetailModel
.
match_id
==
match_id
,
BetDetailModel
.
outcome
==
'UNDER'
,
BetDetailModel
.
result
==
'pending'
,
BetDetailModel
.
result
!=
'cancelled'
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
)
.
all
()
under_amount
=
sum
(
bet
.
amount
for
bet
in
under_bets
)
if
under_bets
else
0.0
logger
.
info
(
f
"💰 [BET CALC] UNDER bets: {len(under_bets)} bets, total amount: {under_amount:.2f}"
)
...
...
@@ -1833,7 +1837,8 @@ class GamesThread(ThreadedComponent):
BetDetailModel
.
match_id
==
match_id
,
BetDetailModel
.
outcome
==
'OVER'
,
BetDetailModel
.
result
==
'pending'
,
BetDetailModel
.
result
!=
'cancelled'
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
)
.
all
()
over_amount
=
sum
(
bet
.
amount
for
bet
in
over_bets
)
if
over_bets
else
0.0
logger
.
info
(
f
"💰 [BET CALC] OVER bets: {len(over_bets)} bets, total amount: {over_amount:.2f}"
)
...
...
@@ -1846,6 +1851,7 @@ class GamesThread(ThreadedComponent):
BetDetailModel
.
match_id
==
match_id
,
BetDetailModel
.
result
==
'pending'
,
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
,
~
BetDetailModel
.
outcome
.
in_
([
'UNDER'
,
'OVER'
])
)
.
all
()
other_amount
=
sum
(
bet
.
amount
for
bet
in
other_bets
)
if
other_bets
else
0.0
...
...
@@ -1997,7 +2003,8 @@ class GamesThread(ThreadedComponent):
expected_redistribution
=
total_payin
*
(
cap
/
100.0
)
winning_bets
=
session
.
query
(
BetDetailModel
)
.
filter
(
BetDetailModel
.
match_id
==
match_id
,
BetDetailModel
.
result
==
'win'
BetDetailModel
.
result
==
'win'
,
BetDetailModel
.
invalidated
==
False
)
.
all
()
actual_redistributed
=
sum
(
float
(
bet
.
win_amount
or
0.0
)
for
bet
in
winning_bets
)
...
...
@@ -3475,7 +3482,8 @@ class GamesThread(ThreadedComponent):
BetDetailModel
.
match_id
==
match_id
,
BetDetailModel
.
outcome
==
'UNDER'
,
BetDetailModel
.
result
==
'pending'
,
BetDetailModel
.
result
!=
'cancelled'
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
)
.
all
()
under_count
=
len
(
total_under
)
if
total_under
else
0
...
...
@@ -3490,7 +3498,8 @@ class GamesThread(ThreadedComponent):
BetDetailModel
.
match_id
==
match_id
,
BetDetailModel
.
outcome
==
'OVER'
,
BetDetailModel
.
result
==
'pending'
,
BetDetailModel
.
result
!=
'cancelled'
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
)
.
all
()
over_count
=
len
(
total_over
)
if
total_over
else
0
...
...
@@ -4303,6 +4312,7 @@ class GamesThread(ThreadedComponent):
total_bets
=
session
.
query
(
BetDetailModel
)
.
join
(
MatchModel
)
.
filter
(
BetDetailModel
.
match_id
==
match_id
,
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
,
MatchModel
.
active_status
==
True
)
.
count
()
...
...
@@ -4311,6 +4321,7 @@ class GamesThread(ThreadedComponent):
)
.
join
(
MatchModel
)
.
filter
(
BetDetailModel
.
match_id
==
match_id
,
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
,
MatchModel
.
active_status
==
True
)
.
all
()
total_amount_collected
=
sum
(
bet
.
amount
for
bet
in
total_amount_collected
)
if
total_amount_collected
else
0.0
...
...
@@ -4321,6 +4332,7 @@ class GamesThread(ThreadedComponent):
)
.
join
(
MatchModel
)
.
filter
(
BetDetailModel
.
match_id
==
match_id
,
BetDetailModel
.
result
==
'win'
,
BetDetailModel
.
invalidated
==
False
,
MatchModel
.
active_status
==
True
)
.
all
()
total_redistributed
=
sum
(
bet
.
win_amount
for
bet
in
total_redistributed
)
if
total_redistributed
else
0.0
...
...
@@ -4330,6 +4342,7 @@ class GamesThread(ThreadedComponent):
BetDetailModel
.
match_id
==
match_id
,
BetDetailModel
.
outcome
==
'UNDER'
,
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
,
MatchModel
.
active_status
==
True
)
.
count
()
...
...
@@ -4339,6 +4352,7 @@ class GamesThread(ThreadedComponent):
BetDetailModel
.
match_id
==
match_id
,
BetDetailModel
.
outcome
==
'UNDER'
,
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
,
MatchModel
.
active_status
==
True
)
.
all
()
under_amount
=
sum
(
bet
.
amount
for
bet
in
under_amount
)
if
under_amount
else
0.0
...
...
@@ -4347,6 +4361,7 @@ class GamesThread(ThreadedComponent):
BetDetailModel
.
match_id
==
match_id
,
BetDetailModel
.
outcome
==
'OVER'
,
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
,
MatchModel
.
active_status
==
True
)
.
count
()
...
...
@@ -4356,6 +4371,7 @@ class GamesThread(ThreadedComponent):
BetDetailModel
.
match_id
==
match_id
,
BetDetailModel
.
outcome
==
'OVER'
,
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
,
MatchModel
.
active_status
==
True
)
.
all
()
over_amount
=
sum
(
bet
.
amount
for
bet
in
over_amount
)
if
over_amount
else
0.0
...
...
@@ -4532,6 +4548,7 @@ class GamesThread(ThreadedComponent):
winning_under_over
=
session
.
query
(
BetDetailModel
)
.
filter
(
BetDetailModel
.
match_id
==
match_id
,
BetDetailModel
.
result
==
'win'
,
BetDetailModel
.
invalidated
==
False
,
BetDetailModel
.
outcome
.
in_
([
'UNDER'
,
'OVER'
])
)
.
first
()
...
...
mbetterclient/database/migrations.py
View file @
74e4612f
...
...
@@ -7833,6 +7833,163 @@ class Migration_071_DeduplicateExtractionAssociations(DatabaseMigration):
return
False
class
Migration_072_ReconcileLateBetsAfterMatchEnd
(
DatabaseMigration
):
"""Export evidence and invalidate bets created after their match ended."""
def
__init__
(
self
):
super
()
.
__init__
(
"072"
,
"Reconcile bets created after match end"
)
def
_add_column_if_missing
(
self
,
conn
,
db_manager
,
table_name
,
column_name
,
column_sql
):
columns
=
get_table_columns
(
db_manager
,
table_name
)
if
column_name
not
in
columns
:
conn
.
execute
(
text
(
f
"ALTER TABLE {table_name} ADD COLUMN {column_sql}"
))
logger
.
info
(
f
"Added column {table_name}.{column_name}"
)
def
_rebuild_result_breakdown
(
self
,
detail_rows
):
breakdown
=
{}
for
row
in
detail_rows
:
outcome
=
row
[
0
]
amount
=
float
(
row
[
1
]
or
0
)
if
not
outcome
:
continue
if
outcome
not
in
breakdown
:
breakdown
[
outcome
]
=
{
'bets'
:
0
,
'amount'
:
0.0
}
breakdown
[
outcome
][
'bets'
]
+=
1
breakdown
[
outcome
][
'amount'
]
+=
amount
return
breakdown
def
_repair_extraction_stats_excluding_invalidated
(
self
,
db_manager
):
import
json
with
db_manager
.
engine
.
connect
()
as
conn
:
stats_rows
=
conn
.
execute
(
text
(
"SELECT id, match_id FROM extraction_stats"
))
.
fetchall
()
repaired
=
0
for
stat_id
,
match_id
in
stats_rows
:
details
=
conn
.
execute
(
text
(
"""
SELECT outcome, amount, result, win_amount
FROM bets_details
WHERE match_id = :match_id
AND result != 'cancelled'
AND COALESCE(invalidated, 0) = 0
"""
),
{
'match_id'
:
match_id
})
.
fetchall
()
total_bets
=
len
(
details
)
total_amount
=
sum
(
float
(
row
[
1
]
or
0
)
for
row
in
details
)
total_redistributed
=
sum
(
float
(
row
[
3
]
or
0
)
for
row
in
details
if
row
[
2
]
==
'win'
)
under_bets
=
sum
(
1
for
row
in
details
if
row
[
0
]
==
'UNDER'
)
under_amount
=
sum
(
float
(
row
[
1
]
or
0
)
for
row
in
details
if
row
[
0
]
==
'UNDER'
)
over_bets
=
sum
(
1
for
row
in
details
if
row
[
0
]
==
'OVER'
)
over_amount
=
sum
(
float
(
row
[
1
]
or
0
)
for
row
in
details
if
row
[
0
]
==
'OVER'
)
result_breakdown
=
json
.
dumps
(
self
.
_rebuild_result_breakdown
(
details
))
conn
.
execute
(
text
(
"""
UPDATE extraction_stats
SET total_bets = :total_bets,
total_amount_collected = :total_amount,
total_redistributed = :total_redistributed,
under_bets = :under_bets,
under_amount = :under_amount,
over_bets = :over_bets,
over_amount = :over_amount,
result_breakdown = :result_breakdown,
updated_at = :updated_at
WHERE id = :stat_id
"""
),
{
'total_bets'
:
total_bets
,
'total_amount'
:
total_amount
,
'total_redistributed'
:
total_redistributed
,
'under_bets'
:
under_bets
,
'under_amount'
:
under_amount
,
'over_bets'
:
over_bets
,
'over_amount'
:
over_amount
,
'result_breakdown'
:
result_breakdown
,
'updated_at'
:
datetime
.
utcnow
(),
'stat_id'
:
stat_id
,
})
repaired
+=
1
conn
.
commit
()
logger
.
info
(
f
"Repaired {repaired} extraction stats rows excluding invalidated late bets"
)
def
up
(
self
,
db_manager
)
->
bool
:
try
:
timestamp_sql
=
get_current_timestamp_sql
(
db_manager
)
with
db_manager
.
engine
.
connect
()
as
conn
:
self
.
_add_column_if_missing
(
conn
,
db_manager
,
'bets'
,
'invalidated'
,
'invalidated BOOLEAN DEFAULT FALSE NOT NULL'
)
self
.
_add_column_if_missing
(
conn
,
db_manager
,
'bets'
,
'invalidation_reason'
,
'invalidation_reason TEXT NULL'
)
self
.
_add_column_if_missing
(
conn
,
db_manager
,
'bets'
,
'invalidated_at'
,
'invalidated_at DATETIME NULL'
)
self
.
_add_column_if_missing
(
conn
,
db_manager
,
'bets_details'
,
'invalidated'
,
'invalidated BOOLEAN DEFAULT FALSE NOT NULL'
)
self
.
_add_column_if_missing
(
conn
,
db_manager
,
'bets_details'
,
'invalidation_reason'
,
'invalidation_reason TEXT NULL'
)
self
.
_add_column_if_missing
(
conn
,
db_manager
,
'bets_details'
,
'invalidated_at'
,
'invalidated_at DATETIME NULL'
)
conn
.
commit
()
from
..utils.late_bet_reconciliation
import
export_late_bet_evidence
,
summarize_late_bet_rows
,
fetch_late_bet_rows
rows
=
fetch_late_bet_rows
(
db_manager
)
evidence_summary
=
export_late_bet_evidence
(
db_manager
)
logger
.
info
(
f
"Late bet evidence exported before reconciliation: {evidence_summary}"
)
with
db_manager
.
engine
.
connect
()
as
conn
:
conn
.
execute
(
text
(
f
"""
UPDATE bets_details
SET invalidated = 1,
invalidation_reason = 'created_after_match_end',
invalidated_at = {timestamp_sql},
result = 'cancelled',
win_amount = 0.0,
updated_at = {timestamp_sql}
WHERE id IN (
SELECT bd.id
FROM bets_details bd
JOIN bets b ON b.uuid = bd.bet_id
JOIN matches m ON m.id = bd.match_id
WHERE m.end_time IS NOT NULL
AND b.created_at > m.end_time
)
"""
))
conn
.
execute
(
text
(
f
"""
UPDATE bets
SET invalidated = 1,
invalidation_reason = 'created_after_match_end',
invalidated_at = {timestamp_sql},
paid_out = 0,
updated_at = {timestamp_sql}
WHERE uuid IN (
SELECT b.uuid
FROM bets b
WHERE EXISTS (
SELECT 1 FROM bets_details bd
JOIN matches m ON m.id = bd.match_id
WHERE bd.bet_id = b.uuid
AND m.end_time IS NOT NULL
AND b.created_at > m.end_time
)
AND NOT EXISTS (
SELECT 1 FROM bets_details bd_valid
WHERE bd_valid.bet_id = b.uuid
AND COALESCE(bd_valid.invalidated, 0) = 0
)
)
"""
))
conn
.
commit
()
self
.
_repair_extraction_stats_excluding_invalidated
(
db_manager
)
summary
=
summarize_late_bet_rows
(
rows
)
logger
.
warning
(
"Invalidated
%
s post-end bet details across
%
s tickets; prior payout exposure
%.2
f"
,
summary
[
'late_detail_count'
],
summary
[
'late_ticket_count'
],
summary
[
'total_payout_exposure'
]
)
return
True
except
Exception
as
e
:
logger
.
error
(
f
"Failed to reconcile late bets after match end: {e}"
)
return
False
def
down
(
self
,
db_manager
)
->
bool
:
logger
.
warning
(
"Cannot rollback Migration_072_ReconcileLateBetsAfterMatchEnd - evidence export and invalidation are audit actions"
)
return
True
MIGRATIONS
:
List
[
DatabaseMigration
]
=
[
Migration_001_InitialSchema
(),
Migration_002_AddIndexes
(),
...
...
@@ -7905,6 +8062,7 @@ MIGRATIONS: List[DatabaseMigration] = [
Migration_069_RepairExtractionStatsFinancials
(),
Migration_070_SeedSmtpEmailSettings
(),
Migration_071_DeduplicateExtractionAssociations
(),
Migration_072_ReconcileLateBetsAfterMatchEnd
(),
]
...
...
mbetterclient/database/models.py
View file @
74e4612f
...
...
@@ -824,6 +824,9 @@ class BetModel(BaseModel):
bet_datetime
=
Column
(
DateTime
,
default
=
datetime
.
utcnow
,
nullable
=
False
,
comment
=
'Bet creation timestamp'
)
paid
=
Column
(
Boolean
,
default
=
False
,
nullable
=
False
,
comment
=
'Payment status (True if payment received)'
)
paid_out
=
Column
(
Boolean
,
default
=
False
,
nullable
=
False
,
comment
=
'Payout status (True if winnings paid out)'
)
invalidated
=
Column
(
Boolean
,
default
=
False
,
nullable
=
False
,
comment
=
'True when all details are invalid and excluded from payouts/reports'
)
invalidation_reason
=
Column
(
Text
,
nullable
=
True
,
comment
=
'Reason this bet was invalidated'
)
invalidated_at
=
Column
(
DateTime
,
nullable
=
True
,
comment
=
'When this bet was invalidated'
)
# Barcode fields for verification
barcode_standard
=
Column
(
String
(
50
),
comment
=
'Barcode standard used (ean13, code128, etc.)'
)
...
...
@@ -947,6 +950,9 @@ class BetDetailModel(BaseModel):
amount
=
Column
(
Float
(
precision
=
2
),
nullable
=
False
,
comment
=
'Bet amount with 2 decimal precision'
)
win_amount
=
Column
(
Float
(
precision
=
2
),
default
=
0.0
,
nullable
=
False
,
comment
=
'Winning amount (calculated when result is win)'
)
result
=
Column
(
Enum
(
'win'
,
'lost'
,
'pending'
,
'cancelled'
),
default
=
'pending'
,
nullable
=
False
,
comment
=
'Bet result status'
)
invalidated
=
Column
(
Boolean
,
default
=
False
,
nullable
=
False
,
comment
=
'True when this detail is excluded from payouts/reports'
)
invalidation_reason
=
Column
(
Text
,
nullable
=
True
,
comment
=
'Reason this detail was invalidated'
)
invalidated_at
=
Column
(
DateTime
,
nullable
=
True
,
comment
=
'When this detail was invalidated'
)
# Relationships
match
=
relationship
(
'MatchModel'
)
...
...
mbetterclient/utils/late_bet_reconciliation.py
0 → 100644
View file @
74e4612f
"""Late bet evidence export and reconciliation helpers."""
from
__future__
import
annotations
import
csv
import
html
import
json
import
logging
import
zipfile
from
datetime
import
datetime
,
timezone
from
pathlib
import
Path
from
typing
import
Any
,
Dict
,
Iterable
,
List
from
sqlalchemy
import
text
from
..config.settings
import
get_user_data_dir
logger
=
logging
.
getLogger
(
__name__
)
LATE_BET_EVIDENCE_QUERY
=
"""
SELECT
b.uuid AS bet_uuid,
b.fixture_id,
b.created_at AS bet_created_at,
b.bet_datetime,
b.paid,
b.paid_out,
b.barcode_standard,
b.barcode_data,
bd.id AS bet_detail_id,
bd.match_id,
bd.match_number,
m.start_time AS match_start_time,
m.end_time AS match_end_time,
CASE
WHEN :dialect = 'sqlite' THEN ROUND((julianday(b.created_at) - julianday(m.end_time)) * 24 * 60, 3)
ELSE NULL
END AS minutes_after_end,
m.status AS match_status,
m.result AS match_result,
m.under_over_result,
m.winning_outcomes,
bd.outcome,
bd.amount,
bd.result AS bet_result,
bd.win_amount,
CASE WHEN bd.result = 'win' THEN bd.win_amount ELSE 0 END AS payout_exposure,
bd.created_at AS detail_created_at,
bd.updated_at AS detail_updated_at
FROM bets b
JOIN bets_details bd ON bd.bet_id = b.uuid
JOIN matches m ON m.id = bd.match_id
WHERE m.end_time IS NOT NULL
AND b.created_at > m.end_time
ORDER BY b.created_at, b.uuid, bd.id
"""
EVIDENCE_COLUMNS
=
[
"bet_uuid"
,
"fixture_id"
,
"bet_created_at"
,
"bet_datetime"
,
"paid"
,
"paid_out"
,
"barcode_standard"
,
"barcode_data"
,
"bet_detail_id"
,
"match_id"
,
"match_number"
,
"match_start_time"
,
"match_end_time"
,
"minutes_after_end"
,
"match_status"
,
"match_result"
,
"under_over_result"
,
"winning_outcomes"
,
"outcome"
,
"amount"
,
"bet_result"
,
"win_amount"
,
"payout_exposure"
,
"detail_created_at"
,
"detail_updated_at"
,
]
def
_as_dict
(
row
:
Any
)
->
Dict
[
str
,
Any
]:
if
hasattr
(
row
,
"_mapping"
):
return
dict
(
row
.
_mapping
)
return
dict
(
row
)
def
fetch_late_bet_rows
(
db_manager
)
->
List
[
Dict
[
str
,
Any
]]:
"""Return bet details created after their match already ended."""
dialect
=
db_manager
.
engine
.
dialect
.
name
with
db_manager
.
engine
.
connect
()
as
conn
:
rows
=
conn
.
execute
(
text
(
LATE_BET_EVIDENCE_QUERY
),
{
"dialect"
:
dialect
})
.
fetchall
()
return
[
_as_dict
(
row
)
for
row
in
rows
]
def
summarize_late_bet_rows
(
rows
:
Iterable
[
Dict
[
str
,
Any
]])
->
Dict
[
str
,
Any
]:
rows
=
list
(
rows
)
return
{
"late_detail_count"
:
len
(
rows
),
"late_ticket_count"
:
len
({
row
.
get
(
"bet_uuid"
)
for
row
in
rows
}),
"total_stake"
:
sum
(
float
(
row
.
get
(
"amount"
)
or
0
)
for
row
in
rows
),
"winning_detail_count"
:
sum
(
1
for
row
in
rows
if
row
.
get
(
"bet_result"
)
==
"win"
),
"total_payout_exposure"
:
sum
(
float
(
row
.
get
(
"payout_exposure"
)
or
0
)
for
row
in
rows
),
}
def
_cell_xml
(
value
:
Any
,
row
:
int
,
col
:
int
)
->
str
:
def
col_name
(
index
:
int
)
->
str
:
result
=
""
while
index
:
index
,
rem
=
divmod
(
index
-
1
,
26
)
result
=
chr
(
65
+
rem
)
+
result
return
result
ref
=
f
"{col_name(col)}{row}"
if
value
is
None
:
value
=
""
if
isinstance
(
value
,
(
int
,
float
))
and
not
isinstance
(
value
,
bool
):
return
f
'<c r="{ref}"><v>{value}</v></c>'
return
f
'<c r="{ref}" t="inlineStr"><is><t>{html.escape(str(value))}</t></is></c>'
def
_write_xlsx
(
path
:
Path
,
rows
:
List
[
Dict
[
str
,
Any
]])
->
None
:
sheet_rows
=
[]
values
=
[
EVIDENCE_COLUMNS
]
+
[[
row
.
get
(
column
)
for
column
in
EVIDENCE_COLUMNS
]
for
row
in
rows
]
for
row_index
,
row_values
in
enumerate
(
values
,
1
):
sheet_rows
.
append
(
f
'<row r="{row_index}">'
+
""
.
join
(
_cell_xml
(
value
,
row_index
,
col_index
)
for
col_index
,
value
in
enumerate
(
row_values
,
1
))
+
"</row>"
)
sheet_xml
=
(
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
'<sheetData>'
+
""
.
join
(
sheet_rows
)
+
'</sheetData></worksheet>'
)
with
zipfile
.
ZipFile
(
path
,
"w"
,
zipfile
.
ZIP_DEFLATED
)
as
xlsx
:
xlsx
.
writestr
(
"[Content_Types].xml"
,
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
'</Types>'
,
)
xlsx
.
writestr
(
"_rels/.rels"
,
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
'</Relationships>'
,
)
xlsx
.
writestr
(
"xl/workbook.xml"
,
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
'<sheets><sheet name="Late Bets Evidence" sheetId="1" r:id="rId1"/></sheets></workbook>'
,
)
xlsx
.
writestr
(
"xl/_rels/workbook.xml.rels"
,
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
'</Relationships>'
,
)
xlsx
.
writestr
(
"xl/worksheets/sheet1.xml"
,
sheet_xml
)
def
export_late_bet_evidence
(
db_manager
,
output_dir
:
Path
|
None
=
None
)
->
Dict
[
str
,
Any
]:
"""Write markdown, CSV, and XLSX evidence files for post-end bets."""
rows
=
fetch_late_bet_rows
(
db_manager
)
output_dir
=
Path
(
output_dir
or
get_user_data_dir
())
output_dir
.
mkdir
(
parents
=
True
,
exist_ok
=
True
)
generated_at
=
datetime
.
now
(
timezone
.
utc
)
.
replace
(
microsecond
=
0
)
.
isoformat
()
safe_timestamp
=
generated_at
.
replace
(
":"
,
""
)
.
replace
(
"+"
,
"_"
)
base
=
output_dir
/
f
"late_bets_after_match_end_evidence_{safe_timestamp}"
summary
=
summarize_late_bet_rows
(
rows
)
summary
.
update
({
"generated_at"
:
generated_at
,
"output_prefix"
:
str
(
base
)})
csv_path
=
base
.
with_suffix
(
".csv"
)
with
csv_path
.
open
(
"w"
,
newline
=
""
,
encoding
=
"utf-8"
)
as
csv_file
:
writer
=
csv
.
DictWriter
(
csv_file
,
fieldnames
=
EVIDENCE_COLUMNS
)
writer
.
writeheader
()
writer
.
writerows
(
rows
)
md_path
=
base
.
with_suffix
(
".md"
)
md_lines
=
[
"# Late Bets After Match End Evidence"
,
""
,
f
"Generated at: `{generated_at}`"
,
""
,
"## Summary"
,
""
,
f
"- Late bet details: `{summary['late_detail_count']}`"
,
f
"- Affected tickets: `{summary['late_ticket_count']}`"
,
f
"- Total stake: `{summary['total_stake']:.2f}`"
,
f
"- Winning late details: `{summary['winning_detail_count']}`"
,
f
"- Payout exposure: `{summary['total_payout_exposure']:.2f}`"
,
""
,
"## Evidence Rule"
,
""
,
"Rows are included when `matches.end_time IS NOT NULL` and `bets.created_at > matches.end_time`."
,
""
,
"## Rows"
,
""
,
"| bet_uuid | detail_id | match_id | match_number | bet_created_at | match_end_time | minutes_after_end | outcome | amount | result | win_amount |"
,
"|---|---:|---:|---:|---|---|---:|---|---:|---|---:|"
,
]
for
row
in
rows
:
md_lines
.
append
(
f
"| `{row.get('bet_uuid')}` | {row.get('bet_detail_id')} | {row.get('match_id')} | "
f
"{row.get('match_number')} | {row.get('bet_created_at')} | {row.get('match_end_time')} | "
f
"{row.get('minutes_after_end')} | {row.get('outcome')} | {float(row.get('amount') or 0):.2f} | "
f
"{row.get('bet_result')} | {float(row.get('win_amount') or 0):.2f} |"
)
md_path
.
write_text
(
"
\n
"
.
join
(
md_lines
)
+
"
\n
"
,
encoding
=
"utf-8"
)
xlsx_path
=
base
.
with_suffix
(
".xlsx"
)
_write_xlsx
(
xlsx_path
,
rows
)
summary
[
"files"
]
=
{
"markdown"
:
str
(
md_path
),
"csv"
:
str
(
csv_path
),
"xlsx"
:
str
(
xlsx_path
)}
logger
.
info
(
"Exported late bet evidence:
%
s"
,
json
.
dumps
(
summary
,
default
=
str
))
return
summary
mbetterclient/web_dashboard/routes.py
View file @
74e4612f
...
...
@@ -93,6 +93,8 @@ def is_bet_detail_winning(detail, match, session):
def
is_settled_bet_detail_winning
(
detail
,
match
,
session
):
"""Prefer settled row status for completed matches, fallback to inferred logic otherwise."""
if
is_bet_detail_invalidated
(
detail
):
return
False
if
detail
.
result
==
'win'
:
return
True
if
detail
.
result
in
[
'lost'
,
'cancelled'
]:
...
...
@@ -170,6 +172,31 @@ def get_api_auth_decorator(require_admin=False):
logger
=
logging
.
getLogger
(
__name__
)
def
is_bet_detail_invalidated
(
detail
)
->
bool
:
return
bool
(
getattr
(
detail
,
'invalidated'
,
False
))
or
getattr
(
detail
,
'result'
,
None
)
==
'cancelled'
def
is_match_open_for_betting
(
match
,
now
=
None
)
->
tuple
[
bool
,
str
]:
"""Authoritative server-side betting cutoff validation."""
if
not
match
:
return
False
,
"Match not found"
now
=
now
or
datetime
.
utcnow
()
if
getattr
(
match
,
'status'
,
None
)
!=
'bet'
:
return
False
,
f
"Match {match.id} is not open for betting"
if
getattr
(
match
,
'running'
,
False
):
return
False
,
f
"Match {match.id} is already running"
if
getattr
(
match
,
'done'
,
False
):
return
False
,
f
"Match {match.id} is already completed"
if
getattr
(
match
,
'result'
,
None
)
or
getattr
(
match
,
'under_over_result'
,
None
):
return
False
,
f
"Match {match.id} already has a result"
if
getattr
(
match
,
'end_time'
,
None
)
is
not
None
:
return
False
,
f
"Match {match.id} already ended"
if
getattr
(
match
,
'start_time'
,
None
)
is
not
None
and
now
>=
match
.
start_time
:
return
False
,
f
"Match {match.id} already started"
return
True
,
""
def
load_email_settings
(
session
):
"""Load stored email settings from configuration."""
from
..database.models
import
ConfigurationModel
...
...
@@ -222,7 +249,10 @@ def compute_daily_report_summary(session, db_manager, target_date, start_time_pa
for
bet
in
bets
:
bet_details
=
session
.
query
(
BetDetailModel
)
.
filter_by
(
bet_id
=
bet
.
uuid
)
.
filter
(
BetDetailModel
.
result
!=
'cancelled'
)
.
all
()
)
.
filter
(
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
)
.
all
()
total_payin
+=
sum
(
float
(
detail
.
amount
)
for
detail
in
bet_details
)
total_bets
+=
len
(
bet_details
)
...
...
@@ -231,7 +261,8 @@ def compute_daily_report_summary(session, db_manager, target_date, start_time_pa
)
.
filter
(
BetModel
.
bet_datetime
>=
start_datetime
,
BetModel
.
bet_datetime
<=
end_datetime
,
BetDetailModel
.
result
!=
'cancelled'
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
)
.
all
()
total_payout
=
0.0
...
...
@@ -6784,13 +6815,20 @@ def create_cashier_bet():
session
=
api_bp
.
db_manager
.
get_session
()
try
:
# Validate match_id exists
for all bet details
# Validate match_id exists
and is still open for all bet details.
from
..database.models
import
MatchModel
now
=
datetime
.
utcnow
()
for
detail
in
bet_details
:
match_id
=
detail
[
'match_id'
]
existing_match
=
session
.
query
(
MatchModel
)
.
filter_by
(
id
=
match_id
)
.
first
()
if
not
existing_match
:
return
jsonify
({
"error"
:
f
"Match {match_id} not found"
}),
404
is_open
,
reason
=
is_match_open_for_betting
(
existing_match
,
now
)
if
not
is_open
:
logger
.
warning
(
f
"Rejected late bet creation for match {match_id}: {reason}"
)
return
jsonify
({
"error"
:
reason
}),
409
# Store match_number for later use
detail
[
'_match_number'
]
=
existing_match
.
match_number
...
...
@@ -7865,7 +7903,10 @@ def get_daily_reports_summary():
# Get bet details for this bet (excluding cancelled bets)
bet_details
=
session
.
query
(
BetDetailModel
)
.
filter_by
(
bet_id
=
bet
.
uuid
)
.
filter
(
BetDetailModel
.
result
!=
'cancelled'
)
.
all
()
)
.
filter
(
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
)
.
all
()
bet_total
=
sum
(
float
(
detail
.
amount
)
for
detail
in
bet_details
)
total_payin
+=
bet_total
total_bets
+=
len
(
bet_details
)
...
...
@@ -7877,7 +7918,8 @@ def get_daily_reports_summary():
)
.
filter
(
BetModel
.
bet_datetime
>=
start_datetime
,
BetModel
.
bet_datetime
<=
end_datetime
,
BetDetailModel
.
result
!=
'cancelled'
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
)
bet_details
=
bet_details_query
.
all
()
...
...
@@ -7963,7 +8005,8 @@ def get_match_reports():
)
.
filter
(
BetModel
.
bet_datetime
>=
start_datetime
,
BetModel
.
bet_datetime
<=
end_datetime
,
BetDetailModel
.
result
!=
'cancelled'
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
)
# Group by match_id and calculate statistics
...
...
@@ -8212,7 +8255,10 @@ def download_excel_report():
for
bet
in
bets
:
bet_details
=
session
.
query
(
BetDetailModel
)
.
filter_by
(
bet_id
=
bet
.
uuid
)
.
filter
(
BetDetailModel
.
result
!=
'cancelled'
)
.
all
()
)
.
filter
(
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
)
.
all
()
bet_total
=
sum
(
float
(
detail
.
amount
)
for
detail
in
bet_details
)
total_payin
+=
bet_total
total_bets
+=
len
(
bet_details
)
...
...
@@ -8222,7 +8268,8 @@ def download_excel_report():
)
.
filter
(
BetModel
.
bet_datetime
>=
start_datetime
,
BetModel
.
bet_datetime
<=
end_datetime
,
BetDetailModel
.
result
!=
'cancelled'
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
)
bet_details
=
bet_details_query
.
all
()
...
...
@@ -8281,7 +8328,8 @@ def download_excel_report():
)
.
filter
(
BetModel
.
bet_datetime
>=
start_datetime
,
BetModel
.
bet_datetime
<=
end_datetime
,
BetDetailModel
.
result
!=
'cancelled'
BetDetailModel
.
result
!=
'cancelled'
,
BetDetailModel
.
invalidated
==
False
)
match_stats
=
{}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment