diff --git a/app.py b/app.py
index 3681c35..03740bd 100644
--- a/app.py
+++ b/app.py
@@ -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__":
diff --git a/templates/fees.html b/templates/fees.html
index 52d4727..ebc4ed3 100644
--- a/templates/fees.html
+++ b/templates/fees.html
@@ -101,11 +101,66 @@
color: #aaaaaa;
/* Light gray for normal cells */
}
+
+ .nav {
+ margin-bottom: 20px;
+ font-size: 12px;
+ color: #555;
+ display: flex;
+ gap: 15px;
+ }
+
+ .nav a {
+ color: #00ff00;
+ text-decoration: none;
+ padding: 2px 8px;
+ border: 1px solid #333;
+ }
+
+ .nav a.active {
+ color: #000;
+ background-color: #00ff00;
+ border-color: #00ff00;
+ }
+
+ .nav a:hover {
+ color: #fff;
+ border-color: #555;
+ }
+
+ .description {
+ margin-bottom: 20px;
+ text-align: center;
+ color: #888;
+ max-width: 800px;
+ }
+
+ .description a {
+ color: #00ff00;
+ text-decoration: none;
+ }
+
+ .description a:hover {
+ text-decoration: underline;
+ }
+
+
FUJ Fees Dashboard
+
+
+
diff --git a/templates/payments.html b/templates/payments.html
new file mode 100644
index 0000000..0ee6d77
--- /dev/null
+++ b/templates/payments.html
@@ -0,0 +1,188 @@
+
+
+
+
+
+
+ FUJ Payments Ledger
+
+
+
+
+
+
+ Payments Ledger
+
+
+
+
+ {% for person in sorted_people %}
+
+
{{ person }}
+
+
+
+ | Date |
+ Amount |
+ Purpose |
+ Bank Message |
+
+
+
+ {% for tx in grouped_payments[person] %}
+
+ | {{ tx.date }} |
+ {{ tx.amount }} CZK |
+ {{ tx.purpose }} |
+ {{ tx.message }} |
+
+ {% endfor %}
+
+
+
+ {% endfor %}
+
+
+
+
+
\ No newline at end of file
diff --git a/templates/reconcile.html b/templates/reconcile.html
new file mode 100644
index 0000000..09fe6a0
--- /dev/null
+++ b/templates/reconcile.html
@@ -0,0 +1,280 @@
+
+
+
+
+
+
+ FUJ Payment Reconciliation
+
+
+
+
+
+
+ Payment Reconciliation
+
+
+
+
+
+
+
+ | Member |
+ {% for m in months %}
+ {{ m }} |
+ {% endfor %}
+ Balance |
+
+
+
+ {% for row in results %}
+
+ | {{ row.name }} |
+ {% for cell in row.months %}
+
+ {{ cell }}
+ |
+ {% endfor %}
+
+ {{ "%+d"|format(row.balance) if row.balance != 0 else "0" }}
+ |
+
+ {% endfor %}
+
+
+
+
+ {% if credits %}
+ Credits (Advance Payments / Surplus)
+
+ {% for item in credits %}
+
+ {{ item.name }}
+ {{ item.amount }} CZK
+
+ {% endfor %}
+
+ {% endif %}
+
+ {% if debts %}
+ Debts (Missing Payments)
+
+ {% for item in debts %}
+
+ {{ item.name }}
+ {{ item.amount }} CZK
+
+ {% endfor %}
+
+ {% endif %}
+
+ {% if unmatched %}
+ Unmatched Transactions
+
+
+ {% for tx in unmatched %}
+
+ {{ tx.date }}
+ {{ tx.amount }}
+ {{ tx.sender }}
+ {{ tx.message }}
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
+
\ No newline at end of file
diff --git a/tests/test_app.py b/tests/test_app.py
new file mode 100644
index 0000000..07290ec
--- /dev/null
+++ b/tests/test_app.py
@@ -0,0 +1,78 @@
+import unittest
+from unittest.mock import patch, MagicMock
+from app import app
+
+class TestWebApp(unittest.TestCase):
+ def setUp(self):
+ # Configure app for testing
+ app.config['TESTING'] = True
+ self.client = app.test_client()
+
+ @patch('app.get_members_with_fees')
+ def test_index_page(self, mock_get_members):
+ """Test that / returns the refresh meta tag"""
+ response = self.client.get('/')
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(b'url=/fees', response.data)
+
+ @patch('app.get_members_with_fees')
+ def test_fees_route(self, mock_get_members):
+ """Test that /fees returns 200 and renders the dashboard"""
+ # Mock attendance data
+ mock_get_members.return_value = (
+ [('Test Member', 'A', {'2026-01': (750, 4)})],
+ ['2026-01']
+ )
+
+ response = self.client.get('/fees')
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(b'FUJ Fees Dashboard', response.data)
+ self.assertIn(b'Test Member', response.data)
+
+ @patch('app.fetch_sheet_data')
+ @patch('app.get_members_with_fees')
+ def test_reconcile_route(self, mock_get_members, mock_fetch_sheet):
+ """Test that /reconcile returns 200 and shows matches"""
+ # Mock attendance data
+ mock_get_members.return_value = (
+ [('Test Member', 'A', {'2026-01': (750, 4)})],
+ ['2026-01']
+ )
+ # Mock sheet data - include all keys required by reconcile
+ mock_fetch_sheet.return_value = [{
+ 'date': '2026-01-01',
+ 'amount': 750,
+ 'person': 'Test Member',
+ 'purpose': '2026-01',
+ 'message': 'test payment',
+ 'sender': 'External Bank User',
+ 'inferred_amount': 750
+ }]
+
+ response = self.client.get('/reconcile')
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(b'Payment Reconciliation', response.data)
+ self.assertIn(b'Test Member', response.data)
+ self.assertIn(b'OK', response.data)
+
+ @patch('app.fetch_sheet_data')
+ def test_payments_route(self, mock_fetch_sheet):
+ """Test that /payments returns 200 and groups transactions"""
+ # Mock sheet data
+ mock_fetch_sheet.return_value = [{
+ 'date': '2026-01-01',
+ 'amount': 750,
+ 'person': 'Test Member',
+ 'purpose': '2026-01',
+ 'message': 'Direct Member Payment',
+ 'sender': 'External Bank User'
+ }]
+
+ response = self.client.get('/payments')
+ self.assertEqual(response.status_code, 200)
+ self.assertIn(b'Payments Ledger', response.data)
+ self.assertIn(b'Test Member', response.data)
+ self.assertIn(b'Direct Member Payment', response.data)
+
+if __name__ == '__main__':
+ unittest.main()