diff --git a/app.py b/app.py index e3881f5..63b6de1 100644 --- a/app.py +++ b/app.py @@ -12,15 +12,15 @@ from flask import Flask, render_template, g, send_file, request scripts_dir = Path(__file__).parent / "scripts" sys.path.append(str(scripts_dir)) -from attendance import get_members_with_fees, get_junior_members_with_fees, SHEET_ID as ATTENDANCE_SHEET_ID, JUNIOR_SHEET_GID, MERGED_MONTHS +from attendance import get_members_with_fees, get_junior_members_with_fees, SHEET_ID as ATTENDANCE_SHEET_ID, JUNIOR_SHEET_GID, ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize, DEFAULT_SPREADSHEET_ID as PAYMENTS_SHEET_ID -def get_month_labels(sorted_months): +def get_month_labels(sorted_months, merged_months): labels = {} for m in sorted_months: dt = datetime.strptime(m, "%Y-%m") # Find which months were merged into m (e.g. 2026-01 is merged into 2026-02) - merged_in = sorted([k for k, v in MERGED_MONTHS.items() if v == m]) + merged_in = sorted([k for k, v in merged_months.items() if v == m]) if merged_in: all_dts = [datetime.strptime(x, "%Y-%m") for x in sorted(merged_in + [m])] years = {d.year for d in all_dts} @@ -87,7 +87,7 @@ def fees(): results = [(name, fees) for name, tier, fees in members if tier == "A"] # Format month labels - month_labels = get_month_labels(sorted_months) + month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS) monthly_totals = {m: 0 for m in sorted_months} @@ -144,7 +144,7 @@ def fees_juniors(): results = sorted([(name, fees) for name, tier, fees in members], key=lambda x: x[0]) # Format month labels - month_labels = get_month_labels(sorted_months) + month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS) monthly_totals = {m: 0 for m in sorted_months} @@ -227,7 +227,7 @@ def reconcile_view(): record_step("reconcile") # Format month labels - month_labels = get_month_labels(sorted_months) + month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS) # Filter to adults for the main table adult_names = sorted([name for name, tier, _ in members if tier == "A"]) @@ -235,7 +235,8 @@ def reconcile_view(): formatted_results = [] for name in adult_names: data = result["members"][name] - row = {"name": name, "months": [], "balance": data["total_balance"]} + row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""} + unpaid_months = [] for m in sorted_months: mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0}) expected = mdata["expected"] @@ -253,10 +254,12 @@ def reconcile_view(): status = "partial" cell_text = f"{paid}/{expected}" amount_to_pay = expected - paid + unpaid_months.append(month_labels[m]) else: status = "unpaid" cell_text = f"UNPAID {expected}" amount_to_pay = expected + unpaid_months.append(month_labels[m]) elif paid > 0: status = "surplus" cell_text = f"PAID {paid}" @@ -268,12 +271,13 @@ def reconcile_view(): "month": month_labels[m] }) + row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "") 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"]) + credits = sorted([{"name": n, "amount": a["total_balance"]} for n, a in result["members"].items() if a["total_balance"] > 0 and n in adult_names], 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 and n in adult_names], key=lambda x: x["name"]) # Format unmatched unmatched = result["unmatched"] import json @@ -330,7 +334,7 @@ def reconcile_juniors_view(): record_step("reconcile") # Format month labels - month_labels = get_month_labels(sorted_months) + month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS) # Filter to juniors for the main table junior_names = sorted([name for name, tier, _ in adapted_members]) @@ -338,7 +342,8 @@ def reconcile_juniors_view(): formatted_results = [] for name in junior_names: data = result["members"][name] - row = {"name": name, "months": [], "balance": data["total_balance"]} + row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""} + unpaid_months = [] for m in sorted_months: mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0}) expected = mdata["expected"] @@ -359,10 +364,12 @@ def reconcile_juniors_view(): status = "partial" cell_text = f"{paid}/{expected}" amount_to_pay = expected - paid + unpaid_months.append(month_labels[m]) else: status = "unpaid" cell_text = f"UNPAID {expected}" amount_to_pay = expected + unpaid_months.append(month_labels[m]) elif paid > 0: status = "surplus" cell_text = f"PAID {paid}" @@ -374,6 +381,7 @@ def reconcile_juniors_view(): "month": month_labels[m] }) + row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "") row["balance"] = data["total_balance"] formatted_results.append(row) diff --git a/scripts/attendance.py b/scripts/attendance.py index ae66fa1..4c6ea32 100644 --- a/scripts/attendance.py +++ b/scripts/attendance.py @@ -17,7 +17,12 @@ JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices JUNIOR_MONTHLY_RATE = { "2025-09": 250 } -MERGED_MONTHS = { +ADULT_MERGED_MONTHS = { + #"2025-12": "2026-01", # keys are merged into values + #"2025-09": "2025-10" +} + +JUNIOR_MERGED_MONTHS = { "2025-12": "2026-01", # keys are merged into values "2025-09": "2025-10" } @@ -65,13 +70,13 @@ def parse_dates(header_row: list[str]) -> list[tuple[int, datetime]]: return dates -def group_by_month(dates: list[tuple[int, datetime]]) -> dict[str, list[int]]: +def group_by_month(dates: list[tuple[int, datetime]], merged_months: dict[str, str]) -> dict[str, list[int]]: """Group column indices by YYYY-MM, handling merged months.""" months: dict[str, list[int]] = {} for col, dt in dates: key = dt.strftime("%Y-%m") # Apply merged month mapping if configured - target_key = MERGED_MONTHS.get(key, key) + target_key = merged_months.get(key, key) months.setdefault(target_key, []).append(col) return months @@ -172,7 +177,7 @@ def get_members_with_fees() -> tuple[list[tuple[str, str, dict[str, int]]], list if not dates: return [], [] - months = group_by_month(dates) + months = group_by_month(dates, ADULT_MERGED_MONTHS) sorted_months = sorted(months.keys()) members_raw = get_members(rows) @@ -211,8 +216,8 @@ def get_junior_members_with_fees() -> tuple[list[tuple[str, str, dict[str, tuple main_dates = parse_dates(main_rows[0]) junior_dates = parse_dates(junior_rows[0]) - main_months = group_by_month(main_dates) - junior_months = group_by_month(junior_dates) + main_months = group_by_month(main_dates, JUNIOR_MERGED_MONTHS) + junior_months = group_by_month(junior_dates, JUNIOR_MERGED_MONTHS) # Collect all unique sorted months all_months = set(main_months.keys()).union(set(junior_months.keys())) diff --git a/scripts/match_payments.py b/scripts/match_payments.py index 3a7e3ab..c400a65 100644 --- a/scripts/match_payments.py +++ b/scripts/match_payments.py @@ -290,9 +290,11 @@ def reconcile( # Initialize ledger ledger: dict[str, dict[str, dict]] = {} + other_ledger: dict[str, list] = {} exceptions = exceptions or {} for name in member_names: ledger[name] = {} + other_ledger[name] = [] for m in sorted_months: # Robust normalization for lookup norm_name = normalize(name) @@ -328,12 +330,13 @@ def reconcile( # Strip markers like [?] person_str = re.sub(r"\[\?\]\s*", "", person_str) - + is_other = purpose_str.lower().startswith("other:") + if person_str and purpose_str: # We have pre-matched data (either from script or manual) # Support multiple people/months in the comma-separated string matched_members = [(p.strip(), "auto") for p in person_str.split(",") if p.strip()] - matched_months = [m.strip() for m in purpose_str.split(",") if m.strip()] + matched_months = [purpose_str] if is_other else [m.strip() for m in purpose_str.split(",") if m.strip()] # Use Inferred Amount if available, otherwise bank Amount amount = tx.get("inferred_amount") @@ -359,6 +362,21 @@ def reconcile( continue # Allocate payment across matched members and months + if is_other: + num_allocations = len(matched_members) + per_allocation = amount / num_allocations if num_allocations > 0 else 0 + for member_name, confidence in matched_members: + if member_name in other_ledger: + other_ledger[member_name].append({ + "amount": per_allocation, + "date": tx["date"], + "sender": tx["sender"], + "message": tx["message"], + "purpose": purpose_str, + "confidence": confidence, + }) + continue + num_allocations = len(matched_members) * len(matched_months) per_allocation = amount / num_allocations if num_allocations > 0 else 0 @@ -399,6 +417,7 @@ def reconcile( name: { "tier": member_tiers[name], "months": ledger[name], + "other_transactions": other_ledger[name], "total_balance": final_balances[name] } for name in member_names diff --git a/templates/reconcile-juniors.html b/templates/reconcile-juniors.html index 8faddf4..d444a23 100644 --- a/templates/reconcile-juniors.html +++ b/templates/reconcile-juniors.html @@ -471,8 +471,12 @@ {% endif %} {% endfor %} - + {{ "%+d"|format(row.balance) if row.balance != 0 else "0" }} + {% if row.balance < 0 %} + + {% endif %} {% endfor %} @@ -576,6 +580,13 @@ + + + +