diff --git a/Makefile b/Makefile index 120b0e6..a337296 100644 --- a/Makefile +++ b/Makefile @@ -19,6 +19,7 @@ help: @echo " make image - Build an OCI container image" @echo " make run - Run the built Docker image locally" @echo " make sync - Sync Fio transactions to Google Sheets" + @echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)" @echo " make sync-2026 - Sync Fio transactions for the whole year of 2026" @echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet" @echo " make reconcile - Show balance report using Google Sheets data" @@ -43,11 +44,14 @@ image: docker build -t fuj-management:latest -f build/Dockerfile . run: - docker run -it --rm -p 5001:5001 fuj-management:latest + docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest sync: $(PYTHON) $(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json +sync-2025: $(PYTHON) + $(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2025-10-01 --to 2025-12-31 --sort-by-date + sync-2026: $(PYTHON) $(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2026-01-01 --to 2026-12-31 --sort-by-date diff --git a/app.py b/app.py index 00d8b41..e3881f5 100644 --- a/app.py +++ b/app.py @@ -12,9 +12,28 @@ 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, SHEET_ID as ATTENDANCE_SHEET_ID +from attendance import get_members_with_fees, get_junior_members_with_fees, SHEET_ID as ATTENDANCE_SHEET_ID, JUNIOR_SHEET_GID, 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): + 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]) + 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} + if len(years) > 1: + parts = [d.strftime("%b %Y") for d in all_dts] + labels[m] = "+".join(parts) + else: + parts = [d.strftime("%b") for d in all_dts] + labels[m] = f"{'+'.join(parts)} {dt.strftime('%Y')}" + else: + labels[m] = dt.strftime("%b %Y") + return labels + app = Flask(__name__) # Bank account for QR code payments (can be overridden by ENV) @@ -68,9 +87,7 @@ def fees(): results = [(name, fees) for name, tier, fees in members if tier == "A"] # Format month labels - month_labels = { - m: datetime.strptime(m, "%Y-%m").strftime("%b %Y") for m in sorted_months - } + month_labels = get_month_labels(sorted_months) monthly_totals = {m: 0 for m in sorted_months} @@ -85,7 +102,6 @@ def fees(): norm_name = normalize(name) for m in sorted_months: fee, count = month_fees.get(m, (0, 0)) - monthly_totals[m] += fee # Check for exception norm_period = normalize(m) @@ -96,6 +112,8 @@ def fees(): cell = f"{override_amount} ({fee}) CZK ({count})" if count > 0 else f"{override_amount} ({fee}) CZK" is_overridden = True else: + if isinstance(fee, int): + monthly_totals[m] += fee cell = f"{fee} CZK ({count})" if count > 0 else "-" is_overridden = False row["months"].append({"cell": cell, "overridden": is_overridden}) @@ -112,6 +130,82 @@ def fees(): payments_url=payments_url ) +@app.route("/fees-juniors") +def fees_juniors(): + attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}" + payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit" + + members, sorted_months = get_junior_members_with_fees() + record_step("fetch_junior_members") + if not members: + return "No data." + + # Sort members by name + 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) + + monthly_totals = {m: 0 for m in sorted_months} + + # Get exceptions for formatting (reusing payments sheet) + credentials_path = ".secret/fuj-management-bot-credentials.json" + exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path) + record_step("fetch_exceptions") + + formatted_results = [] + for name, month_fees in results: + row = {"name": name, "months": []} + norm_name = normalize(name) + for m in sorted_months: + fee_data = month_fees.get(m, (0, 0, 0, 0)) + if len(fee_data) == 4: + fee, total_count, adult_count, junior_count = fee_data + else: + fee, total_count = fee_data + adult_count, junior_count = 0, 0 + + # Check for exception + norm_period = normalize(m) + ex_data = exceptions.get((norm_name, norm_period)) + override_amount = ex_data["amount"] if ex_data else None + + if ex_data is None and isinstance(fee, int): + monthly_totals[m] += fee + + # Formulate the count string display + if adult_count > 0 and junior_count > 0: + count_str = f"{total_count} ({adult_count}A+{junior_count}J)" + elif adult_count > 0: + count_str = f"{total_count} (A)" + elif junior_count > 0: + count_str = f"{total_count} (J)" + else: + count_str = f"{total_count}" + + if override_amount is not None and override_amount != fee: + cell = f"{override_amount} ({fee}) CZK / {count_str}" if total_count > 0 else f"{override_amount} ({fee}) CZK" + is_overridden = True + else: + if fee == "?": + cell = f"? / {count_str}" if total_count > 0 else "-" + else: + cell = f"{fee} CZK / {count_str}" if total_count > 0 else "-" + is_overridden = False + row["months"].append({"cell": cell, "overridden": is_overridden}) + formatted_results.append(row) + + record_step("process_data") + + return render_template( + "fees-juniors.html", + months=[month_labels[m] for m in sorted_months], + results=formatted_results, + totals=[f"{t} CZK" if isinstance(t, int) else t for t in monthly_totals.values()], + 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" @@ -133,9 +227,7 @@ def reconcile_view(): record_step("reconcile") # Format month labels - month_labels = { - m: datetime.strptime(m, "%Y-%m").strftime("%b %Y") for m in sorted_months - } + month_labels = get_month_labels(sorted_months) # Filter to adults for the main table adult_names = sorted([name for name, tier, _ in members if tier == "A"]) @@ -194,6 +286,112 @@ def reconcile_view(): raw_months=sorted_months, results=formatted_results, member_data=json.dumps(result["members"]), + month_labels_json=json.dumps(month_labels), + credits=credits, + debts=debts, + unmatched=unmatched, + attendance_url=attendance_url, + payments_url=payments_url, + bank_account=BANK_ACCOUNT + ) + +@app.route("/reconcile-juniors") +def reconcile_juniors_view(): + attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}" + payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit" + + credentials_path = ".secret/fuj-management-bot-credentials.json" + + junior_members, sorted_months = get_junior_members_with_fees() + record_step("fetch_junior_members") + if not junior_members: + return "No data." + + transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path) + record_step("fetch_payments") + exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path) + record_step("fetch_exceptions") + + # Adapt junior tuple format (name, tier, {month: (fee, total_count, adult_count, junior_count)}) + # to what match_payments expects: (name, tier, {month: (expected_fee, attendance_count)}) + adapted_members = [] + for name, tier, fees_dict in junior_members: + adapted_fees = {} + for m, fee_data in fees_dict.items(): + if len(fee_data) == 4: + fee, total_count, _, _ = fee_data + adapted_fees[m] = (fee, total_count) + else: + fee, count = fee_data + adapted_fees[m] = (fee, count) + adapted_members.append((name, tier, adapted_fees)) + + result = reconcile(adapted_members, sorted_months, transactions, exceptions) + record_step("reconcile") + + # Format month labels + month_labels = get_month_labels(sorted_months) + + # Filter to juniors for the main table + junior_names = sorted([name for name, tier, _ in adapted_members]) + + formatted_results = [] + for name in junior_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, "original_expected": 0, "paid": 0}) + expected = mdata["expected"] + paid = int(mdata["paid"]) + + status = "empty" + cell_text = "-" + amount_to_pay = 0 + + if expected == "?" or (isinstance(expected, int) and expected > 0): + if expected == "?": + status = "empty" + cell_text = "?" + elif paid >= expected: + status = "ok" + cell_text = "OK" + elif paid > 0: + status = "partial" + cell_text = f"{paid}/{expected}" + amount_to_pay = expected - paid + else: + status = "unpaid" + cell_text = f"UNPAID {expected}" + amount_to_pay = expected + 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] + }) + + row["balance"] = data["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"]) + unmatched = result["unmatched"] + import json + + record_step("process_data") + + return render_template( + "reconcile-juniors.html", + months=[month_labels[m] for m in sorted_months], + raw_months=sorted_months, + results=formatted_results, + member_data=json.dumps(result["members"]), + month_labels_json=json.dumps(month_labels), credits=credits, debts=debts, unmatched=unmatched, diff --git a/prompts/2026-03-09-junior-fees.md b/prompts/2026-03-09-junior-fees.md new file mode 100644 index 0000000..0777b5f --- /dev/null +++ b/prompts/2026-03-09-junior-fees.md @@ -0,0 +1,21 @@ +--- +i have new attendance sheet specifically for juniors over here: https://docs.google.com/spreadsheets/d/1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA/edit?gid=1213318614#gid=1213318614 + +I would like you to treat as junior anyone in that sheet +- who does not have tier: X +- is above line that says in column A: # Treneri + +i want to create similar page as we have in /fees, but for juniors - let's give it path /fees-juniors + +i want you to merge monthly attendance from both sheets in the document, but from the first sheet collect only attendance for members in tier: J + +Rules for monthly payments will be: +- attended only once - put ? mark as output +- 2 and more: 500 czk per month + +Also i want to have an option to merge multiple subsequent months to act as one for the payment, for now give me an option to specify it in some datastructure in the code, later we might read it from google sheet. Immediatelly prepare merge of january and february 2026 + +Also even though now the monthly payment is one value, i would like to have it configurable per month, for now prepare exception for september 2025 with 250 + +--- +cool, now i need an improvement: if the member name in both sheets is exactly the same i want to treat it as one person. Also i want you to keep attendances from "adult practice" (first sheet) and juniors practices (other one) in a datastructure separately, so that you can display primarily sum of those, but also each of them (in brackets after sum) so that we have better visibility \ No newline at end of file diff --git a/prompts/outcomes/2026-03-09-junior-fees.md b/prompts/outcomes/2026-03-09-junior-fees.md new file mode 100644 index 0000000..4624903 --- /dev/null +++ b/prompts/outcomes/2026-03-09-junior-fees.md @@ -0,0 +1,34 @@ +# Junior Fees Implementation Summary + +Based on the recent updates, we have introduced a dedicated system for tracking, displaying, and reconciling junior team attendances and payments. + +## 1. Implemented Features +- **Dual-Sheet Architecture:** The system now pulls attendance from two separate Google Sheet tabs—one for adult practices and another for junior practices. +- **New Views:** + - `/fees-juniors`: A dedicated dashboard showing junior attendances and calculated fees. + - `/reconcile-juniors`: A dedicated page matching Fio bank transactions against expected junior fees. +- **Granular Attendance Display:** The UI clearly separates and tallies adult (`A`) and junior (`J`) practice counts for each member (e.g., `4 (2A+2J)` or `2 (J)`). + +## 2. Membership Rules +- **Identification:** A member is processed as a junior if they appear in the *Junior Sheet*, UNLESS: + - They are listed below the separator line `# Treneri` (or `# Trenéři`). + - Their tier is explicitly marked as `X`. +- **Adult Sheet Fallback:** Members from the Adult Sheet whose tier is marked as `J` are also tracked as juniors. +- **Merging Identities:** If a member has the identical name in both the Adult Sheet and the Junior Sheet, their attendance records are merged together into a single profile. + +## 3. Fee Calculation Rules +The base fee calculation for juniors relies on the total combined attendance across both adult and junior practices for a given month: +- **0 attendances:** 0 CZK +- **Exactly 1 attendance:** `?` (Flags the month for manual review/decision) +- **2 or more attendances:** 500 CZK (Default base rate) + +## 4. Exceptions & Overrides +We have hardcoded specific timeline and pricing exceptions directly into the logic: + +- **Modified Monthly Rates:** + - **September 2025** (`2025-09`) is explicitly configured to have a fee of **250 CZK** for 2+ attendances instead of the default 500 CZK. + +- **Merged Billing Months:** + To handle holidays and off-seasons, certain subsequent months are merged and billed as a single period. Their attendances are summed up before the fee rule is applied. The current active merges are: + - **December 2025** is merged into **January 2026** + - **September 2025** is merged into **October 2025** diff --git a/scripts/attendance.py b/scripts/attendance.py index df0e5f9..ae66fa1 100644 --- a/scripts/attendance.py +++ b/scripts/attendance.py @@ -6,20 +6,36 @@ import urllib.request from datetime import datetime SHEET_ID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA" -EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv" +JUNIOR_SHEET_GID = "1213318614" +EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid=0" +JUNIOR_EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid={JUNIOR_SHEET_GID}" FEE_FULL = 750 # CZK, for 2+ practices in a month FEE_SINGLE = 200 # CZK, for exactly 1 practice in a month +JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices +JUNIOR_MONTHLY_RATE = { + "2025-09": 250 +} +MERGED_MONTHS = { + "2025-12": "2026-01", # keys are merged into values + "2025-09": "2025-10" +} + COL_NAME = 0 COL_TIER = 1 FIRST_DATE_COL = 3 -def fetch_csv() -> list[list[str]]: +def fetch_csv(url: str = EXPORT_URL) -> list[list[str]]: """Fetch the attendance Google Sheet as parsed CSV rows.""" - req = urllib.request.Request(EXPORT_URL) - with urllib.request.urlopen(req) as resp: + import ssl + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + req = urllib.request.Request(url) + with urllib.request.urlopen(req, context=ctx) as resp: text = resp.read().decode("utf-8") reader = csv.reader(io.StringIO(text)) return list(reader) @@ -28,23 +44,35 @@ def fetch_csv() -> list[list[str]]: def parse_dates(header_row: list[str]) -> list[tuple[int, datetime]]: """Return (column_index, date) pairs for all date columns.""" dates = [] + for i in range(FIRST_DATE_COL, len(header_row)): raw = header_row[i].strip() if not raw: continue + try: - dates.append((i, datetime.strptime(raw, "%m/%d/%Y"))) + # Try DD.MM.YYYY + dt = datetime.strptime(raw, "%d.%m.%Y") + dates.append((i, dt)) except ValueError: - continue + try: + # Fallback to MM/DD/YYYY + dt = datetime.strptime(raw, "%m/%d/%Y") + dates.append((i, dt)) + except ValueError: + pass + return dates def group_by_month(dates: list[tuple[int, datetime]]) -> dict[str, list[int]]: - """Group column indices by YYYY-MM.""" + """Group column indices by YYYY-MM, handling merged months.""" months: dict[str, list[int]] = {} for col, dt in dates: key = dt.strftime("%Y-%m") - months.setdefault(key, []).append(col) + # Apply merged month mapping if configured + target_key = MERGED_MONTHS.get(key, key) + months.setdefault(target_key, []).append(col) return months @@ -57,6 +85,15 @@ def calculate_fee(attendance_count: int) -> int: return FEE_FULL +def calculate_junior_fee(attendance_count: int, month_key: str) -> str | int: + """Apply junior fee rules: 0 → 0, 1 → '?', 2+ → Configured Rate (default 500).""" + if attendance_count == 0: + return 0 + if attendance_count == 1: + return "?" + return JUNIOR_MONTHLY_RATE.get(month_key, JUNIOR_FEE_DEFAULT) + + def get_members(rows: list[list[str]]) -> list[tuple[str, str, list[str]]]: """Parse member rows. Returns list of (name, tier, row). @@ -86,6 +123,38 @@ def get_members(rows: list[list[str]]) -> list[tuple[str, str, list[str]]]: return members +def get_junior_members_from_sheet(rows: list[list[str]]) -> list[tuple[str, str, list[str]]]: + """Parse junior member rows from the junior sheet. + + Stopped at row where first column contains '# Treneri'. + Returns list of (name, tier, row) for members where tier is not 'X'. + """ + members = [] + for row in rows[1:]: + if not row or len(row) <= COL_NAME: + continue + + first_col = row[COL_NAME].strip() + + # Terminator for rows to process in junior sheet + if "# treneri" in first_col.lower() or "# trenéři" in first_col.lower(): + break + + # Ignore comments + if first_col.startswith("#"): + continue + + if not first_col or first_col.lower() in ("jméno", "name", "jmeno"): + continue + + tier = row[COL_TIER].strip().upper() if len(row) > COL_TIER else "" + if tier == "X": + continue + + members.append((first_col, tier, row)) + return members + + def get_members_with_fees() -> tuple[list[tuple[str, str, dict[str, int]]], list[str]]: """Fetch attendance data and compute fees. @@ -122,3 +191,87 @@ def get_members_with_fees() -> tuple[list[tuple[str, str, dict[str, int]]], list members.append((name, tier, month_fees)) return members, sorted_months + + +def get_junior_members_with_fees() -> tuple[list[tuple[str, str, dict[str, tuple[str | int, int, int, int]]]], list[str]]: + """Fetch attendance data from both sheets and compute junior fees. + + Merges members by exact name match. + + Returns: + (members, sorted_months) where members is a list of + (name, tier, {month_key: (fee, total_count, adult_count, junior_count)}). + """ + main_rows = fetch_csv(EXPORT_URL) + junior_rows = fetch_csv(JUNIOR_EXPORT_URL) + + if len(main_rows) < 2 or len(junior_rows) < 2: + return [], [] + + 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) + + # Collect all unique sorted months + all_months = set(main_months.keys()).union(set(junior_months.keys())) + sorted_months = sorted(list(all_months)) + + from typing import Any + merged_members: dict[str, Any] = {} + + # Process Junior Tier from Main Sheet (Adult Practices) + main_members_raw = get_members(main_rows) + for name, tier, row in main_members_raw: + if tier != "J": + continue + + if name not in merged_members: + merged_members[name] = {"tier": tier, "months": {}} + + for month_key in sorted_months: + if month_key not in merged_members[name]["months"]: + merged_members[name]["months"][month_key] = {"adult": 0, "junior": 0} + + cols = main_months.get(month_key, []) + adult_count = sum( + 1 + for c in cols + if c < len(row) and row[c].strip().upper() == "TRUE" + ) + merged_members[name]["months"][month_key]["adult"] += adult_count + + # Process Junior Sheet (Junior Practices) + junior_members_raw = get_junior_members_from_sheet(junior_rows) + for name, tier, row in junior_members_raw: + if name not in merged_members: + merged_members[name] = {"tier": tier, "months": {}} + + for month_key in sorted_months: + if month_key not in merged_members[name]["months"]: + merged_members[name]["months"][month_key] = {"adult": 0, "junior": 0} + + cols = junior_months.get(month_key, []) + junior_count = sum( + 1 + for c in cols + if c < len(row) and row[c].strip().upper() == "TRUE" + ) + merged_members[name]["months"][month_key]["junior"] += junior_count + + # Compile the final result format + members = [] + for name, data in merged_members.items(): + month_fees = {} + for month_key in sorted_months: + adult_count = data["months"].get(month_key, {}).get("adult", 0) + junior_count = data["months"].get(month_key, {}).get("junior", 0) + total_count = adult_count + junior_count + + fee = calculate_junior_fee(total_count, month_key) + month_fees[month_key] = (fee, total_count, adult_count, junior_count) + + members.append((name, data["tier"], month_fees)) + + return members, sorted_months diff --git a/scripts/match_payments.py b/scripts/match_payments.py index 9c1563e..3a7e3ab 100644 --- a/scripts/match_payments.py +++ b/scripts/match_payments.py @@ -389,7 +389,7 @@ def reconcile( final_balances: dict[str, int] = {} for name in member_names: window_balance = sum( - int(mdata["paid"]) - mdata["expected"] + int(mdata["paid"]) - (mdata["expected"] if isinstance(mdata["expected"], int) else 0) for mdata in ledger[name].values() ) final_balances[name] = window_balance + credits.get(name, 0) diff --git a/templates/fees-juniors.html b/templates/fees-juniors.html new file mode 100644 index 0000000..80840ec --- /dev/null +++ b/templates/fees-juniors.html @@ -0,0 +1,216 @@ + + + + + + + FUJ Junior Fees Dashboard + + + + + + +

FUJ Junior Fees Dashboard

+ +
+ Calculated monthly fees based on attendance markers.
+ Source: Junior Attendance Sheet | + Payments Ledger +
+ +
+ + + + + {% for m in months %} + + {% endfor %} + + + + {% for row in results %} + + + {% for mdata in row.months %} + + {% endfor %} + + {% endfor %} + + + + + {% for t in totals %} + + {% endfor %} + + +
Member{{ m }}
{{ row.name }} + {{ mdata.cell }} +
TOTAL{{ t }}
+
+ {% set rt = get_render_time() %} + + + + diff --git a/templates/fees.html b/templates/fees.html index 8e9bd8d..a916e81 100644 --- a/templates/fees.html +++ b/templates/fees.html @@ -171,7 +171,9 @@ diff --git a/templates/payments.html b/templates/payments.html index a3c55db..0bc8e25 100644 --- a/templates/payments.html +++ b/templates/payments.html @@ -160,7 +160,9 @@ diff --git a/templates/reconcile-juniors.html b/templates/reconcile-juniors.html new file mode 100644 index 0000000..8faddf4 --- /dev/null +++ b/templates/reconcile-juniors.html @@ -0,0 +1,808 @@ + + + + + + + FUJ Junior Payment Reconciliation + + + + + + +

Junior Payment Reconciliation

+ +
+ Balances calculated by matching Google Sheet payments against junior attendance fees.
+ Source: Attendance Sheet | + Payments Ledger +
+ +
+ search member: + +
+ +
+ + + + + {% for m in months %} + + {% endfor %} + + + + + {% for row in results %} + + + {% for cell in row.months %} + + {% endfor %} + + + {% endfor %} + +
Member{{ m }}Balance
+ {{ row.name }} + [i] + + {{ cell.text }} + {% if cell.status == 'unpaid' or cell.status == 'partial' %} + + {% endif %} + + {{ "%+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 %} + + + + +
+ +
+ + {% set rt = get_render_time() %} + + + + + + +``` \ No newline at end of file diff --git a/templates/reconcile.html b/templates/reconcile.html index b1e0193..c6487d5 100644 --- a/templates/reconcile.html +++ b/templates/reconcile.html @@ -424,7 +424,9 @@ @@ -595,6 +597,7 @@