From 815b962dd799821bc8dd759df2343f9feaa6c703 Mon Sep 17 00:00:00 2001 From: Jan Novak Date: Mon, 2 Mar 2026 21:41:36 +0100 Subject: [PATCH] feat: add member details popup with attendance and fee exceptions --- .vscode/settings.json | 3 + app.py | 31 ++- scripts/match_payments.py | 71 +++++- templates/fees.html | 12 +- templates/reconcile.html | 353 ++++++++++++++++++++++++++++- tests/test_reconcile_exceptions.py | 56 +++++ uv.lock | 2 +- 7 files changed, 512 insertions(+), 16 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 tests/test_reconcile_exceptions.py diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..082b194 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "makefile.configureOnOpen": false +} \ No newline at end of file diff --git a/app.py b/app.py index 03740bd..4a163ee 100644 --- a/app.py +++ b/app.py @@ -9,7 +9,7 @@ scripts_dir = Path(__file__).parent / "scripts" sys.path.append(str(scripts_dir)) 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 +from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize, DEFAULT_SPREADSHEET_ID as PAYMENTS_SHEET_ID app = Flask(__name__) @@ -37,14 +37,29 @@ def fees(): monthly_totals = {m: 0 for m in sorted_months} + # Get exceptions for formatting + credentials_path = ".secret/fuj-management-bot-credentials.json" + exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path) + formatted_results = [] for name, month_fees in results: row = {"name": name, "months": []} + norm_name = normalize(name) 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) + + # Check for exception + norm_period = normalize(m) + override = exceptions.get((norm_name, norm_period)) + + if override is not None and override != fee: + cell = f"{override} ({fee}) CZK ({count})" if count > 0 else f"{override} ({fee}) CZK" + is_overridden = True + else: + cell = f"{fee} CZK ({count})" if count > 0 else "-" + is_overridden = False + row["months"].append({"cell": cell, "overridden": is_overridden}) formatted_results.append(row) return render_template( @@ -69,7 +84,8 @@ def reconcile_view(): return "No data." transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path) - result = reconcile(members, sorted_months, transactions) + exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path) + result = reconcile(members, sorted_months, transactions, exceptions) # Format month labels month_labels = { @@ -84,8 +100,9 @@ def reconcile_view(): 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}) + mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0}) expected = mdata["expected"] + original = mdata["original_expected"] paid = int(mdata["paid"]) cell_status = "" @@ -106,14 +123,16 @@ def reconcile_view(): # 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"] + import json return render_template( "reconcile.html", months=[month_labels[m] for m in sorted_months], + raw_months=sorted_months, results=formatted_results, + member_data=json.dumps(result["members"]), credits=credits, debts=debts, unmatched=unmatched, diff --git a/scripts/match_payments.py b/scripts/match_payments.py index e7fba02..7a274e4 100644 --- a/scripts/match_payments.py +++ b/scripts/match_payments.py @@ -233,10 +233,49 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]: return transactions +def fetch_exceptions(spreadsheet_id: str, credentials_path: str) -> dict[tuple[str, str], dict]: + """Fetch manual fee overrides from the 'exceptions' sheet. + + Returns a dict mapping (member_name, period_YYYYMM) to {'amount': int, 'note': str}. + """ + service = get_sheets_service(credentials_path) + try: + result = service.spreadsheets().values().get( + spreadsheetId=spreadsheet_id, + range="'exceptions'!A2:D", + valueRenderOption="UNFORMATTED_VALUE" + ).execute() + rows = result.get("values", []) + except Exception as e: + print(f"Warning: Could not fetch exceptions: {e}") + return {} + + exceptions = {} + for row in rows: + if len(row) < 3 or str(row[0]).lower().startswith("name"): + continue + + name = str(row[0]).strip() + period = str(row[1]).strip() + # Robust normalization using czech_utils.normalize + norm_name = normalize(name) + norm_period = normalize(period) + + try: + amount = int(row[2]) + note = str(row[3]).strip() if len(row) > 3 else "" + exceptions[(norm_name, norm_period)] = {"amount": amount, "note": note} + except (ValueError, TypeError): + continue + + return exceptions + + def reconcile( members: list[tuple[str, str, dict[str, int]]], sorted_months: list[str], transactions: list[dict], + exceptions: dict[tuple[str, str], dict] = None, ) -> dict: """Match transactions to members and months. @@ -251,11 +290,30 @@ def reconcile( # Initialize ledger ledger: dict[str, dict[str, dict]] = {} + exceptions = exceptions or {} for name in member_names: ledger[name] = {} for m in sorted_months: + # Robust normalization for lookup + norm_name = normalize(name) + norm_period = normalize(m) + fee_data = member_fees[name].get(m, (0, 0)) + original_expected = fee_data[0] if isinstance(fee_data, tuple) else fee_data + attendance_count = fee_data[1] if isinstance(fee_data, tuple) else 0 + + ex_data = exceptions.get((norm_name, norm_period)) + if ex_data is not None: + expected = ex_data["amount"] + exception_info = ex_data + else: + expected = original_expected + exception_info = None + ledger[name][m] = { - "expected": member_fees[name].get(m, 0), + "expected": expected, + "original_expected": original_expected, + "attendance_count": attendance_count, + "exception": exception_info, "paid": 0, "transactions": [], } @@ -392,10 +450,12 @@ def print_report(result: dict, sorted_months: list[str]): for m in sorted_months: mdata = data["months"].get(m, {"expected": 0, "paid": 0}) expected = mdata["expected"] + original = mdata["original_expected"] paid = int(mdata["paid"]) total_expected += expected total_paid += paid - + + cell_status = "" if expected == 0 and paid == 0: cell = "-" elif paid >= expected and expected > 0: @@ -404,6 +464,7 @@ def print_report(result: dict, sorted_months: list[str]): 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" @@ -509,7 +570,11 @@ def main(): print(f"Processing {len(transactions)} transactions.\n") - result = reconcile(members, sorted_months, transactions) + exceptions = fetch_exceptions(args.sheet_id, args.credentials) + if exceptions: + print(f"Loaded {len(exceptions)} fee exceptions.") + + result = reconcile(members, sorted_months, transactions, exceptions) print_report(result, sorted_months) diff --git a/templates/fees.html b/templates/fees.html index ebc4ed3..df601f1 100644 --- a/templates/fees.html +++ b/templates/fees.html @@ -102,6 +102,11 @@ /* Light gray for normal cells */ } + .cell-overridden { + color: #ffa500 !important; + /* Orange for overrides */ + } + .nav { margin-bottom: 20px; font-size: 12px; @@ -175,8 +180,11 @@ {% for row in results %} {{ row.name }} - {% for cell in row.months %} - {{ cell }} + {% for mdata in row.months %} + + {{ mdata.cell }} + {% endfor %} {% endfor %} diff --git a/templates/reconcile.html b/templates/reconcile.html index 09fe6a0..d55e168 100644 --- a/templates/reconcile.html +++ b/templates/reconcile.html @@ -183,6 +183,166 @@ border-bottom: 1px solid #333; margin-bottom: 5px; } + + .filter-container { + width: 100%; + max-width: 1200px; + margin-bottom: 15px; + display: flex; + align-items: center; + gap: 10px; + } + + .filter-input { + background-color: #1a1a1a; + border: 1px solid #333; + color: #00ff00; + font-family: inherit; + font-size: 11px; + padding: 4px 8px; + width: 250px; + outline: none; + } + + .filter-input:focus { + border-color: #00ff00; + } + + .filter-label { + color: #888; + text-transform: lowercase; + } + + .info-icon { + color: #00ff00; + cursor: pointer; + margin-left: 5px; + font-size: 10px; + opacity: 0.5; + } + + .info-icon:hover { + opacity: 1; + } + + /* Modal Styles */ + #memberModal { + display: none !important; + /* Force hide by default */ + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.9); + z-index: 9999; + justify-content: center; + align-items: center; + } + + #memberModal.active { + display: flex !important; + } + + .modal-content { + background-color: #0c0c0c; + border: 1px solid #00ff00; + width: 90%; + max-width: 800px; + max-height: 85vh; + overflow-y: auto; + padding: 20px; + box-shadow: 0 0 20px rgba(0, 255, 0, 0.2); + position: relative; + } + + .modal-header { + border-bottom: 1px solid #333; + margin-bottom: 20px; + padding-bottom: 10px; + display: flex; + justify-content: space-between; + align-items: center; + } + + .modal-title { + color: #00ff00; + font-size: 14px; + text-transform: uppercase; + } + + .close-btn { + color: #ff3333; + cursor: pointer; + font-size: 14px; + text-transform: lowercase; + } + + .modal-section { + margin-bottom: 25px; + } + + .modal-section-title { + color: #555; + text-transform: uppercase; + font-size: 10px; + margin-bottom: 8px; + border-bottom: 1px dashed #222; + } + + .modal-table { + width: 100%; + border-collapse: collapse; + } + + .modal-table th, + .modal-table td { + text-align: left; + padding: 4px 0; + border-bottom: 1px dashed #1a1a1a; + } + + .modal-table th { + color: #666; + font-weight: normal; + font-size: 10px; + } + + .tx-list { + list-style: none; + padding: 0; + margin: 0; + } + + .tx-item { + padding: 8px 0; + border-bottom: 1px dashed #222; + } + + .tx-meta { + color: #555; + font-size: 10px; + margin-bottom: 4px; + } + + .tx-main { + display: flex; + justify-content: space-between; + gap: 20px; + } + + .tx-amount { + color: #00ff00; + } + + .tx-sender { + color: #ccc; + } + + .tx-msg { + color: #888; + font-style: italic; + } @@ -201,6 +361,11 @@ Payments Ledger +
+ search member: + +
+
@@ -212,10 +377,13 @@ - + {% for row in results %} - - + + {% for cell in row.months %}
Balance
{{ row.name }}
+ {{ row.name }} + [i] + @@ -275,6 +443,183 @@ {% endif %} +
+ +
+ + - \ No newline at end of file + +``` \ No newline at end of file diff --git a/tests/test_reconcile_exceptions.py b/tests/test_reconcile_exceptions.py new file mode 100644 index 0000000..2a40689 --- /dev/null +++ b/tests/test_reconcile_exceptions.py @@ -0,0 +1,56 @@ +import unittest +from scripts.match_payments import reconcile + +class TestReconcileWithExceptions(unittest.TestCase): + def test_reconcile_applies_exceptions(self): + # 1. Setup mock data + # Member: Alice, Tier A, expected 750 (attendance-based) + members = [ + ('Alice', 'A', {'2026-01': (750, 4)}) + ] + sorted_months = ['2026-01'] + + # Exception: Alice should only pay 400 in 2026-01 (normalized keys, no accents) + exceptions = { + ('alice', '2026-01'): {'amount': 400, 'note': 'Test exception'} + } + + # Transaction: Alice paid 400 + transactions = [{ + 'date': '2026-01-05', + 'amount': 400, + 'person': 'Alice', + 'purpose': '2026-01', + 'inferred_amount': 400, + 'sender': 'Alice Sender', + 'message': 'fee' + }] + + # 2. Reconcile + result = reconcile(members, sorted_months, transactions, exceptions) + + # 3. Assertions + alice_data = result['members']['Alice'] + jan_data = alice_data['months']['2026-01'] + + self.assertEqual(jan_data['expected'], 400, "Expected amount should be overridden by exception") + self.assertEqual(jan_data['paid'], 400, "Paid amount should be 400") + self.assertEqual(alice_data['total_balance'], 0, "Balance should be 0 because 400/400") + + def test_reconcile_fallback_to_attendance(self): + # Alice has attendance-based fee 750, NO exception + members = [ + ('Alice', 'A', {'2026-01': (750, 4)}) + ] + sorted_months = ['2026-01'] + exceptions = {} # No exceptions + + transactions = [] + + result = reconcile(members, sorted_months, transactions, exceptions) + + alice_data = result['members']['Alice'] + self.assertEqual(alice_data['months']['2026-01']['expected'], 750, "Should fallback to attendance fee") + +if __name__ == '__main__': + unittest.main() diff --git a/uv.lock b/uv.lock index f019006..db6d8f4 100644 --- a/uv.lock +++ b/uv.lock @@ -199,7 +199,7 @@ wheels = [ [[package]] name = "fuj-management" -version = "0.5" +version = "0.6" source = { virtual = "." } dependencies = [ { name = "flask" },