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

+ +
+ Calculated monthly fees based on attendance markers.
+ Source: Attendance Sheet | + Payments Ledger +
+
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

+ +
+ All bank transactions from the Google Sheet, grouped by member.
+ Source: Attendance Sheet | + Payments Ledger +
+ +
+ {% for person in sorted_people %} +
+

{{ person }}

+
+ + + + + + + + + + {% for tx in grouped_payments[person] %} + + + + + + + {% endfor %} + +
DateAmountPurposeBank Message
{{ tx.date }}{{ tx.amount }} CZK{{ tx.purpose }}{{ tx.message }}
+
+ {% 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

+ +
+ Balances calculated by matching Google Sheet payments against attendance fees.
+ Source: Attendance Sheet | + Payments Ledger +
+ +
+ + + + + {% for m in months %} + + {% endfor %} + + + + + {% for row in results %} + + + {% for cell in row.months %} + + {% endfor %} + + + {% endfor %} + +
Member{{ m }}Balance
{{ row.name }} + {{ cell }} + + {{ "%+d"|format(row.balance) if row.balance != 0 else "0" }} +
+
+ + {% 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

+
+
+ Date + Amount + Sender + Message +
+ {% 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()