feat: initial dashboard implementation and robust attendance parsing

- Added a Makefile to easily run project scripts (fees, match, web, image)
- Modified attendance.py to dynamically handle a variable number of header rows from the Google Sheet
- Updated both attendance calculations and calculate_fees terminal output to show actual attendance counts (e.g., '750 CZK (3)')
- Created a Flask web dashboard (app.py and templates/fees.html) to view member fees in an attractive, condensed, terminal-like UI
- Bound the Flask server to port 5000 and added a routing alias from '/' to '/fees'
- Configured Python virtual environment (.venv) creation directly into the Makefile to resolve global pip install errors on macOS

Co-authored-by: Antigravity <antigravity@deepmind.com>
This commit is contained in:
Jan Novak
2026-02-27 13:19:00 +01:00
commit 4591592493
16 changed files with 1322 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
name: Build and Push
on:
workflow_dispatch:
inputs:
tag:
description: 'Image tag'
required: true
default: 'latest'
push:
tags:
- '*'
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Login to Gitea registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login -u ${{ github.actor }} --password-stdin gitea.home.hrajfrisbee.cz
- name: Build and push
run: |
TAG=${{ github.ref_name }}
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TAG=${{ inputs.tag }}
fi
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG
docker build -f build/Dockerfile -t $IMAGE .
docker push $IMAGE

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# python cache
**/*.pyc

27
CLAUDE.md Normal file
View File

@@ -0,0 +1,27 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Status
This is a greenfield project in early discovery/design phase. No source code exists yet. The project aims to automate financial and operational management for a small sports club.
See `docs/project-notes.md` for the current brainstorming state, domain model, and open questions that need answering before implementation begins.
## Key Constraints
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
- **Incremental approach**: Start with highest-ROI automation (likely fee billing & payment tracking), not a full platform.
## Development Workflow
This project uses a hybrid workflow:
- Claude.ai chat for brainstorming and design exploration
- Claude Code for implementation
## When Code Exists
Once a tech stack is chosen and implementation begins, update this file with:
- Build, test, and lint commands
- Architecture overview
- Development setup instructions

28
Makefile Normal file
View File

@@ -0,0 +1,28 @@
.PHONY: help fees match web image
export PYTHONPATH := scripts:$(PYTHONPATH)
VENV := .venv
PYTHON := $(VENV)/bin/python3
$(PYTHON):
python3 -m venv $(VENV)
$(PYTHON) -m pip install -q flask
help:
@echo "Available targets:"
@echo " make fees - Calculate monthly fees from the attendance sheet"
@echo " make match - Match Fio bank payments against expected attendance fees"
@echo " make web - Start a dynamic web dashboard locally"
@echo " make image - Build an OCI container image"
fees: $(PYTHON)
$(PYTHON) scripts/calculate_fees.py
match: $(PYTHON)
$(PYTHON) scripts/match_payments.py
web: $(PYTHON)
$(PYTHON) app.py
image:
docker build -t fuj-management:latest -f build/Dockerfile .

53
app.py Normal file
View File

@@ -0,0 +1,53 @@
import sys
from pathlib import Path
from datetime import datetime
from flask import Flask, render_template
# Add scripts directory to path to allow importing from it
scripts_dir = Path(__file__).parent / "scripts"
sys.path.append(str(scripts_dir))
from attendance import get_members_with_fees
app = Flask(__name__)
@app.route("/")
def index():
# Redirect root to /fees for convenience while there are no other apps
return '<meta http-equiv="refresh" content="0; url=/fees" />'
@app.route("/fees")
def fees():
members, sorted_months = get_members_with_fees()
if not members:
return "No data."
# Filter to adults only for display
results = [(name, fees) for name, tier, fees in members if tier == "A"]
# Format month labels
month_labels = {
m: datetime.strptime(m, "%Y-%m").strftime("%b %Y") for m in sorted_months
}
monthly_totals = {m: 0 for m in sorted_months}
formatted_results = []
for name, month_fees in results:
row = {"name": name, "months": []}
for m in sorted_months:
fee, count = month_fees.get(m, (0, 0))
monthly_totals[m] += fee
cell = f"{fee} CZK ({count})" if count > 0 else "-"
row["months"].append(cell)
formatted_results.append(row)
return render_template(
"fees.html",
months=[month_labels[m] for m in sorted_months],
results=formatted_results,
totals=[f"{monthly_totals[m]} CZK" for m in sorted_months]
)
if __name__ == "__main__":
app.run(debug=True, port=5001)

6
build/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
.git
.venv
__pycache__
*.pyc
.claude
.gemini

25
build/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM python:3.13-alpine
RUN apk add --no-cache bash tzdata \
&& cp /usr/share/zoneinfo/Europe/Prague /etc/localtime \
&& echo "Europe/Prague" > /etc/timezone
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN pip install --no-cache-dir flask
COPY app.py Makefile ./
COPY scripts/ ./scripts/
COPY templates/ ./templates/
COPY build/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 5001
HEALTHCHECK --interval=60s --timeout=5s --start-period=5s \
CMD wget -q -O /dev/null http://localhost:5001/ || exit 1
ENTRYPOINT ["/entrypoint.sh"]

8
build/entrypoint.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/bin/bash
set -euo pipefail
echo "[entrypoint] Starting Flask app on port 5001..."
# Running the app directly via python
# For a production setup, we would ideally use gunicorn/waitress, but sticking to what's in app.py for now.
exec python3 /app/app.py

View File

@@ -0,0 +1,69 @@
# Fee Calculation Spec — Tuesday Practices
## Data Source
- Google Sheet: `1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA`
- Sheet: first sheet only (Tuesday practices, 20:3022:00)
- Public export URL (CSV): `https://docs.google.com/spreadsheets/d/1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA/export?format=csv`
## Sheet Structure
| Row | Content |
| --- | --- |
| 1 | Header: title in col A, dates in cols D+ (format `M/D/YYYY`) |
| 2 | Venue per date (irrelevant for pricing) |
| 3 | Total attendees per date |
| 4+ | Member rows: Name (col A), Tier (col B), Total (col C), attendance TRUE/FALSE (cols D+) |
Member rows end when the Name column is empty.
## Tiers
| Code | Meaning | Pays from this sheet? |
| --- | --- | --- |
| A | Adult | Yes |
| J | Junior | No (paid via separate attendance sheet) |
| X | Exempt | No |
## Fee Rules (Adults only)
Fees are calculated per calendar month based on the number of attended practices in that month.
| Practices in month | Fee |
| --- | --- |
| 0 | 0 CZK |
| 1 | 200 CZK |
| 2 or more | 750 CZK |
## Payment Matching
### Bank Account
- Fio banka transparent account: `2800359168/2010`
- Owner: Nathan Heilmann
- Public view: `https://ib.fio.cz/ib/transparent?a=2800359168`
### Data Access
- **Without API token**: scrape the public transparent account HTML page
- **With API token**: Fio REST API at `https://fioapi.fio.cz/v1/rest/periods/{token}/{from}/{to}/transactions.json`
- Token is generated in Fio internetbanking (Settings → API)
- Rate limit: 1 request per 30 seconds per token
- Available fields: date, amount, currency, sender account, sender name, VS, SS, KS, user identification, message, type, and more
### Matching Approach
Payments are matched to members using best-effort heuristics, with uncertain matches flagged for manual review.
1. **Name matching**: Normalize (strip diacritics, lowercase) sender name and message text, compare against member names and nicknames
2. **Month parsing**: Extract Czech month names (leden, únor, ...) and numeric patterns (01/26, 1/2026) from the message
3. **Amount validation**: Check if amount aligns with expected fees (200, 750, or multiples)
4. **Multi-person splitting**: When a message references multiple members, split the payment across them
### Advance Payments
If a payment references a month with no attendance data yet, it is tracked as **credit** on the member's account. The credit is applied once that month's attendance is recorded.
## PII Constraint
No member names or personal data are committed to git. All data is fetched at runtime from the Google Sheet and bank account.

61
docs/project-notes.md Normal file
View File

@@ -0,0 +1,61 @@
# Sports Club Financial Management — Project Notes
> **Context for Claude Code:** This document captures an ongoing brainstorming session
> started in Claude.ai chat. The owner is an experienced SRE/programmer. We are still
> in the discovery/design phase — no code has been written yet. Next steps: fill in
> current state (Section 3), then move into incremental automation design and implementation.
## 1. Project Goal
Design and incrementally automate financial and operational management for a small sports club.
## 2. Domain Entities (Draft)
- **Members** — roster, roles (player/coach/parent), contact info, membership status
- **Fees** — recurring (monthly/seasonal), one-off (tournament entry), per-member or per-family
- **Attendance** — practice sessions, matches, tournaments; who showed up
- **Expenses** — facility rental, equipment, travel, referee fees, insurance
- **Ledger** — income (fees, sponsors, fundraising) vs. expenses; balance tracking
## 3. Current State
_TODO: To be filled — critical input needed before design/implementation._
- Club size (members, teams):
- Current tooling (spreadsheets? paper? existing app?):
- System users (besides owner):
- Biggest pain point / what to solve first:
## 4. Automation Candidates (by estimated ROI)
1. Fee billing & payment tracking (reminders, status per member)
2. Attendance logging (check-in mechanism)
3. Expense categorization & reporting (monthly summaries, budget vs. actual)
4. Tournament management (signup, fee collection, travel)
## 5. Tech Considerations
- Who operates / interacts with the system?
- Complexity spectrum: Spreadsheet + Apps Script → lightweight web app → full platform
- Integration points: Slack, Google Forms, payment gateways, etc.
- **PII caution:** member data (names, emails, payment info) must stay out of git from day one. Enforce config/data separation early.
## 6. Suggested Approach
1. **Map domain** — finalize entities and workflows (Section 2 & 3)
2. **Identify pain points** — what's the worst manual step today?
3. **Design automation incrementally** — start with highest-ROI item
4. **Build** — iterate in this repo
## 7. Open Questions
- All items in Section 3 are unresolved.
- Tech stack TBD — depends on who the users are and complexity needs.
## 8. Decision Log
| Date | Decision | Rationale |
|------------|---------------------------------------|--------------------------------------------------------------|
| 2025-02-11 | Store project docs in git repo | Markdown-native, versioned, natural evolution toward code |
| 2025-02-11 | Hybrid workflow: chat → Claude Code | Chat better for brainstorming; Claude Code for building |
| 2025-02-11 | PII stays out of repo from day one | Avoid retrofitting data separation later |

79
docs/scripts.md Normal file
View File

@@ -0,0 +1,79 @@
# Scripts
All scripts live in `scripts/` and use Python 3.10+ with stdlib only (no pip dependencies).
## calculate_fees.py
Calculates monthly fees for each Adult member based on Tuesday practice attendance.
```bash
cd scripts && python3 calculate_fees.py
```
Outputs a table of Adult members with their monthly fee (0 / 200 / 750 CZK) and totals per month. Data is fetched live from the Google Sheet.
## match_payments.py
Matches incoming bank payments against expected fees to produce a reconciliation report.
```bash
cd scripts && python3 match_payments.py [--from YYYY-MM-DD] [--to YYYY-MM-DD]
```
| Option | Default | Description |
| --- | --- | --- |
| `--from` | `2025-12-01` | Start of date range for bank transactions |
| `--to` | today | End of date range |
**Bank data access** is controlled by the `FIO_API_TOKEN` environment variable:
- **Set** — uses the Fio REST API (JSON, structured data, all fields)
- **Not set** — scrapes the public transparent account HTML page
```bash
# With API token:
FIO_API_TOKEN=xxx python3 match_payments.py --from 2026-01-01 --to 2026-02-11
# Without (public page):
python3 match_payments.py --from 2026-01-01 --to 2026-02-11
```
**Report sections:**
1. **Summary table** — per member, per month: `OK` / `UNPAID {amount}` / `{paid}/{expected}` + balance
2. **Credits** — advance payments for months without attendance data yet
3. **Unmatched transactions** — payments the script couldn't assign to any member
4. **Matched transaction details** — full breakdown of which payment was assigned where, with `[REVIEW]` tags on low-confidence matches
**Known limitations:**
- Lump-sum payments covering multiple months are split evenly rather than by actual per-month fee
- Messages with no member name and a sender not in the member list cannot be matched
- Common surnames (Novák) are excluded from last-name-only matching to avoid false positives
## Shared modules
### attendance.py
Shared attendance and fee logic, imported by both scripts above.
Key functions:
| Function | Description |
| --- | --- |
| `fetch_csv()` | Fetches the Google Sheet as parsed CSV rows |
| `parse_dates(header_row)` | Extracts `(column_index, date)` pairs from the header |
| `group_by_month(dates)` | Groups column indices by `YYYY-MM` |
| `calculate_fee(count)` | Applies fee rules: 0→0, 1→200, 2+→750 CZK |
| `get_members(rows)` | Parses member rows into `(name, tier, row)` tuples |
| `get_members_with_fees()` | Full pipeline: fetch → parse → compute fees. Returns `(members, sorted_months)` |
### czech_utils.py
Czech language text utilities.
| Function | Description |
| --- | --- |
| `normalize(text)` | Strip diacritics and lowercase (`Štrúdl``strudl`) |
| `parse_month_references(text)` | Extract `YYYY-MM` strings from Czech free text. Handles month names in all declensions (`leden`, `ledna`, `lednu`), numeric formats (`01/26`, `11+12/2025`), dot notation (`12.2025`), and ranges (`listopad-leden`) |
| `CZECH_MONTHS` | Dict mapping normalized Czech month names (all declensions) to month numbers |

107
scripts/attendance.py Normal file
View File

@@ -0,0 +1,107 @@
"""Shared attendance/fee logic for FUJ Tuesday practices."""
import csv
import io
import urllib.request
from datetime import datetime
SHEET_ID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA"
EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv"
FEE_FULL = 750 # CZK, for 2+ practices in a month
FEE_SINGLE = 200 # CZK, for exactly 1 practice in a month
COL_NAME = 0
COL_TIER = 1
FIRST_DATE_COL = 3
def fetch_csv() -> list[list[str]]:
"""Fetch the attendance Google Sheet as parsed CSV rows."""
req = urllib.request.Request(EXPORT_URL)
with urllib.request.urlopen(req) as resp:
text = resp.read().decode("utf-8")
reader = csv.reader(io.StringIO(text))
return list(reader)
def parse_dates(header_row: list[str]) -> list[tuple[int, datetime]]:
"""Return (column_index, date) pairs for all date columns."""
dates = []
for i in range(FIRST_DATE_COL, len(header_row)):
raw = header_row[i].strip()
if not raw:
continue
try:
dates.append((i, datetime.strptime(raw, "%m/%d/%Y")))
except ValueError:
continue
return dates
def group_by_month(dates: list[tuple[int, datetime]]) -> dict[str, list[int]]:
"""Group column indices by YYYY-MM."""
months: dict[str, list[int]] = {}
for col, dt in dates:
key = dt.strftime("%Y-%m")
months.setdefault(key, []).append(col)
return months
def calculate_fee(attendance_count: int) -> int:
"""Apply fee rules: 0 → 0, 1 → 200, 2+ → 750."""
if attendance_count == 0:
return 0
if attendance_count == 1:
return FEE_SINGLE
return FEE_FULL
def get_members(rows: list[list[str]]) -> list[tuple[str, str, list[str]]]:
"""Parse member rows. Returns list of (name, tier, row)."""
members = []
for row in rows[1:]:
name = row[COL_NAME].strip() if len(row) > COL_NAME else ""
if not name or name.lower() in ("jméno", "name", "jmeno"):
continue
tier = row[COL_TIER].strip().upper() if len(row) > COL_TIER else ""
members.append((name, tier, row))
return members
def get_members_with_fees() -> tuple[list[tuple[str, str, dict[str, int]]], list[str]]:
"""Fetch attendance data and compute fees.
Returns:
(members, sorted_months) where members is a list of
(name, tier, {month_key: (fee, count)}) for ALL members (all tiers).
sorted_months is the list of YYYY-MM keys in order.
"""
rows = fetch_csv()
if len(rows) < 2:
return [], []
header_row = rows[0]
dates = parse_dates(header_row)
if not dates:
return [], []
months = group_by_month(dates)
sorted_months = sorted(months.keys())
members_raw = get_members(rows)
members = []
for name, tier, row in members_raw:
month_fees = {}
for month_key in sorted_months:
cols = months[month_key]
count = sum(
1
for c in cols
if c < len(row) and row[c].strip().upper() == "TRUE"
)
fee = calculate_fee(count) if tier == "A" else 0
month_fees[month_key] = (fee, count)
members.append((name, tier, month_fees))
return members, sorted_months

53
scripts/calculate_fees.py Normal file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Calculate monthly fees from the FUJ Tuesday practice attendance sheet."""
from datetime import datetime
from attendance import get_members_with_fees
def main():
members, sorted_months = get_members_with_fees()
if not members:
print("No data.")
return
# Filter to adults only for display
results = [(name, fees) for name, tier, fees in members if tier == "A"]
# Format month labels
month_labels = {
m: datetime.strptime(m, "%Y-%m").strftime("%b %Y") for m in sorted_months
}
# Print table
name_width = max((len(r[0]) for r in results), default=20)
col_width = 15
header = f"{'Member':<{name_width}}"
for m in sorted_months:
header += f" | {month_labels[m]:>{col_width}}"
print(header)
print("-" * len(header))
monthly_totals = {m: 0 for m in sorted_months}
for name, month_fees in results:
line = f"{name:<{name_width}}"
for m in sorted_months:
fee, count = month_fees[m]
monthly_totals[m] += fee
cell = f"{fee} CZK ({count})" if count > 0 else "-"
line += f" | {cell:>{col_width}}"
print(line)
# Totals row
print("-" * len(header))
totals_line = f"{'TOTAL':<{name_width}}"
for m in sorted_months:
cell = f"{monthly_totals[m]} CZK"
totals_line += f" | {cell:>{col_width}}"
print(totals_line)
if __name__ == "__main__":
main()

101
scripts/czech_utils.py Normal file
View File

@@ -0,0 +1,101 @@
"""Czech text utilities — diacritics normalization and month parsing."""
import re
import unicodedata
CZECH_MONTHS = {
"leden": 1, "ledna": 1, "lednu": 1,
"unor": 2, "unora": 2, "unoru": 2,
"brezen": 3, "brezna": 3, "breznu": 3,
"duben": 4, "dubna": 4, "dubnu": 4,
"kveten": 5, "kvetna": 5, "kvetnu": 5,
"cerven": 6, "cervna": 6, "cervnu": 6,
"cervenec": 7, "cervnce": 7, "cervenci": 7,
"srpen": 8, "srpna": 8, "srpnu": 8,
"zari": 9,
"rijen": 10, "rijna": 10, "rijnu": 10,
"listopad": 11, "listopadu": 11,
"prosinec": 12, "prosince": 12, "prosinci": 12,
}
def normalize(text: str) -> str:
"""Strip diacritics and lowercase."""
nfkd = unicodedata.normalize("NFKD", text)
return "".join(c for c in nfkd if not unicodedata.combining(c)).lower()
def parse_month_references(text: str, default_year: int = 2026) -> list[str]:
"""Extract YYYY-MM month references from Czech free text.
Handles:
- Czech month names: "leden", "únor", "prosinec" (all declensions)
- Numeric: "01/26", "1/2026", "11+12/2025"
- Ranges: "listopad-leden" (November through January)
- Slash-separated numeric months: "11+12/2025"
Returns sorted list of unique YYYY-MM strings.
"""
normalized = normalize(text)
results: set[str] = set()
# Pattern: numeric months with year, e.g. "11+12/2025", "01/26", "1/2026"
# Match groups of digits separated by + before a /year
numeric_pattern = re.findall(
r"([\d+]+)\s*/\s*(\d{2,4})", normalized
)
for months_part, year_str in numeric_pattern:
year = int(year_str)
if year < 100:
year += 2000
for m_str in months_part.split("+"):
m_str = m_str.strip()
if m_str.isdigit():
m = int(m_str)
if 1 <= m <= 12:
results.add(f"{year:04d}-{m:02d}")
# Pattern: standalone numeric month.year, e.g. "12.2025"
dot_pattern = re.findall(r"(\d{1,2})\s*\.\s*(\d{4})", normalized)
for m_str, year_str in dot_pattern:
m, year = int(m_str), int(year_str)
if 1 <= m <= 12:
results.add(f"{year:04d}-{m:02d}")
# Czech month names — handle ranges like "listopad-leden"
# First, find range patterns
month_name_re = "|".join(sorted(CZECH_MONTHS.keys(), key=len, reverse=True))
range_pattern = re.findall(
rf"({month_name_re})\s*-\s*({month_name_re})", normalized
)
found_in_ranges: set[str] = set()
for start_name, end_name in range_pattern:
found_in_ranges.add(start_name)
found_in_ranges.add(end_name)
start_m = CZECH_MONTHS[start_name]
end_m = CZECH_MONTHS[end_name]
# Walk from start to end, wrapping around December→January
m = start_m
while True:
year = default_year if m >= start_m and start_m > end_m else default_year
# If range wraps (e.g. Nov-Jan), months >= start are previous year
if start_m > end_m and m >= start_m:
year = default_year - 1
results.add(f"{year:04d}-{m:02d}")
if m == end_m:
break
m = m % 12 + 1
# Individual Czech month names (not already part of a range)
for match in re.finditer(rf"\b({month_name_re})\b", normalized):
name = match.group(1)
if name in found_in_ranges:
continue
m = CZECH_MONTHS[name]
# Heuristic: if month > 9 and we're early in the year, it's likely previous year
year = default_year
if m >= 10:
year = default_year - 1
results.add(f"{year:04d}-{m:02d}")
return sorted(results)

527
scripts/match_payments.py Normal file
View File

@@ -0,0 +1,527 @@
#!/usr/bin/env python3
"""Match Fio bank payments against expected attendance fees."""
import argparse
import json
import os
import re
import urllib.request
from datetime import datetime, timedelta
from html.parser import HTMLParser
from attendance import get_members_with_fees
from czech_utils import normalize, parse_month_references
# ---------------------------------------------------------------------------
# Transaction fetching
# ---------------------------------------------------------------------------
class _FioTableParser(HTMLParser):
"""Parse the second <table class="table"> on the Fio transparent page.
Columns: Datum | Částka | Typ | Název protiúčtu | Zpráva pro příjemce | KS | VS | SS | Poznámka
Indices: 0 1 2 3 4 5 6 7 8
"""
def __init__(self):
super().__init__()
self._table_count = 0
self._in_target_table = False
self._in_thead = False
self._in_row = False
self._in_cell = False
self._current_row: list[str] = []
self._rows: list[list[str]] = []
self._cell_text = ""
def handle_starttag(self, tag, attrs):
cls = dict(attrs).get("class", "")
if tag == "table" and "table" in cls.split():
self._table_count += 1
if self._table_count == 2:
self._in_target_table = True
if self._in_target_table:
if tag == "thead":
self._in_thead = True
if tag == "tr" and not self._in_thead:
self._in_row = True
self._current_row = []
if self._in_row and tag in ("td", "th"):
self._in_cell = True
self._cell_text = ""
def handle_endtag(self, tag):
if self._in_cell and tag in ("td", "th"):
self._in_cell = False
self._current_row.append(self._cell_text.strip())
if tag == "thead":
self._in_thead = False
if self._in_row and tag == "tr":
self._in_row = False
if self._current_row:
self._rows.append(self._current_row)
if tag == "table" and self._in_target_table:
self._in_target_table = False
def handle_data(self, data):
if self._in_cell:
self._cell_text += data
def get_rows(self) -> list[list[str]]:
return self._rows
# Fio transparent table column indices
_COL_DATE = 0
_COL_AMOUNT = 1
_COL_SENDER = 3
_COL_MESSAGE = 4
_COL_KS = 5
_COL_VS = 6
_COL_SS = 7
_COL_NOTE = 8
def _parse_czech_amount(s: str) -> float | None:
"""Parse '1 500,00 CZK' to float."""
s = s.replace("\xa0", "").replace(" ", "").replace(",", ".")
s = re.sub(r"[A-Za-z]+", "", s).strip()
try:
return float(s)
except ValueError:
return None
def _parse_czech_date(s: str) -> str | None:
"""Parse 'DD.MM.YYYY' to 'YYYY-MM-DD'."""
s = s.strip()
for fmt in ("%d.%m.%Y", "%d/%m/%Y"):
try:
return datetime.strptime(s, fmt).strftime("%Y-%m-%d")
except ValueError:
continue
return None
def fetch_transactions_transparent(
date_from: str, date_to: str
) -> list[dict]:
"""Fetch transactions from Fio transparent account HTML page.
Args:
date_from: D.M.YYYY format
date_to: D.M.YYYY format
"""
url = (
f"https://ib.fio.cz/ib/transparent?a=2800359168"
f"&f={date_from}&t={date_to}"
)
req = urllib.request.Request(url)
with urllib.request.urlopen(req) as resp:
html = resp.read().decode("utf-8")
parser = _FioTableParser()
parser.feed(html)
rows = parser.get_rows()
transactions = []
for row in rows:
if len(row) < 5:
continue
def col(i):
return row[i].strip() if i < len(row) else ""
date_str = _parse_czech_date(col(_COL_DATE))
amount = _parse_czech_amount(col(_COL_AMOUNT))
if date_str is None or amount is None or amount <= 0:
continue
transactions.append({
"date": date_str,
"amount": amount,
"sender": col(_COL_SENDER),
"message": col(_COL_MESSAGE),
"vs": col(_COL_VS),
})
return transactions
def fetch_transactions_api(
token: str, date_from: str, date_to: str
) -> list[dict]:
"""Fetch transactions via Fio REST API (JSON).
Args:
token: Fio API token
date_from: YYYY-MM-DD format
date_to: YYYY-MM-DD format
"""
url = (
f"https://fioapi.fio.cz/v1/rest/periods/{token}"
f"/{date_from}/{date_to}/transactions.json"
)
req = urllib.request.Request(url)
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read().decode("utf-8"))
transactions = []
tx_list = data.get("accountStatement", {}).get("transactionList", {})
for tx in (tx_list.get("transaction") or []):
# Each field is {"value": ..., "name": ..., "id": ...} or null
def val(col_id):
col = tx.get(f"column{col_id}")
return col["value"] if col else ""
amount = float(val(1) or 0)
if amount <= 0:
continue # Skip outgoing
date_raw = val(0) or ""
# API returns date as "YYYY-MM-DD+HHMM" or ISO format
date_str = date_raw[:10] if date_raw else ""
transactions.append({
"date": date_str,
"amount": amount,
"sender": str(val(10) or ""), # column10 = sender name
"message": str(val(16) or ""), # column16 = message for recipient
"vs": str(val(5) or ""), # column5 = VS
"user_id": str(val(7) or ""), # column7 = user identification
"sender_account": str(val(2) or ""), # column2 = sender account
})
return transactions
def fetch_transactions(date_from: str, date_to: str) -> list[dict]:
"""Fetch transactions, using API if token available, else transparent page."""
token = os.environ.get("FIO_API_TOKEN", "").strip()
if token:
return fetch_transactions_api(token, date_from, date_to)
# Convert YYYY-MM-DD to DD.MM.YYYY for the transparent page URL
from_dt = datetime.strptime(date_from, "%Y-%m-%d")
to_dt = datetime.strptime(date_to, "%Y-%m-%d")
return fetch_transactions_transparent(
from_dt.strftime("%-d.%-m.%Y"),
to_dt.strftime("%-d.%-m.%Y"),
)
# ---------------------------------------------------------------------------
# Name matching
# ---------------------------------------------------------------------------
def _build_name_variants(name: str) -> list[str]:
"""Build searchable name variants from a member name.
E.g. 'František Vrbík (Štrúdl)' → ['frantisek vrbik', 'strudl', 'vrbik']
"""
# Extract nickname from parentheses
nickname_match = re.search(r"\(([^)]+)\)", name)
nickname = nickname_match.group(1) if nickname_match else ""
# Base name without nickname
base = re.sub(r"\s*\([^)]*\)\s*", " ", name).strip()
normalized_base = normalize(base)
normalized_nick = normalize(nickname)
variants = [normalized_base]
if normalized_nick:
variants.append(normalized_nick)
# Also add last name alone (for matching in messages)
parts = normalized_base.split()
if len(parts) >= 2:
variants.append(parts[-1]) # last name
variants.append(parts[0]) # first name
return [v for v in variants if len(v) >= 3]
def match_members(
text: str, member_names: list[str]
) -> list[tuple[str, str]]:
"""Find members mentioned in text.
Returns list of (member_name, confidence) where confidence is 'auto' or 'review'.
"""
normalized_text = normalize(text)
matches = []
for name in member_names:
variants = _build_name_variants(name)
# Full name match = high confidence
full_name = variants[0] if variants else ""
if full_name and full_name in normalized_text:
matches.append((name, "auto"))
continue
# Last name + first name both present = high confidence
parts = full_name.split()
if len(parts) >= 2:
if parts[0] in normalized_text and parts[-1] in normalized_text:
matches.append((name, "auto"))
continue
# Nickname match = high confidence
if len(variants) > 1 and variants[1] in normalized_text:
matches.append((name, "auto"))
continue
# Last name only = lower confidence, but skip very common Czech surnames
_COMMON_SURNAMES = {"novak", "novakova", "prach"}
if (
len(parts) >= 2
and len(parts[-1]) >= 4
and parts[-1] not in _COMMON_SURNAMES
and parts[-1] in normalized_text
):
matches.append((name, "review"))
continue
return matches
# ---------------------------------------------------------------------------
# Reconciliation
# ---------------------------------------------------------------------------
def reconcile(
members: list[tuple[str, str, dict[str, int]]],
sorted_months: list[str],
transactions: list[dict],
) -> dict:
"""Match transactions to members and months.
Returns a dict with:
- 'members': {name: {'tier': str, 'months': {YYYY-MM: {'expected': int, 'paid': int, 'transactions': list}}}}
- 'unmatched': list of transactions that couldn't be matched
- 'credits': {name: int} — excess payments tracked as credit
"""
member_names = [name for name, _, _ in members]
member_tiers = {name: tier for name, tier, _ in members}
member_fees = {name: {m: fee for m, (fee, _) in fees.items()} for name, _, fees in members}
# Initialize ledger
ledger: dict[str, dict[str, dict]] = {}
for name in member_names:
ledger[name] = {}
for m in sorted_months:
ledger[name][m] = {
"expected": member_fees[name].get(m, 0),
"paid": 0,
"transactions": [],
}
unmatched = []
credits: dict[str, int] = {}
for tx in transactions:
# Combine sender + message for searching
search_text = f"{tx['sender']} {tx['message']} {tx.get('user_id', '')}"
matched_members = match_members(search_text, member_names)
matched_months = parse_month_references(
tx["message"] + " " + tx.get("user_id", "")
)
if not matched_members:
# Try matching sender name alone with more lenient matching
matched_members = match_members(tx["sender"], member_names)
if not matched_members:
unmatched.append(tx)
continue
if not matched_months:
# If no month specified, try to infer from payment date
tx_date = tx["date"]
if tx_date:
try:
dt = datetime.strptime(tx_date, "%Y-%m-%d")
# Assume payment is for the current month
matched_months = [dt.strftime("%Y-%m")]
except ValueError:
pass
if not matched_months:
unmatched.append(tx)
continue
# Allocate payment across matched members and months
num_allocations = len(matched_members) * len(matched_months)
per_allocation = tx["amount"] / num_allocations if num_allocations > 0 else 0
for member_name, confidence in matched_members:
for month_key in matched_months:
entry = {
"amount": per_allocation,
"date": tx["date"],
"sender": tx["sender"],
"message": tx["message"],
"confidence": confidence,
}
if month_key in ledger.get(member_name, {}):
ledger[member_name][month_key]["paid"] += per_allocation
ledger[member_name][month_key]["transactions"].append(entry)
else:
# Future month — track as credit
credits[member_name] = credits.get(member_name, 0) + int(per_allocation)
return {
"members": {
name: {
"tier": member_tiers[name],
"months": ledger[name],
}
for name in member_names
},
"unmatched": unmatched,
"credits": credits,
}
# ---------------------------------------------------------------------------
# Report output
# ---------------------------------------------------------------------------
def print_report(result: dict, sorted_months: list[str]):
month_labels = {
m: datetime.strptime(m, "%Y-%m").strftime("%b %Y") for m in sorted_months
}
# --- Per-member breakdown (adults only) ---
print("=" * 80)
print("PAYMENT RECONCILIATION REPORT")
print("=" * 80)
adults = {
name: data
for name, data in result["members"].items()
if data["tier"] == "A"
}
total_expected = 0
total_paid = 0
# Summary table
name_width = max((len(n) for n in adults), default=20)
header = f"{'Member':<{name_width}}"
for m in sorted_months:
header += f" | {month_labels[m]:>10}"
header += " | {'Balance':>10}"
print(f"\n{'Member':<{name_width}}", end="")
for m in sorted_months:
print(f" | {month_labels[m]:>10}", end="")
print(f" | {'Balance':>10}")
print("-" * (name_width + (len(sorted_months) + 1) * 13))
for name in sorted(adults.keys()):
data = adults[name]
line = f"{name:<{name_width}}"
member_balance = 0
for m in sorted_months:
mdata = data["months"].get(m, {"expected": 0, "paid": 0})
expected = mdata["expected"]
paid = int(mdata["paid"])
total_expected += expected
total_paid += paid
if expected == 0 and paid == 0:
cell = "-"
elif paid >= expected and expected > 0:
cell = "OK"
elif paid > 0:
cell = f"{paid}/{expected}"
else:
cell = f"UNPAID {expected}"
member_balance += paid - expected
line += f" | {cell:>10}"
balance_str = f"{member_balance:+d}" if member_balance != 0 else "0"
line += f" | {balance_str:>10}"
print(line)
print("-" * (name_width + (len(sorted_months) + 1) * 13))
print(f"{'TOTAL':<{name_width}}", end="")
for _ in sorted_months:
print(f" | {'':>10}", end="")
balance = total_paid - total_expected
print(f" | {f'Expected: {total_expected}, Paid: {int(total_paid)}, Balance: {balance:+d}'}")
# --- Credits ---
if result["credits"]:
print(f"\n{'CREDITS (advance payments for future months)':}")
for name, amount in sorted(result["credits"].items()):
print(f" {name}: {amount} CZK")
# --- Unmatched transactions ---
if result["unmatched"]:
print(f"\n{'UNMATCHED TRANSACTIONS (need manual review)':}")
print(f" {'Date':<12} {'Amount':>10} {'Sender':<30} {'Message'}")
print(f" {'-'*12} {'-'*10} {'-'*30} {'-'*30}")
for tx in result["unmatched"]:
print(
f" {tx['date']:<12} {tx['amount']:>10.0f} "
f"{tx['sender']:<30} {tx['message']}"
)
# --- Detailed matched transactions ---
print(f"\n{'MATCHED TRANSACTION DETAILS':}")
for name in sorted(adults.keys()):
data = adults[name]
has_payments = any(
data["months"].get(m, {}).get("transactions")
for m in sorted_months
)
if not has_payments:
continue
print(f"\n {name}:")
for m in sorted_months:
mdata = data["months"].get(m, {})
for tx in mdata.get("transactions", []):
conf = " [REVIEW]" if tx["confidence"] == "review" else ""
print(
f" {month_labels[m]}: {tx['amount']:.0f} CZK "
f"from {tx['sender']}\"{tx['message']}\"{conf}"
)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Match bank payments against expected attendance fees."
)
parser.add_argument(
"--from", dest="date_from", default="2025-12-01",
help="Start date YYYY-MM-DD (default: 2025-12-01)",
)
parser.add_argument(
"--to", dest="date_to",
default=datetime.now().strftime("%Y-%m-%d"),
help="End date YYYY-MM-DD (default: today)",
)
args = parser.parse_args()
print(f"Fetching attendance data...")
members, sorted_months = get_members_with_fees()
if not members:
print("No attendance data found.")
return
print(f"Fetching transactions from {args.date_from} to {args.date_to}...")
transactions = fetch_transactions(args.date_from, args.date_to)
print(f"Found {len(transactions)} incoming transactions.\n")
result = reconcile(members, sorted_months, transactions)
print_report(result, sorted_months)
if __name__ == "__main__":
main()

141
templates/fees.html Normal file
View File

@@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FUJ Fees Dashboard</title>
<style>
body {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background-color: #0c0c0c;
/* Deeper black */
color: #cccccc;
/* Base gray terminal text */
padding: 10px;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
font-size: 11px;
/* Even smaller font */
line-height: 1.2;
}
h1 {
color: #00ff00;
/* Terminal green */
font-family: inherit;
/* Use monospace for header too */
margin-top: 10px;
margin-bottom: 20px;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 14px;
}
.table-container {
background-color: transparent;
/* Remove the card background */
border: 1px solid #333;
/* Just a thin outline if needed, or none */
box-shadow: none;
overflow-x: auto;
width: 100%;
max-width: 1200px;
}
table {
border-collapse: collapse;
width: 100%;
table-layout: auto;
}
th,
td {
padding: 2px 8px;
/* Extremely tight padding */
text-align: right;
border-bottom: 1px dashed #222;
/* Dashed lines for terminal feel */
white-space: nowrap;
}
th:first-child,
td:first-child {
text-align: left;
}
th {
background-color: transparent;
color: #888888;
font-weight: normal;
border-bottom: 1px solid #555;
/* Stronger border for header */
text-transform: lowercase;
}
tr:hover {
background-color: #1a1a1a;
/* Very subtle hover */
}
.total {
font-weight: bold;
background-color: transparent;
color: #00ff00;
/* Highlight total row */
border-top: 1px solid #555;
}
.total:hover {
background-color: transparent;
}
.cell-empty {
color: #444444;
/* Darker gray for empty cells */
}
.cell-paid {
color: #aaaaaa;
/* Light gray for normal cells */
}
</style>
</head>
<body>
<h1>FUJ Fees Dashboard</h1>
<div class="table-container">
<table>
<thead>
<tr>
<th>Member</th>
{% for m in months %}
<th>{{ m }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in results %}
<tr>
<td>{{ row.name }}</td>
{% for cell in row.months %}
<td class="{% if cell == '-' %}cell-empty{% else %}cell-paid{% endif %}">{{ cell }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total">
<td>TOTAL</td>
{% for t in totals %}
<td>{{ t }}</td>
{% endfor %}
</tr>
</tfoot>
</table>
</div>
</body>
</html>