count overpay as debts when fronted + add debt writeoff

This commit is contained in:
SowinskiBraeden committed 2026-06-11 13:11:04 -07:00
1 parent 2939415f76
commit 7ec95ee24a
8 files changed
+194 -5

No files matched your search

+7 -1
View File
@@ -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.
+78
View File
@@ -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,
+12
View File
@@ -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;
}
+33
View File
@@ -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,
+2 -1
View File
@@ -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(
+45 -1
View File
@@ -112,6 +112,39 @@
</form>
</div>
</section>
<form class="panel form-card" method="post" action="{{ url_for('admin_write_off_front') }}">
<p class="eyebrow">Front debt</p>
<h2>Write off debt</h2>
<label>
<span>Outstanding debt</span>
<select name="debt_key" required>
<option value="">Select debt</option>
{% for item in debt_entries %}
{% set session = item.session %}
{% set entry = item.entry %}
<option value="{{ session.session_id }}||{{ entry.player_name }}">
{{ entry.player_name }} · {{ session_label(session) }} · owes {{ entry.player_owes_cents | money }}
</option>
{% endfor %}
</select>
</label>
<label>
<span>Amount to write off</span>
<input type="number" step="0.01" name="amount" placeholder="5.00" required>
</label>
<label>
<span>Note</span>
<textarea name="note" rows="3" placeholder="Won't be collected."></textarea>
</label>
<div class="button-row">
<button class="secondary-button" type="submit">Write off front</button>
</div>
</form>
</section>
</section>
@@ -140,6 +173,13 @@
</strong>
</article>
<article class="panel stat-card">
<span>Written off</span>
<strong class="{{ 'negative' if admin_totals.written_off_cents > 0 else '' }}">
{{ admin_totals.written_off_cents | money }}
</strong>
</article>
<article class="panel stat-card">
<span>Open balance</span>
<strong class="{% if admin_totals.open_balance_cents > 0 %}negative{% elif admin_totals.open_balance_cents < 0 %}positive{% endif %}">
@@ -165,6 +205,7 @@
<th>Paid out</th>
<th>Still owed</th>
<th>Players owe</th>
<th>Written off</th>
<th>Open balance</th>
<th>Status</th>
<th>Action</th>
@@ -187,6 +228,9 @@
<td class="{{ 'negative' if session.total_player_owes_cents > 0 else '' }}">
{{ session.total_player_owes_cents | money }}
</td>
<td class="{{ 'negative' if session.total_front_writeoff_cents > 0 else '' }}">
{{ session.total_front_writeoff_cents | money }}
</td>
<td class="{% if session.total_open_balance_cents > 0 %}negative{% elif session.total_open_balance_cents < 0 %}positive{% endif %}">
{{ session.total_open_balance_cents | money }}
</td>
@@ -208,7 +252,7 @@
</tr>
{% else %}
<tr>
<td colspan="4">No sessions yet.</td>
<td colspan="10">No sessions yet.</td>
</tr>
{% endfor %}
</tbody>
+4
View File
@@ -16,6 +16,8 @@
<article class="panel stat-card"><span>Total invested</span><strong>{{ player.total_invested_cents | money }}</strong></article>
<article class="panel stat-card"><span>Paid Buy-in</span><strong>{{ player.total_buy_in_cents | money }}</strong></article>
<article class="panel stat-card"><span>Fronted</span><strong>{{ player.total_front_cents | money }}</strong></article>
<article class="panel stat-card"><span>Current debt</span><strong class="{{ 'negative' if player.current_player_owes_cents > 0 else '' }}">{{ player.current_player_owes_cents | money }}</strong></article>
<article class="panel stat-card"><span>Written off</span><strong class="{{ 'negative' if player.total_front_writeoff_cents > 0 else '' }}">{{ player.total_front_writeoff_cents | money }}</strong></article>
<!--<article class="panel stat-card"><span>Rolled in</span><strong>{{ player.total_rollover_in_cents | money }}</strong></article>-->
<article class="panel stat-card"><span>Cash-outs</span><strong>{{ player.total_cash_out_cents | money }}</strong></article>
<article class="panel stat-card"><span>Sessions</span><strong>{{ player.sessions_played }}</strong></article>
@@ -87,6 +89,8 @@
<tbody>
<tr><th>Paid Buy-ins</th><td>{{ player.total_buy_in_cents | money }}</td></tr>
<tr><th>Fronted</th><td>{{ player.total_front_cents | money }}</td></tr>
<tr><th>Current Debt</th><td>{{ player.current_player_owes_cents | money }}</td></tr>
<tr><th>Written Off</th><td>{{ player.total_front_writeoff_cents | money }}</td></tr>
<tr><th>Rolled In</th><td>{{ player.total_rollover_in_cents | money }}</td></tr>
<tr><th>Total Invested</th><td>{{ player.total_invested_cents | money }}</td></tr>
<tr><th>Rolled Out</th><td>{{ player.total_rollover_out_cents | money }}</td></tr>
+13 -2
View File
@@ -54,6 +54,13 @@
{{ session.total_player_owes_cents | money }}
</strong>
</article>
<article class="panel stat-card">
<span>Written off</span>
<strong class="{{ 'negative' if session.total_front_writeoff_cents > 0 else '' }}">
{{ session.total_front_writeoff_cents | money }}
</strong>
</article>
</section>
@@ -87,6 +94,7 @@
<th class="detail-col">Gross due</th>
<th class="detail-col">Paid</th>
<th class="detail-col">Rolled out</th>
<th class="detail-col">Written off</th>
<th>Still owed</th>
<th>Status</th>
<th>Notes</th>
@@ -111,7 +119,7 @@
</td>
<td class="detail-col">
{% if entry.player_owes_cents > 0 %}
{% if entry.raw_player_owes_cents > 0 %}
{% else %}
{{ entry.gross_payout_cents | money }}
@@ -120,10 +128,13 @@
<td class="detail-col">{{ entry.paid_cents | money }}</td>
<td class="detail-col">{{ entry.rollover_out_cents | money }}</td>
<td class="detail-col">{{ entry.front_writeoff_applied_cents | money }}</td>
<td class="{% if entry.player_owes_cents > 0 %}negative{% elif entry.current_due_cents > 0 %}negative{% else %}positive{% endif %}">
<td class="{% if entry.player_owes_cents > 0 %}negative{% elif entry.front_writeoff_applied_cents > 0 %}negative{% elif entry.current_due_cents > 0 %}negative{% else %}positive{% endif %}">
{% 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 %}