commit 3bfea4e0a44d21e1cc5d1a90ed23c22a45ec715f Author: Jan Novak Date: Fri Feb 27 13:19:00 2026 +0100 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 diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..9563caa --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9f9899 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# python cache +**/*.pyc diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5b387e4 --- /dev/null +++ b/CLAUDE.md @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e8e4e1a --- /dev/null +++ b/Makefile @@ -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 . diff --git a/app.py b/app.py new file mode 100644 index 0000000..5630a61 --- /dev/null +++ b/app.py @@ -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 '' + +@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) diff --git a/build/.dockerignore b/build/.dockerignore new file mode 100644 index 0000000..cc3255f --- /dev/null +++ b/build/.dockerignore @@ -0,0 +1,6 @@ +.git +.venv +__pycache__ +*.pyc +.claude +.gemini diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..dbf3cce --- /dev/null +++ b/build/Dockerfile @@ -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"] diff --git a/build/entrypoint.sh b/build/entrypoint.sh new file mode 100755 index 0000000..8d50a84 --- /dev/null +++ b/build/entrypoint.sh @@ -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 diff --git a/docs/fee-calculation-spec.md b/docs/fee-calculation-spec.md new file mode 100644 index 0000000..ffb76c5 --- /dev/null +++ b/docs/fee-calculation-spec.md @@ -0,0 +1,69 @@ +# Fee Calculation Spec — Tuesday Practices + +## Data Source + +- Google Sheet: `1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA` +- Sheet: first sheet only (Tuesday practices, 20:30–22: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. diff --git a/docs/project-notes.md b/docs/project-notes.md new file mode 100644 index 0000000..5dc1932 --- /dev/null +++ b/docs/project-notes.md @@ -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 | \ No newline at end of file diff --git a/docs/scripts.md b/docs/scripts.md new file mode 100644 index 0000000..b0d5b53 --- /dev/null +++ b/docs/scripts.md @@ -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 | diff --git a/scripts/attendance.py b/scripts/attendance.py new file mode 100644 index 0000000..cc46c2b --- /dev/null +++ b/scripts/attendance.py @@ -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 diff --git a/scripts/calculate_fees.py b/scripts/calculate_fees.py new file mode 100644 index 0000000..85b4940 --- /dev/null +++ b/scripts/calculate_fees.py @@ -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() diff --git a/scripts/czech_utils.py b/scripts/czech_utils.py new file mode 100644 index 0000000..759ac0d --- /dev/null +++ b/scripts/czech_utils.py @@ -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) diff --git a/scripts/match_payments.py b/scripts/match_payments.py new file mode 100644 index 0000000..3e7c27f --- /dev/null +++ b/scripts/match_payments.py @@ -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 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() diff --git a/templates/fees.html b/templates/fees.html new file mode 100644 index 0000000..52d4727 --- /dev/null +++ b/templates/fees.html @@ -0,0 +1,141 @@ + + + + + + + FUJ Fees Dashboard + + + + +

FUJ Fees Dashboard

+
+
+ + + + {% for m in months %} + + {% endfor %} + + + + {% for row in results %} + + + {% for cell in row.months %} + + {% endfor %} + + {% endfor %} + + + + + {% for t in totals %} + + {% endfor %} + + +
Member{{ m }}
{{ row.name }}{{ cell }}
TOTAL{{ t }}
+ + + + \ No newline at end of file