remove legacy systems

This commit is contained in:
SowinskiBraeden committed 2026-06-28 10:51:59 -07:00
1 parent 9e6f5bd3ef
commit 79c638b8fa
12 files changed
+178 -1175

No files matched your search

+41 -70
View File
@@ -1,38 +1,31 @@
# Poker Portal
# myboker.org
A small Flask app I put together for tracking our low-stakes hold'em nights without having to keep a spreadsheet open all the time.
Flask app for running home-poker leagues without keeping a spreadsheet open all night.
The idea is pretty simple: public pages for stats and session history, plus a small admin area for recording buy-ins, gross cashout results, actual paid-out cash, and notes. The data sits in a CSV audit log, so everything is append-only and easy to follow later.
The current app is account-based and league-based. League owners/managers record sessions and ledger events, viewers can inspect public-facing league data, and site-admin access is reserved for internal tooling.
## What it does
## What It Does
- all-time leaderboard
- per-session pages
- per-player stat pages
- admin login for recording events
- open / closed session tracking
- actual cash payout tracking with `paid_out` events
- debt repayments and write-offs with `debt_repayment` / `writeoff` events
- session and player charts
- CSV import / export from the admin page
- append-only `entries.csv` ledger instead of overwriting old rows
- account signup, login, and email verification
- league creation, invitations, ownership transfer, and member roles
- player, season, session, and ledger management per league
- append-only ledger events with void/correction support
- public/private league visibility
- leaderboards, charts, dashboard stats, and session summaries
- CSV import/export for league ledgers
## Stack
Built with a pretty lightweight setup:
- Python
- Flask
- SQLAlchemy / Flask-Migrate
- Jinja templates
- Chart.js
- plain CSS
- CSV event log for storage
## Ledger model
## Ledger Model
The app treats `data/entries.csv` as the source of truth.
Each row is an event, not a final snapshot. Instead of editing an old row, I append another one. That keeps rebuys, corrections, payouts, and session state changes visible in the log instead of hiding them behind edits.
Each ledger row is an event, not a final snapshot. Corrections are made by appending or voiding events, so the history remains auditable.
Current event types:
@@ -49,17 +42,7 @@ Current event types:
- `session_open`
- `session_close`
A few examples:
- another `buyin` for a rebuy
- another `cashout` if chip counts are corrected later
- a `paid_out` event when someone is actually settled up
- a `debt_repayment` event when a front is repaid outside poker
- a `writeoff` event when a front will not be collected
- a `note` event for bookkeeping context
- `session_open` / `session_close` to mark whether a game night is still live
Accounting in the app keeps poker results separate from banker cashflow:
Accounting keeps poker results separate from banker cashflow:
- poker investment is `buyin + front + rollover_in`
- poker net is `cashout - poker investment`
@@ -67,30 +50,12 @@ Accounting in the app keeps poker results separate from banker cashflow:
- real cash out is `paid_out`
- `rollover_out` settles the source session without counting as cash out
- `rollover_in` enters play in the destination session without counting as cash in
- `payout_carry_in` records prior-session value carried into a later payout; it increases the destination payout due without counting as poker investment or cash in
- `payout_carry_in` records prior-session value carried into a later payout without counting as poker investment or cash in
- `writeoff` resolves a receivable without counting as cash in
Older ledgers may still contain `paid`, `front_collected`, or `front_writeoff`.
The app reads those historical names as aliases for `paid_out`,
`debt_repayment`, and `writeoff`.
Older imports may still contain `paid`, `front_collected`, or `front_writeoff`; those are read as aliases for `paid_out`, `debt_repayment`, and `writeoff`.
It is still just a small side project, but I wanted the event model to stay clean enough that the numbers are easy to trust and the history is easy to read back through.
## CSV format
Main file:
`data/entries.csv`
Header:
```csv
id,created_at,session_id,session_date,player_name,event_type,amount_cents,note,actor
```
Amounts are stored in cents to avoid floating-point issues.
## Running it locally
## Running Locally
```bash
python -m venv .venv
@@ -102,31 +67,37 @@ python app.py
Then open:
`http://127.0.0.1:8000`
`http://127.0.0.1:5000`
## Environment values
## Environment Values
The app reads these from `.env`:
- `SECRET_KEY`
- `ADMIN_USERNAME`
- `ADMIN_PASSWORD`
- `DATABASE_URL`
- `APP_BASE_URL`
- `MAIL_SERVER`
- `MAIL_PORT`
- `MAIL_USE_TLS`
- `MAIL_USE_SSL`
- `MAIL_USERNAME`
- `MAIL_PASSWORD`
- `MAIL_DEFAULT_SENDER`
Example:
## Site Admin Access
```env
SECRET_KEY=change-this
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me
Internal admin access uses normal database-backed accounts, not hardcoded `.env` credentials.
Grant access:
```bash
flask --app app grant-site-admin user@example.com
```
## Notes
Revoke access:
A few choices here were deliberate:
```bash
flask --app app revoke-site-admin user@example.com
```
- no database for now
- no user accounts, just one admin login
- public-facing stats pages, admin-only controls
- CSV backup before importing a replacement ledger
If I ever decide to take it further, the first real upgrade would probably be moving the storage layer to SQLite while keeping the rest of the app roughly the same.
The old single-password `/admin` system has been removed.
+41 -1
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import click
from flask import Flask, render_template
from auth import current_user_id, is_logged_in
from auth import current_user_id, is_logged_in, normalize_email
from config import Config
from db import database_extensions_available, db, init_database
from extensions import csrf, limiter, mail
@@ -62,6 +62,46 @@ def create_app(config_overrides: dict | None = None) -> Flask:
click.echo("Initialized database tables.")
@app.cli.command("grant-site-admin")
@click.argument("email")
def grant_site_admin_command(email: str) -> None:
if not database_extensions_available() or db is None:
raise click.ClickException(
"Database dependencies are not installed. Run pip install -r requirements.txt."
)
from db_models import User
normalized_email = normalize_email(email)
with app.app_context():
user = User.query.filter_by(email=normalized_email, disabled_at=None).one_or_none()
if user is None:
raise click.ClickException(f"No active user found for {normalized_email}.")
user.is_site_admin = True
db.session.commit()
click.echo(f"Granted site admin access to {normalized_email}.")
@app.cli.command("revoke-site-admin")
@click.argument("email")
def revoke_site_admin_command(email: str) -> None:
if not database_extensions_available() or db is None:
raise click.ClickException(
"Database dependencies are not installed. Run pip install -r requirements.txt."
)
from db_models import User
normalized_email = normalize_email(email)
with app.app_context():
user = User.query.filter_by(email=normalized_email).one_or_none()
if user is None:
raise click.ClickException(f"No user found for {normalized_email}.")
user.is_site_admin = False
db.session.commit()
click.echo(f"Revoked site admin access from {normalized_email}.")
return app
+30 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from functools import wraps
from flask import current_app, flash, redirect, request, session as flask_session, url_for
from flask import abort, current_app, flash, redirect, request, session as flask_session, url_for
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from werkzeug.security import check_password_hash, generate_password_hash
@@ -51,7 +51,6 @@ def normalize_email(email: str) -> str:
def log_user_in(user_id: str) -> None:
flask_session.pop("is_admin", None)
flask_session["user_id"] = user_id
@@ -68,6 +67,22 @@ def is_logged_in() -> bool:
return current_user_id() is not None
def is_site_admin() -> bool:
user_id = current_user_id()
if not user_id:
return False
from db import database_extensions_available
if not database_extensions_available():
return False
from db_models import User
user = User.query.filter_by(id=user_id, disabled_at=None).one_or_none()
return bool(user and user.is_site_admin)
def login_required(view):
@wraps(view)
def wrapped_view(*args, **kwargs):
@@ -77,3 +92,16 @@ def login_required(view):
return view(*args, **kwargs)
return wrapped_view
def site_admin_required(view):
@wraps(view)
def wrapped_view(*args, **kwargs):
if not is_logged_in():
flash("Login required.", "error")
return redirect(url_for("account.login", next=request.full_path))
if not is_site_admin():
abort(403)
return view(*args, **kwargs)
return wrapped_view
+1 -1
View File
@@ -9,7 +9,7 @@ DATA_PATH = BASE_DIR / "data" / "entries.csv"
DEFAULT_DATABASE_URL = f"sqlite:///{BASE_DIR / 'data' / 'boker-dev.sqlite3'}"
ELIGIBLE_MIN_SESSIONS = 3
APP_VERSION = "2.5.23"
APP_VERSION = "2.5.24"
def load_local_env(env_path: Path) -> None:
+1
View File
@@ -77,6 +77,7 @@ class User(TimestampMixin, db.Model):
email_verification_sent_at = db.Column(db.DateTime(timezone=True), nullable=True)
last_login_at = db.Column(db.DateTime(timezone=True), nullable=True)
disabled_at = db.Column(db.DateTime(timezone=True), nullable=True)
is_site_admin = db.Column(db.Boolean, nullable=False, default=False)
class League(TimestampMixin, db.Model):
@@ -0,0 +1,36 @@
"""add account-backed site admin flag
Revision ID: 0005_site_admin_accounts
Revises: 0004_email_verification_codes
Create Date: 2026-06-28
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0005_site_admin_accounts"
down_revision = "0004_email_verification_codes"
branch_labels = None
depends_on = None
def upgrade() -> None:
columns = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("users")}
if "is_site_admin" not in columns:
op.add_column(
"users",
sa.Column(
"is_site_admin",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
)
)
def downgrade() -> None:
columns = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("users")}
if "is_site_admin" in columns:
op.drop_column("users", "is_site_admin")
-584
View File
@@ -1,584 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import csv
from datetime import datetime, timezone
from flask import (
Blueprint,
current_app,
flash,
redirect,
render_template,
request,
send_file,
session as flask_session,
url_for,
)
from auth import is_admin
from services import (
build_session_summaries,
next_session_id,
pending_payout_carry_items,
prunable_empty_session_ids,
unique_player_names,
)
from storage import CSV_HEADERS, append_event, load_events, write_events
from utils import cents_to_dollars, session_label
admin_bp = Blueprint("admin", __name__)
ADMIN_EVENT_TYPES = [
("buyin", "Buy-in", "var(--accent)"),
("front", "Front", "var(--warn)"),
("rollover_in", "Rollover-in", "var(--rolled)"),
("payout_carry_in", "Payout carry-in", "var(--rolled)"),
("cashout", "Cashout result", "var(--rank-2)"),
("paid_out", "Paid out cash", "var(--pos)"),
("rollover_out", "Rollover-out", "var(--rolled)"),
("note", "Note", "var(--muted)"),
]
@admin_bp.route("/admin/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if (
username == current_app.config["ADMIN_USERNAME"]
and password == current_app.config["ADMIN_PASSWORD"]
):
flask_session.pop("user_id", None)
flask_session["is_admin"] = True
flash("Admin login successful.", "success")
return redirect(url_for("admin.dashboard"))
flash("Invalid admin credentials.", "error")
return render_template("admin_login.html")
@admin_bp.post("/admin/logout")
def logout():
flask_session.clear()
flash("Logged out.", "success")
return redirect(url_for("public.home"))
@admin_bp.post("/admin/session-state")
def session_state():
if not is_admin():
flash("Admin login required.", "error")
return redirect(url_for("admin.login"))
data_path = current_app.config["DATA_PATH"]
session_id = request.form.get("session_id", "").strip()
state = request.form.get("state", "").strip()
events = load_events(data_path)
sessions = build_session_summaries(events)
by_id = {s.session_id: s for s in sessions}
target = by_id.get(session_id)
if target is None:
flash("Session not found.", "error")
return redirect(url_for("admin.dashboard"))
if state not in {"open", "closed"}:
flash("Invalid session state.", "error")
return redirect(url_for("admin.dashboard"))
append_event(
data_path,
session_id=target.session_id,
session_date=target.session_date,
amount_cents=0,
player_name="",
event_type="session_open" if state == "open" else "session_close",
note=f"Session marked {state}.",
actor=current_app.config["ADMIN_USERNAME"],
)
flash(f"Session marked {state}.", "success")
return redirect(url_for("admin.dashboard"))
@admin_bp.get("/admin/export")
@admin_bp.get("/admin/export.csv")
def export_csv():
if not is_admin():
flash("Admin login required.", "error")
return redirect(url_for("admin.login"))
data_path = current_app.config["DATA_PATH"]
export_name = (
f"entries_export_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.csv"
)
return send_file(
data_path,
as_attachment=True,
download_name=export_name,
mimetype="text/csv",
)
@admin_bp.post("/admin/import")
def import_csv():
if not is_admin():
flash("Admin login required.", "error")
return redirect(url_for("admin.login"))
data_path = current_app.config["DATA_PATH"]
uploaded_file = request.files.get("csv_file")
if uploaded_file is None or uploaded_file.filename == "":
flash("Choose a CSV file to import.", "error")
return redirect(url_for("admin.dashboard"))
if not uploaded_file.filename.lower().endswith(".csv"):
flash("Only CSV files are allowed.", "error")
return redirect(url_for("admin.dashboard"))
try:
uploaded_text = uploaded_file.stream.read().decode("utf-8-sig")
except UnicodeDecodeError:
flash("CSV file must be valid UTF-8 text.", "error")
return redirect(url_for("admin.dashboard"))
uploaded_lines = uploaded_text.splitlines()
if not uploaded_lines:
flash("CSV file is empty.", "error")
return redirect(url_for("admin.dashboard"))
try:
uploaded_header = next(csv.reader(uploaded_lines))
except Exception:
flash("Could not read the uploaded CSV header.", "error")
return redirect(url_for("admin.dashboard"))
if uploaded_header != CSV_HEADERS:
flash("CSV header does not match the current audit format.", "error")
return redirect(url_for("admin.dashboard"))
try:
existing_ids = {event["id"] for event in load_events(data_path)}
reader = csv.DictReader(uploaded_lines)
new_rows = []
skipped_count = 0
for row in reader:
event_id = row.get("id", "")
if not event_id or event_id in existing_ids:
skipped_count += 1
continue
new_rows.append({header: row.get(header, "") for header in CSV_HEADERS})
existing_ids.add(event_id)
if new_rows:
with open(data_path, "a", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=CSV_HEADERS)
writer.writerows(new_rows)
flash(
f"Imported {len(new_rows)} new events, skipped {skipped_count} duplicates.",
"success",
)
except Exception:
flash("Import failed. Existing CSV was not updated.", "error")
return redirect(url_for("admin.dashboard"))
@admin_bp.post("/admin/write-off-front")
def write_off_front():
if not is_admin():
flash("Admin login required.", "error")
return redirect(url_for("admin.login"))
data_path = current_app.config["DATA_PATH"]
debt_key = request.form.get("debt_key", "").strip()
note = request.form.get("note", "").strip()
amount_raw = request.form.get("amount", "0").strip()
try:
amount_cents = int(round(float(amount_raw) * 100))
except ValueError:
flash("Amount must be a number.", "error")
return redirect(url_for("admin.dashboard"))
if amount_cents <= 0:
flash("Write-off amount must be greater than zero.", "error")
return redirect(url_for("admin.dashboard"))
try:
session_id, player_name = debt_key.split("||", 1)
except ValueError:
flash("Select a valid player debt.", "error")
return redirect(url_for("admin.dashboard"))
events = load_events(data_path)
sessions = build_session_summaries(events)
target = next((s for s in sessions if s.session_id == session_id), None)
if target is None:
flash("Session not found.", "error")
return redirect(url_for("admin.dashboard"))
entry = next(
(e for e in target.entries if e.player_name == player_name),
None,
)
if entry is None or entry.player_owes_cents <= 0:
flash("That player does not have an outstanding debt.", "error")
return redirect(url_for("admin.dashboard"))
if amount_cents > entry.player_owes_cents:
flash(
f"Write-off cannot exceed {cents_to_dollars(entry.player_owes_cents)}.",
"error",
)
return redirect(url_for("admin.dashboard"))
append_event(
data_path,
session_id=target.session_id,
session_date=target.session_date,
player_name=entry.player_name,
event_type="writeoff",
amount_cents=amount_cents,
note=note or "Debt written off.",
actor=current_app.config["ADMIN_USERNAME"],
)
flash("Debt written off.", "success")
return redirect(url_for("admin.dashboard"))
@admin_bp.post("/admin/collect-front")
def collect_front():
if not is_admin():
flash("Admin login required.", "error")
return redirect(url_for("admin.login"))
data_path = current_app.config["DATA_PATH"]
debt_key = request.form.get("debt_key", "").strip()
note = request.form.get("note", "").strip()
amount_raw = request.form.get("amount", "0").strip()
try:
amount_cents = int(round(float(amount_raw) * 100))
except ValueError:
flash("Amount must be a number.", "error")
return redirect(url_for("admin.dashboard"))
if amount_cents <= 0:
flash("Collected amount must be greater than zero.", "error")
return redirect(url_for("admin.dashboard"))
try:
session_id, player_name = debt_key.split("||", 1)
except ValueError:
flash("Select a valid player debt.", "error")
return redirect(url_for("admin.dashboard"))
events = load_events(data_path)
sessions = build_session_summaries(events)
target = next((s for s in sessions if s.session_id == session_id), None)
if target is None:
flash("Session not found.", "error")
return redirect(url_for("admin.dashboard"))
entry = next(
(e for e in target.entries if e.player_name == player_name),
None,
)
if entry is None or entry.player_owes_cents <= 0:
flash("That player does not have an outstanding debt.", "error")
return redirect(url_for("admin.dashboard"))
if amount_cents > entry.player_owes_cents:
flash(
f"Collection cannot exceed {cents_to_dollars(entry.player_owes_cents)}.",
"error",
)
return redirect(url_for("admin.dashboard"))
append_event(
data_path,
session_id=target.session_id,
session_date=target.session_date,
player_name=entry.player_name,
event_type="debt_repayment",
amount_cents=amount_cents,
note=note or "Debt repayment collected.",
actor=current_app.config["ADMIN_USERNAME"],
)
flash("Debt repayment collected.", "success")
return redirect(url_for("admin.dashboard"))
@admin_bp.post("/admin/open-session")
def open_session():
if not is_admin():
flash("Admin login required.", "error")
return redirect(url_for("admin.login"))
data_path = current_app.config["DATA_PATH"]
session_date = request.form.get("session_date", "").strip()
if not session_date:
flash("Session date is required.", "error")
return redirect(url_for("admin.dashboard"))
events = load_events(data_path)
sessions = build_session_summaries(events)
new_session_id = next_session_id(sessions, session_date)
append_event(
data_path,
session_id=new_session_id,
session_date=session_date,
amount_cents=0,
player_name="",
event_type="session_open",
note="Session opened.",
actor=current_app.config["ADMIN_USERNAME"],
)
flash(f"Opened session {new_session_id}.", "success")
return redirect(url_for("admin.dashboard"))
@admin_bp.post("/admin/prune-empty-sessions")
def prune_empty_sessions():
if not is_admin():
flash("Admin login required.", "error")
return redirect(url_for("admin.login"))
data_path = current_app.config["DATA_PATH"]
events = load_events(data_path)
sessions = build_session_summaries(events)
prunable_ids = prunable_empty_session_ids(events, sessions)
if not prunable_ids:
flash("No empty marker-only sessions to prune.", "success")
return redirect(url_for("admin.dashboard"))
pruned_events = [
event
for event in events
if (event["session_id"].strip() or event["session_date"].strip())
not in prunable_ids
]
write_events(data_path, pruned_events)
flash(
f"Pruned {len(prunable_ids)} empty session"
f"{'s' if len(prunable_ids) != 1 else ''}.",
"success",
)
return redirect(url_for("admin.dashboard"))
@admin_bp.post("/admin/apply-payout-carry-in")
def apply_payout_carry_in():
if not is_admin():
flash("Admin login required.", "error")
return redirect(url_for("admin.login"))
data_path = current_app.config["DATA_PATH"]
session_id = request.form.get("session_id", "").strip()
player_name = request.form.get("player_name", "").strip()
events = load_events(data_path)
sessions = build_session_summaries(events)
by_id = {s.session_id: s for s in sessions}
target = by_id.get(session_id)
if target is None or target.status != "open":
flash("Select a valid open session.", "error")
return redirect(url_for("admin.dashboard"))
if not any(e.player_name == player_name for e in target.entries):
flash("Add that player to the open session before applying carry-in.", "error")
return redirect(url_for("admin.dashboard"))
pending_by_player = {
str(item["player_name"]): int(item["amount_cents"])
for item in pending_payout_carry_items(sessions)
}
amount_cents = pending_by_player.get(player_name, 0)
if amount_cents <= 0:
flash("No pending payout carry-in found for that player.", "error")
return redirect(url_for("admin.dashboard"))
append_event(
data_path,
session_id=target.session_id,
session_date=target.session_date,
player_name=player_name,
event_type="payout_carry_in",
amount_cents=amount_cents,
note="Payout carry-in from prior carry-out.",
actor=current_app.config["ADMIN_USERNAME"],
)
flash(
f"Applied {cents_to_dollars(amount_cents)} payout carry-in for {player_name}.",
"success",
)
return redirect(url_for("admin.dashboard"))
@admin_bp.route("/admin", methods=["GET", "POST"])
def dashboard():
if not is_admin():
return redirect(url_for("admin.login"))
data_path = current_app.config["DATA_PATH"]
if request.method == "POST":
session_id = request.form.get("session_id", "").strip()
player_name = request.form.get("player_name", "").strip()
event_type = request.form.get("event_type", "").strip()
note = request.form.get("note", "").strip()
amount_raw = request.form.get("amount", "0").strip()
try:
amount_cents = int(round(float(amount_raw) * 100))
except ValueError:
flash("Amount must be a number.", "error")
return redirect(url_for("admin.dashboard"))
events = load_events(data_path)
sessions = build_session_summaries(events)
by_id = {s.session_id: s for s in sessions}
target = by_id.get(session_id)
if target is None:
flash("Select a valid open session.", "error")
return redirect(url_for("admin.dashboard"))
if target.status != "open":
flash(
"That session is closed. Reopen it first if you need to add events.",
"error",
)
return redirect(url_for("admin.dashboard"))
append_event(
data_path,
session_id=target.session_id,
session_date=target.session_date,
player_name=player_name,
event_type=event_type,
amount_cents=amount_cents,
note=note,
actor=current_app.config["ADMIN_USERNAME"],
)
flash("Event added.", "success")
return redirect(
url_for(
"admin.dashboard",
session_id=session_id,
player_name=player_name,
event_type=event_type,
)
)
events = load_events(data_path)
sessions = build_session_summaries(events)
empty_session_count = len(prunable_empty_session_ids(events, sessions))
pending_carry_items = pending_payout_carry_items(sessions)
pending_carry_total_cents = sum(
int(item["amount_cents"]) for item in pending_carry_items
)
open_sessions = [s for s in sessions if s.status == "open"]
live_session = open_sessions[0] if open_sessions else None
live_session_player_names = (
{e.player_name for e in live_session.entries}
if live_session is not None
else set()
)
live_session_no = ""
live_unresolved_count = 0
if live_session is not None:
live_session_no = f"#{len(sessions):03d}"
live_unresolved_count = sum(
1
for e in live_session.entries
if e.current_due_cents > 0 or e.player_owes_cents > 0
)
recent_events = list(reversed(events[-20:]))
event_type_colors = {value: color for value, _label, color in ADMIN_EVENT_TYPES}
event_type_labels = {value: label for value, label, _color in ADMIN_EVENT_TYPES}
selected_event_type = request.args.get("event_type", "buyin").strip()
if selected_event_type not in event_type_colors:
selected_event_type = "buyin"
append_form = {
"session_id": request.args.get("session_id", "").strip(),
"player_name": request.args.get("player_name", "").strip(),
"event_type": selected_event_type,
}
debt_entries = [
{"session": s, "entry": e}
for s in sessions
for e in s.entries
if e.player_owes_cents > 0
]
cash_in_cents = sum(s.total_banker_cash_in_cents for s in sessions)
cash_out_cents = sum(s.total_banker_cash_out_cents for s in sessions)
house_owes_players_cents = (
sum(s.total_current_due_to_player_cents for s in sessions)
+ pending_carry_total_cents
)
players_owe_house_cents = sum(
s.total_current_due_to_house_cents for s in sessions
)
admin_totals = {
"cash_in_cents": cash_in_cents,
"cash_out_cents": cash_out_cents,
"house_owes_players_cents": house_owes_players_cents,
"players_owe_house_cents": players_owe_house_cents,
"pending_carry_cents": pending_carry_total_cents,
"collected_cents": sum(s.total_debt_repayment_cents for s in sessions),
"written_off_cents": sum(s.total_writeoff_cents for s in sessions),
"net_book_position_cents": (
cash_in_cents
- cash_out_cents
+ players_owe_house_cents
- house_owes_players_cents
),
}
return render_template(
"admin_dashboard.html",
open_sessions=open_sessions,
recent_sessions=sessions,
recent_events=recent_events,
debt_entries=debt_entries,
live_session=live_session,
live_session_player_names=live_session_player_names,
live_session_no=live_session_no,
live_unresolved_count=live_unresolved_count,
pending_carry_items=pending_carry_items,
player_names=unique_player_names(events),
append_form=append_form,
event_types=ADMIN_EVENT_TYPES,
event_type_colors=event_type_colors,
event_type_labels=event_type_labels,
admin_today=datetime.now().date().isoformat(),
session_label=session_label,
admin_totals=admin_totals,
empty_session_count=empty_session_count,
)
-436
View File
@@ -1,436 +0,0 @@
{% extends "base.html" %}
{% block title %}Admin · myboker.org{% endblock %}
{% block page_class %}page--admin{% endblock %}
{% block content %}
<div class="hero" style="padding-bottom:12px;">
<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;">
<p class="hero__kicker" style="margin:0;">Admin</p>
{% if open_sessions %}
<span class="live-badge">{{ open_sessions|length }} session{{ 's' if open_sessions|length != 1 else '' }} live</span>
{% endif %}
</div>
<h1 class="hero__title">Ledger controls</h1>
<p class="hero__sub">Append events to the audit trail. Player ledger history stays permanent.</p>
</div>
<!-- Banker cash/book position. Cash tiles are real money only; rollovers only
affect receivables/payables through the session settlement formulas. -->
<div class="cash-strip">
<div class="stat stat--rail" style="--rail:var(--accent);">
<span class="kicker">Cash in</span>
<div class="stat__val" style="color:var(--num)">{{ admin_totals.cash_in_cents | money }}</div>
</div>
<div class="stat stat--rail" style="--rail:var(--pos);">
<span class="kicker">Cash paid out</span>
<div class="stat__val" style="color:var(--pos)">{{ admin_totals.cash_out_cents | money }}</div>
</div>
<div class="stat stat--rail" style="--rail:var(--neg);">
<span class="kicker">House owes players</span>
<div class="stat__val {{ 'num-neg' if admin_totals.house_owes_players_cents > 0 else '' }}">{{ admin_totals.house_owes_players_cents | money }}</div>
</div>
<div class="stat stat--rail" style="--rail:var(--owes);">
<span class="kicker">Players owe house</span>
<div class="stat__val {{ 'num-neg' if admin_totals.players_owe_house_cents > 0 else '' }}">{{ admin_totals.players_owe_house_cents | money }}</div>
</div>
<div class="stat stat--rail" style="--rail:{{ 'var(--pos)' if admin_totals.net_book_position_cents == 0 else 'var(--warn)' }};">
<span class="kicker">Net book position</span>
<div class="stat__val {% if admin_totals.net_book_position_cents == 0 %}num-muted{% elif admin_totals.net_book_position_cents > 0 %}num-pos{% else %}num-neg{% endif %}">
{{ admin_totals.net_book_position_cents | money }}
</div>
<div class="stat__sub">cash in - cash paid out + receivables - payables</div>
</div>
</div>
<!-- Admin workspace -->
<div class="admin-workspace">
<form class="panel append" method="post" action="{{ url_for('admin.dashboard') }}">
<div class="panel__head">
<h2 class="panel__title">Append ledger event</h2>
<span class="panel__tag">fast entry</span>
</div>
<div class="append__body">
<div class="append__grid">
<label class="field">
<span class="field__label">Session</span>
<select class="control" name="session_id" required>
<option value="">Select session</option>
{% for s in open_sessions %}
<option value="{{ s.session_id }}" {{ 'selected' if append_form.session_id == s.session_id else '' }}>
{{ session_label(s) }}{{ ' (live)' if s.status == 'open' else '' }}
</option>
{% endfor %}
</select>
</label>
<label class="field">
<span class="field__label">Player</span>
<input class="control" list="player_names_list" type="text" name="player_name" placeholder="Player name" value="{{ append_form.player_name }}" required>
<datalist id="player_names_list">
{% for name in player_names %}<option value="{{ name }}"></option>{% endfor %}
</datalist>
</label>
<label class="field">
<span class="field__label">Event type</span>
<select class="control" name="event_type" required id="eventTypeSelect">
{% for value, label, color in event_types %}
<option value="{{ value }}" data-color="{{ color }}" data-label="{{ label }}" {{ 'selected' if append_form.event_type == value else '' }}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="field">
<span class="field__label">Amount</span>
<span class="control control--money">
<span class="control__prefix">$</span>
<input name="amount" inputmode="decimal" placeholder="0" autofocus>
</span>
</label>
</div>
<label class="field append__note">
<span class="field__label">Note <span class="field__opt">(optional)</span></span>
<textarea class="control append__textarea" name="note" placeholder="e.g. fronted buy-in, settle next week"></textarea>
</label>
<div class="append__footer">
<button type="submit" class="btn btn--primary">Append event</button>
<span class="append__preview">Will record as
<span class="evtag-pill" id="eventTypePreview" style="color:{{ event_type_colors[append_form.event_type] }}">{{ event_type_labels[append_form.event_type] | upper }}</span>
</span>
</div>
{% if pending_carry_items %}
<div class="carry-recs">
<div class="micro">Recommended carry-ins</div>
{% for item in pending_carry_items %}
<div class="carry-rec">
<span>{{ item.player_name }}</span>
<strong>{{ item.amount_cents | money }}</strong>
{% if live_session and item.player_name in live_session_player_names %}
<form method="post" action="{{ url_for('admin.apply_payout_carry_in') }}">
<input type="hidden" name="session_id" value="{{ live_session.session_id }}">
<input type="hidden" name="player_name" value="{{ item.player_name }}">
<button class="table-action-button" type="submit">Apply</button>
</form>
{% elif live_session %}
<em>Add buy-in first</em>
{% else %}
<em>Open session</em>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
</form>
<div class="panel panel--pad session-control-panel">
<h2 class="panel__title panel__title--mb">Session controls</h2>
<div class="session-summary-strip">
<div>
<span class="micro">Today</span>
<strong>{{ admin_today }}</strong>
</div>
<div>
<span class="micro">Empty</span>
<strong>{{ empty_session_count }}</strong>
</div>
</div>
{% if live_session %}
<div class="livecard">
<div class="livecard__head">
<span class="dot dot--live"></span>
<span class="livecard__name">{{ session_label(live_session) }}</span>
<span class="livecard__no">{{ live_session_no }}</span>
</div>
<div class="livecard__metrics">
<div><div class="micro">Invested</div><div class="mono16">{{ live_session.total_invested_cents | money }}</div></div>
<div><div class="micro">Seated</div><div class="mono16">{{ live_session.entries|length }}</div></div>
<div><div class="micro">Open</div><div class="mono16 mono16--warn">{{ live_unresolved_count }}</div></div>
</div>
<form method="post" action="{{ url_for('admin.session_state') }}"
{% if live_unresolved_count > 0 %}data-close-warning="true"{% endif %}>
<input type="hidden" name="session_id" value="{{ live_session.session_id }}">
<button class="btn-close" type="submit" name="state" value="closed">Close session</button>
</form>
</div>
{% else %}
<div class="livecard livecard--empty">
<div class="livecard__head">
<span class="dot dot--idle"></span>
<span class="livecard__name">No open session</span>
<span class="livecard__no">idle</span>
</div>
<div class="livecard__metrics">
<div><div class="micro">Invested</div><div class="mono16">{{ 0 | money }}</div></div>
<div><div class="micro">Seated</div><div class="mono16">0</div></div>
<div><div class="micro">Open</div><div class="mono16">0</div></div>
</div>
</div>
{% endif %}
<form method="post" action="{{ url_for('admin.open_session') }}">
<input type="hidden" name="session_date" value="{{ admin_today }}">
<button class="btn-newsession {{ 'btn-newsession--primary' if not live_session else '' }}" type="submit">+ Open new session</button>
</form>
<form method="post" action="{{ url_for('admin.prune_empty_sessions') }}" class="prune-session-form">
<button class="secondary-button" type="submit" {% if empty_session_count == 0 %}disabled{% endif %}>
Prune empty sessions{% if empty_session_count > 0 %} ({{ empty_session_count }}){% endif %}
</button>
</form>
</div>
<div class="panel panel--pad import-export-panel">
<h2 class="panel__title" style="margin:0 0 4px">Import / export</h2>
<p class="csv__desc">Back up or migrate the raw event ledger.</p>
<form method="post" action="{{ url_for('admin.import_csv') }}" enctype="multipart/form-data" class="import-form">
<div class="csv__btns">
<a class="csv-btn-ghost" href="{{ url_for('admin.export_csv') }}">&darr; Export CSV</a>
<label class="csv-btn-ghost" for="csvTopImport">&uarr; Import CSV</label>
</div>
<input id="csvTopImport" type="file" accept=".csv,text/csv" name="csv_file" hidden onchange="this.form.submit()">
<label class="csv__drop" id="csvDrop" for="csvTopImport">Drop ledger.csv to import</label>
</form>
</div>
</div><!-- /.admin-workspace -->
<!-- Debt resolution -->
<div class="panel debt-panel">
<div class="panel__head">
<div>
<span class="kicker" style="margin:0;">Front receivables</span>
<h2 class="panel__title">Debt resolution</h2>
</div>
<div class="debt-summary">
<span class="badge badge--owes">Open {{ admin_totals.players_owe_house_cents | money }}</span>
<span class="badge badge--paid">Cash repaid {{ admin_totals.collected_cents | money }}</span>
<span class="badge badge--written_off">Written off {{ admin_totals.written_off_cents | money }}</span>
</div>
</div>
{% if debt_entries %}
<div class="debt-layout">
<div class="debt-list">
{% for item in debt_entries %}
{% set s = item.session %}{% set e = item.entry %}
<div class="debt-list-item">
<div>
<strong>{{ e.player_name }}</strong>
<span>{{ session_label(s) }}</span>
</div>
<b>{{ e.player_owes_cents | money }}</b>
</div>
{% endfor %}
</div>
<div class="debt-action-grid">
<form class="form-card debt-form" method="post" action="{{ url_for('admin.collect_front') }}">
<h3>Collect debt</h3>
<label>
<span>Outstanding debt</span>
<select name="debt_key" required>
<option value="">Select debt</option>
{% for item in debt_entries %}
{% set s = item.session %}{% set e = item.entry %}
<option value="{{ s.session_id }}||{{ e.player_name }}">
{{ e.player_name }} · {{ session_label(s) }} · owes {{ e.player_owes_cents | money }}
</option>
{% endfor %}
</select>
</label>
<label>
<span>Amount collected</span>
<input type="number" step="0.01" name="amount" placeholder="0.00" required>
</label>
<label>
<span>Note</span>
<textarea name="note" rows="2" placeholder="E-transfer received, cash, etc."></textarea>
</label>
<button class="primary-button" type="submit">Mark collected</button>
</form>
<form class="form-card debt-form" method="post" action="{{ url_for('admin.write_off_front') }}">
<h3>Write off debt</h3>
<label>
<span>Outstanding debt</span>
<select name="debt_key" required>
<option value="">Select debt</option>
{% for item in debt_entries %}
{% set s = item.session %}{% set e = item.entry %}
<option value="{{ s.session_id }}||{{ e.player_name }}">
{{ e.player_name }} · {{ session_label(s) }} · owes {{ e.player_owes_cents | money }}
</option>
{% endfor %}
</select>
</label>
<label>
<span>Amount to write off</span>
<input type="number" step="0.01" name="amount" placeholder="0.00" required>
</label>
<label>
<span>Note</span>
<textarea name="note" rows="2" placeholder="Won't be collected."></textarea>
</label>
<button class="secondary-button" type="submit">Write off debt</button>
</form>
</div>
</div>
{% else %}
<div class="debt-empty">
<div class="debt-empty__mark">$0</div>
<p>No open player debts right now.</p>
</div>
{% endif %}
</div>
<div class="admin-bottom-grid">
<div class="panel">
<div class="panel__head">
<div>
<span class="kicker" style="margin:0;">All sessions</span>
<h2 class="panel__title">Current totals</h2>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Session</th>
<th>Players</th>
<th>Cash in</th>
<th>Cash paid out</th>
<th>House owes players</th>
<th>Players owe house</th>
<th>Book position</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% for s in recent_sessions %}
<tr>
<td><a href="{{ url_for('public.session_detail', session_id=s.session_id) }}">{{ session_label(s) }}</a></td>
<td style="font-family:var(--font-mono);color:var(--num)">{{ s.entries|length }}</td>
<td style="font-family:var(--font-mono);color:var(--num)">{{ s.total_banker_cash_in_cents | money }}</td>
<td style="font-family:var(--font-mono);color:var(--pos)">{{ s.total_banker_cash_out_cents | money }}</td>
<td class="{{ 'negative' if s.total_current_due_to_player_cents > 0 else '' }}" style="font-family:var(--font-mono)">{{ s.total_current_due_to_player_cents | money }}</td>
<td class="{{ 'negative' if s.total_current_due_to_house_cents > 0 else '' }}" style="font-family:var(--font-mono)">{{ s.total_current_due_to_house_cents | money }}</td>
<td class="{% if s.total_net_book_position_cents > 0 %}positive{% elif s.total_net_book_position_cents < 0 %}negative{% endif %}" style="font-family:var(--font-mono)">{{ s.total_net_book_position_cents | money }}</td>
<td><span class="status-badge status-{{ s.status }}">{{ s.status }}</span></td>
<td>
<form method="post" action="{{ url_for('admin.session_state') }}" class="inline-action-form">
<input type="hidden" name="session_id" value="{{ s.session_id }}">
{% if s.status == "open" %}
<button class="table-action-button" type="submit" name="state" value="closed">Close</button>
{% else %}
<button class="table-action-button" type="submit" name="state" value="open">Reopen</button>
{% endif %}
</form>
</td>
</tr>
{% else %}
<tr><td colspan="9">No sessions yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="panel">
<div class="panel__head">
<div>
<span class="kicker" style="margin:0;">Recent events</span>
<h2 class="panel__title">Ledger feed</h2>
</div>
<span class="badge badge--closed" style="font-size:9.5px;">append-only</span>
</div>
<div class="event-feed feed feed--inline">
{% for event in recent_events %}
<div class="event-card event-card--inline">
<div class="event-card-top">
<strong>{{ event.player_name or "Session" }}</strong>
<span class="pill pill-{{ event.event_type }}">{{ event.event_type | replace('_', ' ') }}</span>
{% if event.note %}<span class="event-note">{{ event.note }}</span>{% endif %}
</div>
<p class="event-amount">{{ event.amount_cents | money }}</p>
<p class="tiny-text">{{ event.session_date | pretty_date }}</p>
</div>
{% else %}
<p class="muted-text" style="padding:20px;">No recent events.</p>
{% endfor %}
</div>
</div>
</div>
<div class="modal-backdrop" id="closeSessionModal" hidden>
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="closeSessionModalTitle">
<div class="modal-card__head">
<span class="kicker" style="margin:0;">Open settlement</span>
<h2 class="panel__title" id="closeSessionModalTitle">Close session?</h2>
</div>
<p class="modal-card__text">This session has unpaid or uncarried cashouts, or unresolved debts. Closing stops table entry, but the open settlement items will remain tracked.</p>
<div class="modal-card__actions">
<button class="secondary-button" type="button" id="cancelCloseSession">Keep open</button>
<button class="btn btn--primary" type="button" id="confirmCloseSession">Close session</button>
</div>
</div>
</div>
<script>
const eventTypeSelect = document.getElementById('eventTypeSelect');
const eventTypePreview = document.getElementById('eventTypePreview');
eventTypeSelect?.addEventListener('change', () => {
const option = eventTypeSelect.selectedOptions[0];
if (!option || !eventTypePreview) return;
eventTypePreview.style.color = option.dataset.color || 'var(--muted)';
eventTypePreview.textContent = (option.dataset.label || option.value).toUpperCase();
});
const csvDrop = document.getElementById('csvDrop');
const csvInput = document.getElementById('csvTopImport');
csvDrop?.addEventListener('dragover', (event) => {
event.preventDefault();
csvDrop.classList.add('is-drag');
});
csvDrop?.addEventListener('dragleave', () => csvDrop.classList.remove('is-drag'));
csvDrop?.addEventListener('drop', (event) => {
event.preventDefault();
csvDrop.classList.remove('is-drag');
if (!csvInput || !event.dataTransfer?.files?.length) return;
csvInput.files = event.dataTransfer.files;
csvInput.form?.submit();
});
const closeSessionModal = document.getElementById('closeSessionModal');
const cancelCloseSession = document.getElementById('cancelCloseSession');
const confirmCloseSession = document.getElementById('confirmCloseSession');
let pendingCloseForm = null;
document.querySelectorAll('form[data-close-warning="true"]').forEach((form) => {
form.addEventListener('submit', (event) => {
if (form.dataset.confirmed === 'true') return;
event.preventDefault();
pendingCloseForm = form;
closeSessionModal.hidden = false;
confirmCloseSession?.focus();
});
});
cancelCloseSession?.addEventListener('click', () => {
closeSessionModal.hidden = true;
pendingCloseForm = null;
});
closeSessionModal?.addEventListener('click', (event) => {
if (event.target !== closeSessionModal) return;
closeSessionModal.hidden = true;
pendingCloseForm = null;
});
confirmCloseSession?.addEventListener('click', () => {
if (!pendingCloseForm) return;
pendingCloseForm.dataset.confirmed = 'true';
pendingCloseForm.submit();
});
</script>
{% endblock %}
-24
View File
@@ -1,24 +0,0 @@
{% extends "base.html" %}
{% block title %}Admin Login · myboker.org{% endblock %}
{% block content %}
<div class="form-shell narrow">
<form class="panel form-card" method="post">
<div>
<p class="eyebrow">Restricted access</p>
<h1 style="font-size:28px;margin-bottom:8px;">Admin login</h1>
<p class="muted-text" style="margin-bottom:8px;">Only myboker.org manager needs credentials. All stats pages stay public.</p>
</div>
<label>
<span>Username</span>
<input type="text" name="username" autocomplete="username" required>
</label>
<label>
<span>Password</span>
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button class="primary-button" type="submit">Login</button>
</form>
</div>
{% endblock %}
+1 -1
View File
@@ -131,7 +131,7 @@
{% else %}
<tr>
<td colspan="9" style="text-align:left;padding:28px;color:var(--muted);">
No players yet — add the first event from the admin page.
No players yet.
</td>
</tr>
{% endfor %}
+16 -47
View File
@@ -1,15 +1,12 @@
from pathlib import Path
import tempfile
import unittest
import app as app_module
from charts import player_session_series
from services import (
build_leaderboard,
build_session_summaries,
pending_payout_carry_items,
prunable_empty_session_ids,
)
from storage import load_events, write_events
from utils import session_sort_key
@@ -189,7 +186,7 @@ class AccountingTests(unittest.TestCase):
self.assertEqual(entry.paid_out_cents, 0)
self.assertEqual(entry.payout_status, "settled")
def test_admin_can_apply_pending_payout_carry_in_to_open_session(self):
def test_pending_payout_carry_in_preserves_poker_investment(self):
rows = [
event("s1", "2026-01-01", "", "session_open", 0, 1),
event("s1", "2026-01-01", "A", "buyin", 10, 2),
@@ -200,32 +197,14 @@ class AccountingTests(unittest.TestCase):
event("s2", "2026-01-02", "A", "buyin", 10, 7),
]
with tempfile.TemporaryDirectory() as tmpdir:
data_path = Path(tmpdir) / "entries.csv"
write_events(data_path, rows)
original_path = app_module.app.config["DATA_PATH"]
app_module.app.config["DATA_PATH"] = data_path
try:
sessions = build_session_summaries(load_events(data_path))
sessions = build_session_summaries(rows)
self.assertEqual(
pending_payout_carry_items(sessions),
[{"player_name": "A", "amount_cents": cents(5)}],
)
with app_module.app.test_client() as client:
with client.session_transaction() as flask_session:
flask_session["is_admin"] = True
response = client.post(
"/admin/apply-payout-carry-in",
data={"session_id": "s2", "player_name": "A"},
)
finally:
app_module.app.config["DATA_PATH"] = original_path
self.assertEqual(response.status_code, 302)
updated_sessions = build_session_summaries(load_events(data_path))
rows.append(event("s2", "2026-01-02", "A", "payout_carry_in", 5, 8))
updated_sessions = build_session_summaries(rows)
target = next(s for s in updated_sessions if s.session_id == "s2")
target_entry = target.entries[0]
self.assertEqual(target_entry.payout_carry_in_cents, cents(5))
@@ -248,7 +227,7 @@ class AccountingTests(unittest.TestCase):
["Mar 21, 2026 · S1", "Mar 21, 2026 · S2"],
)
def test_admin_prunes_only_empty_marker_sessions(self):
def test_prunable_empty_sessions_excludes_played_sessions(self):
rows = [
event("empty-1", "2026-01-01", "", "session_open", 0, 1),
event("empty-1", "2026-01-01", "", "session_close", 0, 2),
@@ -258,27 +237,17 @@ class AccountingTests(unittest.TestCase):
event("played-1", "2026-01-02", "", "session_close", 0, 6),
]
with tempfile.TemporaryDirectory() as tmpdir:
data_path = Path(tmpdir) / "entries.csv"
write_events(data_path, rows)
original_path = app_module.app.config["DATA_PATH"]
app_module.app.config["DATA_PATH"] = data_path
sessions = build_session_summaries(rows)
prunable_ids = prunable_empty_session_ids(rows, sessions)
self.assertEqual(prunable_ids, {"empty-1"})
try:
with app_module.app.test_client() as client:
with client.session_transaction() as flask_session:
flask_session["is_admin"] = True
response = client.post("/admin/prune-empty-sessions")
finally:
app_module.app.config["DATA_PATH"] = original_path
self.assertEqual(response.status_code, 302)
remaining = load_events(data_path)
self.assertEqual(
{row["session_id"] for row in remaining},
{"played-1"},
)
remaining = [
row
for row in rows
if (row["session_id"].strip() or row["session_date"].strip())
not in prunable_ids
]
self.assertEqual({row["session_id"] for row in remaining}, {"played-1"})
self.assertEqual(
[row["event_type"] for row in remaining],
["session_open", "buyin", "cashout", "session_close"],
+11 -9
View File
@@ -1,10 +1,11 @@
import unittest
from stats import SessionEntry, SessionSummary, build_leaderboard
from models import SessionEntry, SessionSummary
from services import build_leaderboard
class CashInAccountingTest(unittest.TestCase):
def test_front_recovered_from_cashout_counts_as_session_cash_in(self):
def test_front_is_poker_investment_not_cash_in(self):
entry = SessionEntry(
session_id="2026-06-21-01",
session_date="2026-06-21",
@@ -20,10 +21,10 @@ class CashInAccountingTest(unittest.TestCase):
)
self.assertEqual(session.total_buy_in_cents, 2500)
self.assertEqual(session.total_front_collected_cents, 0)
self.assertEqual(session.total_cash_in_cents, 3000)
self.assertEqual(session.total_front_cents, 500)
self.assertEqual(session.total_cash_in_cents, 2500)
def test_collected_front_shortfall_counts_as_session_cash_in(self):
def test_debt_repayment_counts_as_session_cash_in(self):
entry = SessionEntry(
session_id="2026-06-21-01",
session_date="2026-06-21",
@@ -39,7 +40,7 @@ class CashInAccountingTest(unittest.TestCase):
)
self.assertEqual(session.total_front_collected_cents, 600)
self.assertEqual(session.total_cash_in_cents, 1000)
self.assertEqual(session.total_cash_in_cents, 600)
def test_written_off_front_does_not_count_as_session_cash_in(self):
entry = SessionEntry(
@@ -58,7 +59,7 @@ class CashInAccountingTest(unittest.TestCase):
self.assertEqual(session.total_front_writeoff_cents, 1000)
self.assertEqual(session.total_cash_in_cents, 0)
def test_leaderboard_cash_in_includes_collected_fronts(self):
def test_leaderboard_cash_in_uses_real_cash_only(self):
session = SessionSummary(
session_id="2026-06-21-01",
session_date="2026-06-21",
@@ -69,6 +70,7 @@ class CashInAccountingTest(unittest.TestCase):
player_name="Alex",
buy_in_cents=2500,
front_cents=500,
front_collected_cents=200,
cash_out_cents=4000,
)
],
@@ -77,8 +79,8 @@ class CashInAccountingTest(unittest.TestCase):
player = build_leaderboard([session])[0]
self.assertEqual(player.total_buy_in_cents, 2500)
self.assertEqual(player.total_front_collected_cents, 0)
self.assertEqual(player.total_cash_in_cents, 3000)
self.assertEqual(player.total_front_collected_cents, 200)
self.assertEqual(player.total_real_cash_in_cents, 2700)
if __name__ == "__main__":