diff --git a/app.py b/app.py index 7d5a524..d34ccc9 100644 --- a/app.py +++ b/app.py @@ -24,6 +24,8 @@ from config import ( from attendance import get_members_with_fees, get_junior_members_with_fees, ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED, flush_cache +from sync_fio_to_sheets import sync_to_sheets +from infer_payments import infer_payments def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs): mod_time = get_sheet_modified_time(cache_key) @@ -120,6 +122,35 @@ def flush_cache_endpoint(): deleted = flush_cache() return jsonify({"status": "ok", "deleted_files": deleted}) +@app.route("/sync-bank") +def sync_bank(): + import contextlib + output = io.StringIO() + success = True + try: + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output): + # sync_to_sheets: equivalent of make sync-2026 + output.write("=== Syncing Fio transactions (2026) ===\n") + sync_to_sheets( + spreadsheet_id=PAYMENTS_SHEET_ID, + credentials_path=CREDENTIALS_PATH, + date_from_str="2026-01-01", + date_to_str="2026-12-31", + sort_by_date=True, + ) + output.write("\n=== Inferring payment details ===\n") + infer_payments(PAYMENTS_SHEET_ID, CREDENTIALS_PATH) + output.write("\n=== Flushing cache ===\n") + deleted = flush_cache() + output.write(f"Deleted {deleted} cache files.\n") + output.write("\n=== Done ===\n") + except Exception as e: + import traceback + output.write(f"\n!!! Error: {e}\n") + output.write(traceback.format_exc()) + success = False + return render_template("sync.html", output=output.getvalue(), success=success) + @app.route("/version") def version(): return BUILD_META @@ -300,8 +331,9 @@ def adults_view(): formatted_results = [] for name in adult_names: data = result["members"][name] - row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""} + row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""} unpaid_months = [] + raw_unpaid_months = [] for m in sorted_months: mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None}) expected = mdata.get("expected", 0) @@ -309,12 +341,12 @@ def adults_view(): count = mdata.get("attendance_count", 0) paid = int(mdata.get("paid", 0)) exception_info = mdata.get("exception", None) - + monthly_totals[m]["expected"] += expected monthly_totals[m]["paid"] += paid - + override_amount = exception_info["amount"] if exception_info else None - + if override_amount is not None and override_amount != original_expected: is_overridden = True fee_display = f"{override_amount} ({original_expected}) CZK ({count})" if count > 0 else f"{override_amount} ({original_expected}) CZK" @@ -325,7 +357,7 @@ def adults_view(): status = "empty" cell_text = "-" amount_to_pay = 0 - + if expected > 0: amount_to_pay = max(0, expected - paid) if paid >= expected: @@ -335,32 +367,36 @@ def adults_view(): status = "partial" cell_text = f"{paid}/{fee_display}" unpaid_months.append(month_labels[m]) + raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y")) else: status = "unpaid" cell_text = f"0/{fee_display}" unpaid_months.append(month_labels[m]) + raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y")) elif paid > 0: status = "surplus" cell_text = f"PAID {paid}" else: cell_text = "-" amount_to_pay = 0 - + if expected > 0 or paid > 0: tooltip = f"Received: {paid}, Expected: {expected}" else: tooltip = "" - + row["months"].append({ "text": cell_text, "overridden": is_overridden, "status": status, "amount": amount_to_pay, "month": month_labels[m], + "raw_month": m, "tooltip": tooltip }) - + row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "") + row["raw_unpaid_periods"] = "+".join(raw_unpaid_months) row["balance"] = data["total_balance"] formatted_results.append(row) @@ -439,17 +475,18 @@ def reconcile_view(): formatted_results = [] for name in adult_names: data = result["members"][name] - row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""} + row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""} unpaid_months = [] + raw_unpaid_months = [] for m in sorted_months: mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0}) expected = mdata["expected"] paid = int(mdata["paid"]) - + status = "empty" cell_text = "-" amount_to_pay = 0 - + if expected > 0: if paid >= expected: status = "ok" @@ -459,23 +496,27 @@ def reconcile_view(): cell_text = f"{paid}/{expected}" amount_to_pay = expected - paid unpaid_months.append(month_labels[m]) + raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y")) else: status = "unpaid" cell_text = f"UNPAID {expected}" amount_to_pay = expected unpaid_months.append(month_labels[m]) + raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y")) elif paid > 0: status = "surplus" cell_text = f"PAID {paid}" - + row["months"].append({ "text": cell_text, "status": status, "amount": amount_to_pay, - "month": month_labels[m] + "month": month_labels[m], + "raw_month": m }) - + row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "") + row["raw_unpaid_periods"] = "+".join(raw_unpaid_months) row["balance"] = data["total_balance"] # Updated to use total_balance formatted_results.append(row) @@ -552,8 +593,9 @@ def juniors_view(): formatted_results = [] for name in junior_names: data = result["members"][name] - row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""} + row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""} unpaid_months = [] + raw_unpaid_months = [] for m in sorted_months: mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None}) expected = mdata.get("expected", 0) @@ -561,11 +603,11 @@ def juniors_view(): count = mdata.get("attendance_count", 0) paid = int(mdata.get("paid", 0)) exception_info = mdata.get("exception", None) - + if expected != "?" and isinstance(expected, int): monthly_totals[m]["expected"] += expected monthly_totals[m]["paid"] += paid - + orig_fee_data = junior_members_dict.get(name, {}).get(m) adult_count = 0 junior_count = 0 @@ -581,9 +623,9 @@ def juniors_view(): breakdown = f":{adult_count}A" count_str = f" ({count}{breakdown})" if count > 0 else "" - + override_amount = exception_info["amount"] if exception_info else None - + if override_amount is not None and override_amount != original_expected: is_overridden = True fee_display = f"{override_amount} ({original_expected}) CZK{count_str}" @@ -594,7 +636,7 @@ def juniors_view(): status = "empty" cell_text = "-" amount_to_pay = 0 - + if expected == "?" or (isinstance(expected, int) and expected > 0): if expected == "?": status = "empty" @@ -607,30 +649,34 @@ def juniors_view(): cell_text = f"{paid}/{fee_display}" amount_to_pay = expected - paid unpaid_months.append(month_labels[m]) + raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y")) else: status = "unpaid" cell_text = f"0/{fee_display}" amount_to_pay = expected unpaid_months.append(month_labels[m]) + raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y")) elif paid > 0: status = "surplus" cell_text = f"PAID {paid}" - + if (isinstance(expected, int) and expected > 0) or paid > 0: tooltip = f"Received: {paid}, Expected: {expected}" else: tooltip = "" - + row["months"].append({ "text": cell_text, "overridden": is_overridden, "status": status, "amount": amount_to_pay, "month": month_labels[m], + "raw_month": m, "tooltip": tooltip }) - + row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "") + row["raw_unpaid_periods"] = "+".join(raw_unpaid_months) row["balance"] = data["total_balance"] formatted_results.append(row) @@ -726,8 +772,9 @@ def reconcile_juniors_view(): formatted_results = [] for name in junior_names: data = result["members"][name] - row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""} + row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""} unpaid_months = [] + raw_unpaid_months = [] for m in sorted_months: mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0}) expected = mdata["expected"] @@ -766,23 +813,27 @@ def reconcile_juniors_view(): cell_text = f"{paid}/{expected}" amount_to_pay = expected - paid unpaid_months.append(month_labels[m]) + raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y")) else: status = "unpaid" cell_text = f"UNPAID {expected}" amount_to_pay = expected unpaid_months.append(month_labels[m]) + raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y")) elif paid > 0: status = "surplus" cell_text = f"PAID {paid}" - + row["months"].append({ "text": cell_text, "status": status, "amount": amount_to_pay, - "month": month_labels[m] + "month": month_labels[m], + "raw_month": m }) - + row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "") + row["raw_unpaid_periods"] = "+".join(raw_unpaid_months) row["balance"] = data["total_balance"] formatted_results.append(row) diff --git a/templates/adults.html b/templates/adults.html index 0420116..5736b0c 100644 --- a/templates/adults.html +++ b/templates/adults.html @@ -464,6 +464,10 @@ [Junior Payment Reconciliation] [Payments Ledger] +

Adults Dashboard

@@ -503,7 +507,7 @@ {{ cell.text }} {% if cell.status == 'unpaid' or cell.status == 'partial' %} + onclick="showPayQR('{{ row.name|e }}', {{ cell.amount }}, '{{ cell.month|e }}', '{{ cell.raw_month }}')">Pay {% endif %} {% endfor %} @@ -511,7 +515,7 @@ {{ "%+d"|format(row.balance) if row.balance != 0 else "0" }} {% if row.balance < 0 %} + onclick="showPayQR('{{ row.name|e }}', {{ -row.balance }}, '{{ row.unpaid_periods|e }}', '{{ row.raw_unpaid_periods|e }}')">Pay All {% endif %} @@ -857,9 +861,13 @@ showMemberDetails(nextName); } } - function showPayQR(name, amount, month) { + function showPayQR(name, amount, month, rawMonth) { const account = "{{ bank_account }}"; - const message = `${name} / ${month}`; + // Convert YYYY-MM to MM/YYYY for infer_payments.py compatibility + const numericMonth = rawMonth.includes('+') + ? rawMonth.split('+').map(p => p.replace(/(\d{4})-(\d{2})/, '$2/$1')).join('+') + : rawMonth.replace(/(\d{4})-(\d{2})/, '$2/$1'); + const message = `${name} / ${numericMonth}`; const qrTitle = document.getElementById('qrTitle'); const qrImg = document.getElementById('qrImg'); const qrAccount = document.getElementById('qrAccount'); diff --git a/templates/fees-juniors.html b/templates/fees-juniors.html index fdb5a72..1184682 100644 --- a/templates/fees-juniors.html +++ b/templates/fees-juniors.html @@ -192,6 +192,10 @@ [Junior Payment Reconciliation] [Payments Ledger] +

FUJ Junior Fees Dashboard

diff --git a/templates/fees.html b/templates/fees.html index 45bfd55..64d2eb8 100644 --- a/templates/fees.html +++ b/templates/fees.html @@ -207,6 +207,10 @@ [Junior Payment Reconciliation] [Payments Ledger] +

FUJ Fees Dashboard

diff --git a/templates/juniors.html b/templates/juniors.html index 22d79eb..209e322 100644 --- a/templates/juniors.html +++ b/templates/juniors.html @@ -464,6 +464,10 @@ [Junior Payment Reconciliation] [Payments Ledger] +

Juniors Dashboard

@@ -503,7 +507,7 @@ {{ cell.text }} {% if cell.status == 'unpaid' or cell.status == 'partial' %} + onclick="showPayQR('{{ row.name|e }}', {{ cell.amount }}, '{{ cell.month|e }}', '{{ cell.raw_month }}')">Pay {% endif %} {% endfor %} @@ -511,7 +515,7 @@ {{ "%+d"|format(row.balance) if row.balance != 0 else "0" }} {% if row.balance < 0 %} + onclick="showPayQR('{{ row.name|e }}', {{ -row.balance }}, '{{ row.unpaid_periods|e }}', '{{ row.raw_unpaid_periods|e }}')">Pay All {% endif %} @@ -838,9 +842,13 @@ showMemberDetails(nextName); } } - function showPayQR(name, amount, month) { + function showPayQR(name, amount, month, rawMonth) { const account = "{{ bank_account }}"; - const message = `${name} / ${month}`; + // Convert YYYY-MM to MM/YYYY for infer_payments.py compatibility + const numericMonth = rawMonth.includes('+') + ? rawMonth.split('+').map(p => p.replace(/(\d{4})-(\d{2})/, '$2/$1')).join('+') + : rawMonth.replace(/(\d{4})-(\d{2})/, '$2/$1'); + const message = `${name} / ${numericMonth}`; const qrTitle = document.getElementById('qrTitle'); const qrImg = document.getElementById('qrImg'); const qrAccount = document.getElementById('qrAccount'); diff --git a/templates/payments.html b/templates/payments.html index 0ab36d9..b33613d 100644 --- a/templates/payments.html +++ b/templates/payments.html @@ -196,6 +196,10 @@ [Junior Payment Reconciliation] [Payments Ledger] +

Payments Ledger

diff --git a/templates/reconcile-juniors.html b/templates/reconcile-juniors.html index de7aa3a..8b04011 100644 --- a/templates/reconcile-juniors.html +++ b/templates/reconcile-juniors.html @@ -460,6 +460,10 @@ [Junior Payment Reconciliation] [Payments Ledger] +

Junior Payment Reconciliation

@@ -499,7 +503,7 @@ {{ cell.text }} {% if cell.status == 'unpaid' or cell.status == 'partial' %} + onclick="showPayQR('{{ row.name|e }}', {{ cell.amount }}, '{{ cell.month|e }}', '{{ cell.raw_month }}')">Pay {% endif %} {% endfor %} @@ -507,7 +511,7 @@ {{ "%+d"|format(row.balance) if row.balance != 0 else "0" }} {% if row.balance < 0 %} + onclick="showPayQR('{{ row.name|e }}', {{ -row.balance }}, '{{ row.unpaid_periods|e }}', '{{ row.raw_unpaid_periods|e }}')">Pay All {% endif %} @@ -841,9 +845,13 @@ showMemberDetails(nextName); } } - function showPayQR(name, amount, month) { + function showPayQR(name, amount, month, rawMonth) { const account = "{{ bank_account }}"; - const message = `${name} / ${month}`; + // Convert YYYY-MM to MM/YYYY for infer_payments.py compatibility + const numericMonth = rawMonth.includes('+') + ? rawMonth.split('+').map(p => p.replace(/(\d{4})-(\d{2})/, '$2/$1')).join('+') + : rawMonth.replace(/(\d{4})-(\d{2})/, '$2/$1'); + const message = `${name} / ${numericMonth}`; const qrTitle = document.getElementById('qrTitle'); const qrImg = document.getElementById('qrImg'); const qrAccount = document.getElementById('qrAccount'); diff --git a/templates/reconcile.html b/templates/reconcile.html index 02ac0c5..5879f5e 100644 --- a/templates/reconcile.html +++ b/templates/reconcile.html @@ -460,6 +460,10 @@ [Junior Payment Reconciliation] [Payments Ledger] +

Payment Reconciliation

@@ -499,7 +503,7 @@ {{ cell.text }} {% if cell.status == 'unpaid' or cell.status == 'partial' %} + onclick="showPayQR('{{ row.name|e }}', {{ cell.amount }}, '{{ cell.month|e }}', '{{ cell.raw_month }}')">Pay {% endif %} {% endfor %} @@ -507,7 +511,7 @@ {{ "%+d"|format(row.balance) if row.balance != 0 else "0" }} {% if row.balance < 0 %} + onclick="showPayQR('{{ row.name|e }}', {{ -row.balance }}, '{{ row.unpaid_periods|e }}', '{{ row.raw_unpaid_periods|e }}')">Pay All {% endif %} @@ -841,9 +845,13 @@ showMemberDetails(nextName); } } - function showPayQR(name, amount, month) { + function showPayQR(name, amount, month, rawMonth) { const account = "{{ bank_account }}"; - const message = `${name} / ${month}`; + // Convert YYYY-MM to MM/YYYY for infer_payments.py compatibility + const numericMonth = rawMonth.includes('+') + ? rawMonth.split('+').map(p => p.replace(/(\d{4})-(\d{2})/, '$2/$1')).join('+') + : rawMonth.replace(/(\d{4})-(\d{2})/, '$2/$1'); + const message = `${name} / ${numericMonth}`; const qrTitle = document.getElementById('qrTitle'); const qrImg = document.getElementById('qrImg'); const qrAccount = document.getElementById('qrAccount'); diff --git a/templates/sync.html b/templates/sync.html new file mode 100644 index 0000000..0c0f6ed --- /dev/null +++ b/templates/sync.html @@ -0,0 +1,156 @@ + + + + + + + FUJ - Sync Bank Data + + + + + + +

Sync Bank Data

+ +
+ {% if success %} + Sync completed successfully. + {% else %} + Sync failed - see output below. + {% endif %} +
+ +
+
{{ output }}
+
+ + + + +