feat: add reconciliation and ledger views to web dashboard with test suite
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s

This commit is contained in:
Jan Novak
2026-03-02 14:29:48 +01:00
parent d719383c9c
commit 535e1bb772
5 changed files with 713 additions and 2 deletions

114
app.py
View File

@@ -1,13 +1,15 @@
import sys
from pathlib import Path
from datetime import datetime
import re
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
from attendance import get_members_with_fees, SHEET_ID as ATTENDANCE_SHEET_ID
from match_payments import reconcile, fetch_sheet_data, DEFAULT_SPREADSHEET_ID as PAYMENTS_SHEET_ID
app = Flask(__name__)
@@ -18,6 +20,9 @@ def index():
@app.route("/fees")
def fees():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
members, sorted_months = get_members_with_fees()
if not members:
return "No data."
@@ -46,7 +51,112 @@ def fees():
"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]
totals=[f"{monthly_totals[m]} CZK" for m in sorted_months],
attendance_url=attendance_url,
payments_url=payments_url
)
@app.route("/reconcile")
def reconcile_view():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
# Use hardcoded credentials path for now, consistent with other scripts
credentials_path = ".secret/fuj-management-bot-credentials.json"
members, sorted_months = get_members_with_fees()
if not members:
return "No data."
transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
result = reconcile(members, sorted_months, transactions)
# Format month labels
month_labels = {
m: datetime.strptime(m, "%Y-%m").strftime("%b %Y") for m in sorted_months
}
# Filter to adults for the main table
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
formatted_results = []
for name in adult_names:
data = result["members"][name]
row = {"name": name, "months": [], "balance": data["total_balance"]}
for m in sorted_months:
mdata = data["months"].get(m, {"expected": 0, "paid": 0})
expected = mdata["expected"]
paid = int(mdata["paid"])
cell_status = ""
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}"
row["months"].append(cell)
row["balance"] = data["total_balance"] # Updated to use total_balance
formatted_results.append(row)
# Format credits and debts
credits = sorted([{"name": n, "amount": a["total_balance"]} for n, a in result["members"].items() if a["total_balance"] > 0], key=lambda x: x["name"])
debts = sorted([{"name": n, "amount": abs(a["total_balance"])} for n, a in result["members"].items() if a["total_balance"] < 0], key=lambda x: x["name"])
# Format unmatched
unmatched = result["unmatched"]
return render_template(
"reconcile.html",
months=[month_labels[m] for m in sorted_months],
results=formatted_results,
credits=credits,
debts=debts,
unmatched=unmatched,
attendance_url=attendance_url,
payments_url=payments_url
)
@app.route("/payments")
def payments():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
credentials_path = ".secret/fuj-management-bot-credentials.json"
transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
# Group transactions by person
grouped = {}
for tx in transactions:
person = str(tx.get("person", "")).strip()
if not person:
person = "Unmatched / Unknown"
# Handle multiple people (comma separated)
people = [p.strip() for p in person.split(",") if p.strip()]
for p in people:
# Strip markers
clean_p = re.sub(r"\[\?\]\s*", "", p)
if clean_p not in grouped:
grouped[clean_p] = []
grouped[clean_p].append(tx)
# Sort people and their transactions
sorted_people = sorted(grouped.keys())
for p in sorted_people:
# Sort by date descending
grouped[p].sort(key=lambda x: str(x.get("date", "")), reverse=True)
return render_template(
"payments.html",
grouped_payments=grouped,
sorted_people=sorted_people,
attendance_url=attendance_url,
payments_url=payments_url
)
if __name__ == "__main__":