From 64b6cd175ecb13e8094b57fba44d1e3b56038c43 Mon Sep 17 00:00:00 2001 From: SowinskiBraeden Date: Thu, 11 Jun 2026 13:11:04 -0700 Subject: [PATCH] count overpay as debts when fronted + add debt writeoff --- README.md | 8 +++- app.py | 78 ++++++++++++++++++++++++++++++++++ static/css/style.css | 12 ++++++ stats.py | 33 ++++++++++++++ storage.py | 3 +- templates/admin_dashboard.html | 46 +++++++++++++++++++- templates/player_detail.html | 4 ++ templates/session_detail.html | 15 ++++++- 8 files changed, 194 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 88868e0..d09f1f4 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ The idea is pretty simple: public pages for stats and session history, plus a sm - admin login for recording events - open / closed session tracking - payout tracking with `paid` events +- front debt write-offs with `front_writeoff` events - session and player charts - CSV import / export from the admin page - append-only `entries.csv` ledger instead of overwriting old rows @@ -36,8 +37,12 @@ Each row is an event, not a final snapshot. Instead of editing an old row, I app Current event types: - `buyin` +- `front` +- `front_writeoff` - `cashout` - `paid` +- `rollover_in` +- `rollover_out` - `note` - `session_open` - `session_close` @@ -47,6 +52,7 @@ A few examples: - another `buyin` for a rebuy - another `cashout` if chip counts are corrected later - a `paid` event when someone is actually settled up +- a `front_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 @@ -61,7 +67,7 @@ Main file: Header: ```csv -id,created_at,session_date,player_name,event_type,amount_cents,note,actor +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. diff --git a/app.py b/app.py index 48ae2f9..b429f71 100644 --- a/app.py +++ b/app.py @@ -370,6 +370,74 @@ def admin_import_csv(): return redirect(url_for("admin_dashboard")) +@app.post("/admin/write-off-front") +def admin_write_off_front() -> str: + if not is_admin(): + flash("Admin login required.", "error") + return redirect(url_for("admin_login")) + + 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( + (session for session in sessions if session.session_id == session_id), + None, + ) + + if target is None: + flash("Session not found.", "error") + return redirect(url_for("admin_dashboard")) + + entry = next( + (entry for entry in target.entries if entry.player_name == player_name), + None, + ) + + if entry is None or entry.player_owes_cents <= 0: + flash("That player does not have an outstanding front 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="front_writeoff", + amount_cents=amount_cents, + note=note or "Front debt written off.", + actor=app.config["ADMIN_USERNAME"], + ) + + flash("Front debt written off.", "success") + return redirect(url_for("admin_dashboard")) + + @app.post("/admin/open-session") def admin_open_session() -> str: if not is_admin(): @@ -453,6 +521,12 @@ def admin_dashboard(): open_sessions = [session for session in sessions if session.status == "open"] recent_sessions = sessions[:8] recent_events = list(reversed(events[-20:])) + debt_entries = [ + {"session": session, "entry": entry} + for session in sessions + for entry in session.entries + if entry.player_owes_cents > 0 + ] admin_totals = { "cash_in_cents": sum(session.total_cash_in_cents for session in sessions), @@ -463,6 +537,9 @@ def admin_dashboard(): "players_owe_cents": sum( session.total_player_owes_cents for session in sessions ), + "written_off_cents": sum( + session.total_front_writeoff_cents for session in sessions + ), "open_balance_cents": sum( session.total_open_balance_cents for session in sessions ), @@ -473,6 +550,7 @@ def admin_dashboard(): open_sessions=open_sessions, recent_sessions=recent_sessions, recent_events=recent_events, + debt_entries=debt_entries, player_names=unique_player_names(events), session_label=session_label, admin_totals=admin_totals, diff --git a/static/css/style.css b/static/css/style.css index 1655265..3873a15 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -958,6 +958,12 @@ td a:hover { border: 1px solid rgba(251, 146, 60, 0.22); } +.pill-front_writeoff { + color: #f87171; + background: rgba(248, 113, 113, 0.12); + border: 1px solid rgba(248, 113, 113, 0.22); +} + .pill-note { color: #cbd5e1; background: rgba(203, 213, 225, 0.08); @@ -983,6 +989,12 @@ td a:hover { border: 1px solid rgba(239, 68, 68, 0.24); } +.status-written_off { + color: #f87171; + background: rgba(248, 113, 113, 0.12); + border: 1px solid rgba(248, 113, 113, 0.24); +} + .inline-action-form { margin: 0; } diff --git a/stats.py b/stats.py index 59fe04e..29082a4 100644 --- a/stats.py +++ b/stats.py @@ -31,6 +31,7 @@ class SessionEntry: player_name: str buy_in_cents: int = 0 front_cents: int = 0 + front_writeoff_cents: int = 0 cash_out_cents: int = 0 paid_cents: int = 0 rollover_in_cents: int = 0 @@ -67,12 +68,30 @@ class SessionEntry: @property def player_owes_cents(self) -> int: + return max(self.raw_player_owes_cents - self.front_writeoff_applied_cents, 0) + + @property + def raw_player_owes_cents(self) -> int: + return self.front_shortfall_cents + self.overpaid_front_cents + + @property + def front_shortfall_cents(self) -> int: return max(self.front_cents - self.cash_out_cents, 0) + @property + def overpaid_front_cents(self) -> int: + return max(self.settled_cents - self.gross_payout_cents, 0) + + @property + def front_writeoff_applied_cents(self) -> int: + return min(self.front_writeoff_cents, self.raw_player_owes_cents) + @property def payout_status(self) -> str: if self.player_owes_cents > 0: return "owes" + if self.front_writeoff_applied_cents > 0: + return "written_off" if self.gross_payout_cents <= 0: return "none" if self.current_due_cents <= 0: @@ -146,6 +165,10 @@ class SessionSummary: def total_player_owes_cents(self) -> int: return sum(entry.player_owes_cents for entry in self.entries) + @property + def total_front_writeoff_cents(self) -> int: + return sum(entry.front_writeoff_applied_cents for entry in self.entries) + @property def total_cash_in_cents(self) -> int: return sum(entry.buy_in_cents for entry in self.entries) @@ -173,6 +196,8 @@ class PlayerStats: biggest_loss_cents: int total_buy_in_cents: int total_front_cents: int + total_front_writeoff_cents: int + current_player_owes_cents: int total_rollover_in_cents: int total_invested_cents: int total_cash_out_cents: int @@ -322,6 +347,8 @@ def build_session_summaries(events: list[EventRow]) -> list[SessionSummary]: entry.buy_in_cents += event["amount_cents"] elif event_type == "front": entry.front_cents += event["amount_cents"] + elif event_type == "front_writeoff": + entry.front_writeoff_cents += event["amount_cents"] elif event_type == "rollover_in": entry.rollover_in_cents += event["amount_cents"] elif event_type == "cashout": @@ -454,6 +481,10 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: total_buy_in = sum(entry.buy_in_cents for entry in entries) total_front = sum(entry.front_cents for entry in entries) + total_front_writeoff = sum( + entry.front_writeoff_applied_cents for entry in entries + ) + current_player_owes = sum(entry.player_owes_cents for entry in entries) total_rollover_in = sum(entry.rollover_in_cents for entry in entries) total_invested = sum(entry.invested_cents for entry in entries) total_cash_out = sum(entry.cash_out_cents for entry in entries) @@ -476,6 +507,8 @@ def build_leaderboard(sessions: list[SessionSummary]) -> list[PlayerStats]: biggest_loss_cents=biggest_loss, total_buy_in_cents=total_buy_in, total_front_cents=total_front, + total_front_writeoff_cents=total_front_writeoff, + current_player_owes_cents=current_player_owes, total_rollover_in_cents=total_rollover_in, total_invested_cents=total_invested, total_cash_out_cents=total_cash_out, diff --git a/storage.py b/storage.py index f9c6662..e250670 100644 --- a/storage.py +++ b/storage.py @@ -35,6 +35,7 @@ class EventRow(TypedDict): VALID_EVENT_TYPES = { "buyin", "front", + "front_writeoff", "cashout", "paid", "rollover_in", @@ -98,7 +99,7 @@ def append_event( if normalized_type not in VALID_EVENT_TYPES: raise ValueError(f"Unsupported event type: {event_type}") - if normalized_type == {"note", "session_open", "session_close"}: + if normalized_type in {"note", "session_open", "session_close"}: amount_cents = 0 event = EventRow( diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index 41d9b29..fca5e65 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -112,6 +112,39 @@ + +
+

Front debt

+

Write off debt

+ + + + + + + +
+ +
+
@@ -140,6 +173,13 @@ +
+ Written off + + {{ admin_totals.written_off_cents | money }} + +
+
Open balance @@ -165,6 +205,7 @@ Paid out Still owed Players owe + Written off Open balance Status Action @@ -187,6 +228,9 @@ {{ session.total_player_owes_cents | money }} + + {{ session.total_front_writeoff_cents | money }} + {{ session.total_open_balance_cents | money }} @@ -208,7 +252,7 @@ {% else %} - No sessions yet. + No sessions yet. {% endfor %} diff --git a/templates/player_detail.html b/templates/player_detail.html index 9f23b3e..329bee5 100644 --- a/templates/player_detail.html +++ b/templates/player_detail.html @@ -16,6 +16,8 @@
Total invested{{ player.total_invested_cents | money }}
Paid Buy-in{{ player.total_buy_in_cents | money }}
Fronted{{ player.total_front_cents | money }}
+
Current debt{{ player.current_player_owes_cents | money }}
+
Written off{{ player.total_front_writeoff_cents | money }}
Cash-outs{{ player.total_cash_out_cents | money }}
Sessions{{ player.sessions_played }}
@@ -87,6 +89,8 @@ Paid Buy-ins{{ player.total_buy_in_cents | money }} Fronted{{ player.total_front_cents | money }} + Current Debt{{ player.current_player_owes_cents | money }} + Written Off{{ player.total_front_writeoff_cents | money }} Rolled In{{ player.total_rollover_in_cents | money }} Total Invested{{ player.total_invested_cents | money }} Rolled Out{{ player.total_rollover_out_cents | money }} diff --git a/templates/session_detail.html b/templates/session_detail.html index c23d52b..39267f8 100644 --- a/templates/session_detail.html +++ b/templates/session_detail.html @@ -54,6 +54,13 @@ {{ session.total_player_owes_cents | money }}
+ +
+ Written off + + {{ session.total_front_writeoff_cents | money }} + +
@@ -87,6 +94,7 @@ Gross due Paid Rolled out + Written off Still owed Status Notes @@ -111,7 +119,7 @@ - {% if entry.player_owes_cents > 0 %} + {% if entry.raw_player_owes_cents > 0 %} — {% else %} {{ entry.gross_payout_cents | money }} @@ -120,10 +128,13 @@ {{ entry.paid_cents | money }} {{ entry.rollover_out_cents | money }} + {{ entry.front_writeoff_applied_cents | money }} - + {% if entry.player_owes_cents > 0 %} Owes {{ entry.player_owes_cents | money }} + {% elif entry.front_writeoff_applied_cents > 0 %} + Written off {{ entry.front_writeoff_applied_cents | money }} {% else %} {{ entry.current_due_cents | money }} {% endif %}