count overpay as debts when fronted + add debt writeoff
This commit is contained in:
1 parent
2939415f76
commit
7ec95ee24a
8 files changed
+194
-5
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in new issue
Block a user