Commit 74e4612f authored by Your Name's avatar Your Name

Fix late bet reconciliation

parent 1d11cc1e
......@@ -145,7 +145,9 @@ ehthumbs.db
Thumbs.db
# Project specific
debug/
logs/
packages/
videos/
*.db
*.sqlite
......
......@@ -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.35"
__version__ = "1.0.36"
__author__ = "MBetter Project"
__email__ = "dev@mbetter.net"
__description__ = "Cross-platform multimedia client with video overlay and web dashboard"
......
......@@ -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)
......
......@@ -262,7 +262,7 @@ class ApiConfig:
# Request settings
verify_ssl: bool = True
user_agent: str = "MbetterClient/1.0r35"
user_agent: str = "MbetterClient/1.0r36"
max_response_size_mb: int = 100
# Additional API client settings
......
......@@ -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()
......
......@@ -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 %.2f",
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(),
]
......
......@@ -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')
......
"""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
......@@ -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 = {}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment