6 Commits

Author SHA1 Message Date
c8c145486f Merge pull request 'calculate-finance-for-juniors' (#1) from calculate-finance-for-juniors into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 12s
Reviewed-on: #1
2026-03-10 22:12:32 +00:00
Jan Novak
27ad66ff79 style: Rename navigation links to distinguish Adult and Junior sections
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Build and Push / build (push) Successful in 7s
Co-authored-by: Antigravity <antigravity@google.com>
2026-03-09 23:18:12 +01:00
Jan Novak
1257f0d644 Feat: separate merged months configs and add 'other' payments to member popups
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Build and Push / build (push) Successful in 8s
2026-03-09 23:07:22 +01:00
Jan Novak
75a36eb49b feat: Implement junior fees dashboard and reconciliation
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
Build and Push / build (push) Successful in 9s
- Add dual-sheet architecture to pull attendance from both adult and junior spreadsheets.
- Introduce parsing rules to isolate juniors (e.g. above '# Treneri', tier 'J').
- Add new endpoints `/fees-juniors` and `/reconcile-juniors` to track junior attendances and match bank payments.
- Display granular attendance components showing adult vs. junior practices.
- Add fee rule configuration supporting custom pricing exceptions for specific months (e.g. Sep 2025) and merging billing periods.
- Add `make sync-2025` target to the Makefile for convenience.
- Document junior fees implementation logic and rules in prompts/outcomes.

Co-authored-by: Antigravity <antigravity@google.com>
2026-03-09 17:35:26 +01:00
Jan Novak
f40015a2ef fix: mark docs target as .PHONY in Makefile
Some checks failed
Deploy to K8s / deploy (push) Failing after 9s
2026-03-03 14:24:28 +01:00
Jan Novak
5bdc7a4566 feat: add keyboard navigation to member details and fix attendance count
Some checks failed
Deploy to K8s / deploy (push) Failing after 7s
Build and Push / build (push) Successful in 9s
- Users can now navigate between members in the details popup using Up/Down arrows.
- Fixed 0 attendance count in member popup by preserving count in reconciliation.
- Updated uv.lock following dependency changes.

Co-authored-by: Antigravity <antigravity@google.com>
2026-03-03 11:04:50 +01:00
13 changed files with 1755 additions and 41 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: help fees match web image run sync sync-2026 test test-v
.PHONY: help fees match web image run sync sync-2026 test test-v docs
export PYTHONPATH := scripts:$(PYTHONPATH)
VENV := .venv
@@ -19,12 +19,14 @@ 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"
@echo " make venv - Sync virtual environment with pyproject.toml"
@echo " make test - Run web application infrastructure tests"
@echo " make test-v - Run tests with verbose output"
@echo " make docs - Serve documentation in a browser"
venv:
uv sync
@@ -42,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
@@ -61,3 +66,8 @@ test: $(PYTHON) ## Run web application tests
test-v: $(PYTHON) ## Run tests with verbose output
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover -v tests
docs: ## Serve documentation locally
@echo "Starting documentation server at http://localhost:8000"
@echo "Press Ctrl+C to stop."
$(PYTHON) -m http.server 8000 --directory docs

228
app.py
View File

@@ -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, 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, 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])
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, ADULT_MERGED_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, JUNIOR_MERGED_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, ADULT_MERGED_MONTHS)
# Filter to adults for the main table
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
@@ -143,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"]
@@ -161,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}"
@@ -176,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
@@ -194,6 +290,116 @@ 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, JUNIOR_MERGED_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"], "unpaid_periods": ""}
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 == "?" 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
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}"
row["months"].append({
"text": cell_text,
"status": status,
"amount": amount_to_pay,
"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)
# 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,

View File

@@ -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

View File

@@ -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**

View File

@@ -6,20 +6,41 @@ 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
}
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"
}
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 +49,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."""
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")
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 +90,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 +128,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.
@@ -103,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)
@@ -122,3 +196,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_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()))
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

View File

@@ -286,13 +286,15 @@ def reconcile(
"""
member_names = [name for name, _, _ in members]
member_tiers = {name: tier for name, tier, _ in members}
member_fees = {name: {m: fee for m, (fee, _) in fees.items()} for name, _, fees in members}
member_fees = {name: fees for name, _, fees in members}
# 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
@@ -389,7 +407,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)
@@ -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

216
templates/fees-juniors.html Normal file
View File

@@ -0,0 +1,216 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FUJ Junior Fees Dashboard</title>
<style>
body {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background-color: #0c0c0c;
color: #cccccc;
padding: 10px;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
font-size: 11px;
line-height: 1.2;
}
h1 {
color: #00ff00;
font-family: inherit;
margin-top: 10px;
margin-bottom: 20px;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 14px;
}
.table-container {
background-color: transparent;
border: 1px solid #333;
box-shadow: none;
overflow-x: auto;
width: 100%;
max-width: 1200px;
}
table {
border-collapse: collapse;
width: 100%;
table-layout: auto;
}
th,
td {
padding: 2px 8px;
text-align: right;
border-bottom: 1px dashed #222;
white-space: nowrap;
}
th:first-child,
td:first-child {
text-align: left;
}
th {
background-color: transparent;
color: #888888;
font-weight: normal;
border-bottom: 1px solid #555;
text-transform: lowercase;
}
tr:hover {
background-color: #1a1a1a;
}
.total {
font-weight: bold;
background-color: transparent;
color: #00ff00;
border-top: 1px solid #555;
}
.total:hover {
background-color: transparent;
}
.cell-empty {
color: #444444;
}
.cell-paid {
color: #aaaaaa;
}
.cell-overridden {
color: #ffa500 !important;
}
.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;
}
.footer {
margin-top: 50px;
margin-bottom: 20px;
color: #333;
font-size: 9px;
text-align: center;
width: 100%;
cursor: pointer;
user-select: none;
}
.perf-breakdown {
display: none;
margin-top: 5px;
color: #222;
}
</style>
</head>
<body>
<div class="nav">
<a href="/fees">[Adult - Attendance/Fees]</a>
<a href="/fees-juniors" class="active">[Junior Attendance/Fees]</a>
<a href="/reconcile">[Adult Payment Reconciliation]</a>
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
<a href="/payments">[Payments Ledger]</a>
</div>
<h1>FUJ Junior Fees Dashboard</h1>
<div class="description">
Calculated monthly fees based on attendance markers.<br>
Source: <a href="{{ attendance_url }}" target="_blank">Junior Attendance Sheet</a> |
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Member</th>
{% for m in months %}
<th>{{ m }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in results %}
<tr>
<td>{{ row.name }}</td>
{% for mdata in row.months %}
<td
class="{% if mdata.cell == '-' %}cell-empty{% elif mdata.overridden %}cell-overridden{% else %}cell-paid{% endif %}">
{{ mdata.cell }}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total">
<td>TOTAL</td>
{% for t in totals %}
<td>{{ t }}</td>
{% endfor %}
</tr>
</tfoot>
</table>
</div>
{% set rt = get_render_time() %}
<div class="footer"
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
render time: {{ rt.total }}s
<div id="perf-details" class="perf-breakdown">
{{ rt.breakdown }}
</div>
</div>
</body>
</html>

View File

@@ -170,8 +170,10 @@
<body>
<div class="nav">
<a href="/fees" class="active">[Attendance/Fees]</a>
<a href="/reconcile">[Payment Reconciliation]</a>
<a href="/fees" class="active">[Adult - Attendance/Fees]</a>
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
<a href="/reconcile">[Adult Payment Reconciliation]</a>
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
<a href="/payments">[Payments Ledger]</a>
</div>

View File

@@ -159,8 +159,10 @@
<body>
<div class="nav">
<a href="/fees">[Attendance/Fees]</a>
<a href="/reconcile">[Payment Reconciliation]</a>
<a href="/fees">[Adult - Attendance/Fees]</a>
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
<a href="/reconcile">[Adult Payment Reconciliation]</a>
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
<a href="/payments" class="active">[Payments Ledger]</a>
</div>

View File

@@ -0,0 +1,843 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FUJ Junior Payment Reconciliation</title>
<style>
body {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background-color: #0c0c0c;
color: #cccccc;
padding: 10px;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
font-size: 11px;
line-height: 1.2;
}
h1 {
color: #00ff00;
font-family: inherit;
margin-top: 10px;
margin-bottom: 20px;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 14px;
}
h2 {
color: #00ff00;
font-size: 12px;
margin-top: 30px;
margin-bottom: 10px;
text-transform: uppercase;
width: 100%;
max-width: 1200px;
border-bottom: 1px solid #333;
padding-bottom: 5px;
}
.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;
}
.table-container {
background-color: transparent;
border: 1px solid #333;
box-shadow: none;
overflow-x: auto;
width: 100%;
max-width: 1200px;
margin-bottom: 30px;
}
table {
border-collapse: collapse;
width: 100%;
table-layout: auto;
}
th,
td {
padding: 2px 8px;
text-align: right;
border-bottom: 1px dashed #222;
white-space: nowrap;
}
th:first-child,
td:first-child {
text-align: left;
}
th {
background-color: transparent;
color: #888888;
font-weight: normal;
border-bottom: 1px solid #555;
text-transform: lowercase;
}
tr:hover {
background-color: #1a1a1a;
}
.balance-pos {
color: #00ff00;
}
.balance-neg {
color: #ff3333;
}
.cell-ok {
color: #00ff00;
}
.cell-unpaid {
color: #ff3333;
background-color: rgba(255, 51, 51, 0.05);
position: relative;
}
.pay-btn {
display: none;
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
background: #ff3333;
color: white;
border: none;
border-radius: 3px;
padding: 2px 6px;
font-size: 10px;
cursor: pointer;
font-weight: bold;
}
.member-row:hover .pay-btn {
display: inline-block;
}
.cell-empty {
color: #444444;
}
.list-container {
width: 100%;
max-width: 1200px;
color: #888;
margin-bottom: 40px;
}
.list-item {
display: flex;
justify-content: flex-start;
gap: 20px;
padding: 1px 0;
border-bottom: 1px dashed #222;
}
.list-item-name {
color: #ccc;
min-width: 200px;
}
.list-item-val {
color: #00ff00;
}
.unmatched-row {
font-family: inherit;
display: grid;
grid-template-columns: 100px 100px 200px 1fr;
gap: 15px;
color: #888;
padding: 2px 0;
border-bottom: 1px dashed #222;
}
.unmatched-header {
color: #555;
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;
}
.footer {
margin-top: 50px;
margin-bottom: 20px;
color: #333;
font-size: 9px;
text-align: center;
width: 100%;
cursor: pointer;
user-select: none;
}
.perf-breakdown {
display: none;
margin-top: 5px;
color: #222;
}
/* QR Modal styles */
#qrModal .modal-content {
max-width: 400px;
text-align: center;
}
.qr-image {
background: white;
padding: 10px;
border-radius: 5px;
margin: 20px 0;
display: inline-block;
}
.qr-image img {
display: block;
width: 250px;
height: 250px;
}
.qr-details {
text-align: left;
margin-top: 15px;
font-size: 14px;
color: #ccc;
}
.qr-details div {
margin-bottom: 5px;
}
.qr-details span {
color: #00ff00;
font-family: monospace;
}
</style>
</head>
<body>
<div class="nav">
<a href="/fees">[Adult - Attendance/Fees]</a>
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
<a href="/reconcile">[Adult Payment Reconciliation]</a>
<a href="/reconcile-juniors" class="active">[Junior Payment Reconciliation]</a>
<a href="/payments">[Payments Ledger]</a>
</div>
<h1>Junior Payment Reconciliation</h1>
<div class="description">
Balances calculated by matching Google Sheet payments against junior attendance fees.<br>
Source: <a href="{{ attendance_url }}" target="_blank">Attendance Sheet</a> |
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
</div>
<div class="filter-container">
<span class="filter-label">search member:</span>
<input type="text" id="nameFilter" class="filter-input" placeholder="..." autocomplete="off">
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Member</th>
{% for m in months %}
<th>{{ m }}</th>
{% endfor %}
<th>Balance</th>
</tr>
</thead>
<tbody id="reconcileBody">
{% for row in results %}
<tr class="member-row">
<td class="member-name">
{{ row.name }}
<span class="info-icon" onclick="showMemberDetails('{{ row.name|e }}')">[i]</span>
</td>
{% for cell in row.months %}
<td
class="{% if cell.status == 'empty' %}cell-empty{% elif cell.status == 'unpaid' or cell.status == 'partial' %}cell-unpaid{% elif cell.status == 'ok' %}cell-ok{% endif %}">
{{ cell.text }}
{% if cell.status == 'unpaid' or cell.status == 'partial' %}
<button class="pay-btn"
onclick="showPayQR('{{ row.name|e }}', {{ cell.amount }}, '{{ cell.month|e }}')">Pay</button>
{% endif %}
</td>
{% endfor %}
<td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}" style="position: relative;">
{{ "%+d"|format(row.balance) if row.balance != 0 else "0" }}
{% if row.balance < 0 %}
<button class="pay-btn"
onclick="showPayQR('{{ row.name|e }}', {{ -row.balance }}, '{{ row.unpaid_periods|e }}')">Pay All</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if credits %}
<h2>Credits (Advance Payments / Surplus)</h2>
<div class="list-container">
{% for item in credits %}
<div class="list-item">
<span class="list-item-name">{{ item.name }}</span>
<span class="list-item-val">{{ item.amount }} CZK</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if debts %}
<h2>Debts (Missing Payments)</h2>
<div class="list-container">
{% for item in debts %}
<div class="list-item">
<span class="list-item-name">{{ item.name }}</span>
<span class="list-item-val" style="color: #ff3333;">{{ item.amount }} CZK</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if unmatched %}
<h2>Unmatched Transactions</h2>
<div class="list-container">
<div class="unmatched-row unmatched-header">
<span>Date</span>
<span>Amount</span>
<span>Sender</span>
<span>Message</span>
</div>
{% for tx in unmatched %}
<div class="unmatched-row">
<span>{{ tx.date }}</span>
<span>{{ tx.amount }}</span>
<span>{{ tx.sender }}</span>
<span>{{ tx.message }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<!-- QR Code Modal -->
<div id="qrModal" class="modal"
style="display:none; 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;">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="qrTitle">Payment for ...</div>
<div class="close-btn" onclick="closeModal('qrModal')">[close]</div>
</div>
<div class="qr-image">
<img id="qrImg" src="" alt="Payment QR Code">
</div>
<div class="qr-details">
<div>Account: <span id="qrAccount"></span></div>
<div>Amount: <span id="qrAmount"></span> CZK</div>
<div>Message: <span id="qrMessage"></span></div>
</div>
</div>
</div>
<div id="memberModal">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="modalMemberName">Member Name</div>
<div class="close-btn" onclick="closeModal()">[close]</div>
</div>
<div class="modal-section">
<div class="modal-section-title">Status Summary</div>
<div id="modalTier" style="margin-bottom: 10px; color: #888;">Tier: -</div>
<table class="modal-table">
<thead>
<tr>
<th>Month</th>
<th style="text-align: center;">Att.</th>
<th style="text-align: center;">Expected</th>
<th style="text-align: center;">Paid</th>
<th style="text-align: right;">Status</th>
</tr>
</thead>
<tbody id="modalStatusBody">
<!-- Filled by JS -->
</tbody>
</table>
</div>
<div class="modal-section" id="modalExceptionSection" style="display: none;">
<div class="modal-section-title">Fee Exceptions</div>
<div id="modalExceptionList" class="tx-list">
<!-- Filled by JS -->
</div>
</div>
<div class="modal-section" id="modalOtherSection" style="display: none;">
<div class="modal-section-title">Other Transactions</div>
<div id="modalOtherList" class="tx-list">
<!-- Filled by JS -->
</div>
</div>
<div class="modal-section">
<div class="modal-section-title">Payment History</div>
<div id="modalTxList" class="tx-list">
<!-- Filled by JS -->
</div>
</div>
</div>
</div>
{% set rt = get_render_time() %}
<div class="footer"
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
render time: {{ rt.total }}s
<div id="perf-details" class="perf-breakdown">
{{ rt.breakdown }}
</div>
</div>
<script>
const memberData = {{ member_data| safe }};
const sortedMonths = {{ raw_months| tojson }};
const monthLabels = {{ month_labels_json| safe }};
let currentMemberName = null;
function showMemberDetails(name) {
currentMemberName = name;
const data = memberData[name];
if (!data) return;
document.getElementById('modalMemberName').textContent = name;
document.getElementById('modalTier').textContent = 'Tier: ' + data.tier;
const statusBody = document.getElementById('modalStatusBody');
statusBody.innerHTML = '';
// Collect all transactions for listing
const allTransactions = [];
// We need to iterate over months in reverse to show newest first
const monthKeys = Object.keys(data.months).sort().reverse();
monthKeys.forEach(m => {
const mdata = data.months[m];
const expected = mdata.expected || 0;
const paid = mdata.paid || 0;
const attendance = mdata.attendance_count || 0;
const originalExpected = mdata.original_expected;
let status = '-';
let statusClass = '';
if (expected > 0 || paid > 0) {
if (paid >= expected && expected > 0) { status = 'OK'; statusClass = 'cell-ok'; }
else if (paid > 0) { status = paid + '/' + expected; }
else { status = 'UNPAID ' + expected; statusClass = 'cell-unpaid'; }
}
const expectedCell = mdata.exception
? `<span style="color: #ffaa00;" title="Overridden from ${originalExpected}">${expected}*</span>`
: expected;
const displayMonth = monthLabels[m] || m;
const row = document.createElement('tr');
row.innerHTML = `
<td style="color: #888;">${displayMonth}</td>
<td style="text-align: center; color: #ccc;">${attendance}</td>
<td style="text-align: center; color: #ccc;">${expectedCell}</td>
<td style="text-align: center; color: #ccc;">${paid}</td>
<td style="text-align: right;" class="${statusClass}">${status}</td>
`;
statusBody.appendChild(row);
if (mdata.transactions) {
mdata.transactions.forEach(tx => {
allTransactions.push({ month: m, ...tx });
});
}
});
const exList = document.getElementById('modalExceptionList');
const exSection = document.getElementById('modalExceptionSection');
exList.innerHTML = '';
const exceptions = [];
monthKeys.forEach(m => {
if (data.months[m].exception) {
exceptions.push({ month: m, ...data.months[m].exception });
}
});
if (exceptions.length > 0) {
exSection.style.display = 'block';
exceptions.forEach(ex => {
const displayMonth = monthLabels[ex.month] || ex.month;
const item = document.createElement('div');
item.className = 'tx-item'; // Reuse style
item.innerHTML = `
<div class="tx-meta">${displayMonth}</div>
<div class="tx-main">
<span class="tx-amount" style="color: #ffaa00;">${ex.amount} CZK</span>
</div>
<div class="tx-msg">${ex.note || 'No details provided.'}</div>
`;
exList.appendChild(item);
});
} else {
exSection.style.display = 'none';
}
const otherList = document.getElementById('modalOtherList');
const otherSection = document.getElementById('modalOtherSection');
otherList.innerHTML = '';
if (data.other_transactions && data.other_transactions.length > 0) {
otherSection.style.display = 'block';
data.other_transactions.forEach(tx => {
const displayPurpose = tx.purpose || 'Other';
const item = document.createElement('div');
item.className = 'tx-item';
item.innerHTML = `
<div class="tx-meta">${tx.date} | ${displayPurpose}</div>
<div class="tx-main">
<span class="tx-amount" style="color: #66ccff;">${tx.amount} CZK</span>
<span class="tx-sender">${tx.sender}</span>
</div>
<div class="tx-msg">${tx.message || ''}</div>
`;
otherList.appendChild(item);
});
} else {
otherSection.style.display = 'none';
}
const txList = document.getElementById('modalTxList');
txList.innerHTML = '';
if (allTransactions.length === 0) {
txList.innerHTML = '<div style="color: #444; font-style: italic; padding: 10px 0;">No transactions matched to this member.</div>';
} else {
allTransactions.sort((a, b) => b.date.localeCompare(a.date)).forEach(tx => {
const displayMonth = monthLabels[tx.month] || tx.month;
const item = document.createElement('div');
item.className = 'tx-item';
item.innerHTML = `
<div class="tx-meta">${tx.date} | matched to ${displayMonth}</div>
<div class="tx-main">
<span class="tx-amount">${tx.amount} CZK</span>
<span class="tx-sender">${tx.sender}</span>
</div>
<div class="tx-msg">${tx.message || ''}</div>
`;
txList.appendChild(item);
});
}
document.getElementById('memberModal').classList.add('active');
}
function closeModal(id) {
if (id) {
document.getElementById(id).style.display = 'none';
if (id === 'qrModal') {
document.getElementById(id).style.display = 'none';
}
} else {
document.getElementById('memberModal').classList.remove('active');
}
}
// Existing filter script
document.getElementById('nameFilter').addEventListener('input', function (e) {
const filterValue = e.target.value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
const rows = document.querySelectorAll('.member-row');
rows.forEach(row => {
const nameNode = row.querySelector('.member-name');
const name = nameNode.childNodes[0].textContent.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
if (name.includes(filterValue)) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
});
// Close on Esc and Navigate with Arrows
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
closeModal();
closeModal('qrModal');
}
const modal = document.getElementById('memberModal');
if (modal.classList.contains('active')) {
if (e.key === 'ArrowUp') {
e.preventDefault();
navigateMember(-1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
navigateMember(1);
}
}
});
function navigateMember(direction) {
const rows = Array.from(document.querySelectorAll('.member-row'));
const visibleRows = rows.filter(row => row.style.display !== 'none');
let currentIndex = visibleRows.findIndex(row => {
const nameNode = row.querySelector('.member-name');
const name = nameNode.childNodes[0].textContent.trim();
return name === currentMemberName;
});
if (currentIndex === -1) return;
let nextIndex = currentIndex + direction;
if (nextIndex >= 0 && nextIndex < visibleRows.length) {
const nextRow = visibleRows[nextIndex];
const nextName = nextRow.querySelector('.member-name').childNodes[0].textContent.trim();
showMemberDetails(nextName);
}
}
function showPayQR(name, amount, month) {
const account = "{{ bank_account }}";
const message = `${name} / ${month}`;
const qrTitle = document.getElementById('qrTitle');
const qrImg = document.getElementById('qrImg');
const qrAccount = document.getElementById('qrAccount');
const qrAmount = document.getElementById('qrAmount');
const qrMessage = document.getElementById('qrMessage');
qrTitle.innerText = `Payment for ${month}`;
qrAccount.innerText = account;
qrAmount.innerText = amount;
qrMessage.innerText = message;
const encodedMessage = encodeURIComponent(message);
const qrUrl = `/qr?account=${encodeURIComponent(account)}&amount=${amount}&message=${encodedMessage}`;
qrImg.src = qrUrl;
document.getElementById('qrModal').style.display = 'block';
}
// Close modal when clicking outside
window.onclick = function (event) {
if (event.target.className === 'modal') {
event.target.style.display = 'none';
}
}
</script>
</body>
</html>
```

View File

@@ -423,8 +423,10 @@
<body>
<div class="nav">
<a href="/fees">[Attendance/Fees]</a>
<a href="/reconcile" class="active">[Payment Reconciliation]</a>
<a href="/fees">[Adult - Attendance/Fees]</a>
<a href="/fees-juniors">[Junior Attendance/Fees]</a>
<a href="/reconcile" class="active">[Adult Payment Reconciliation]</a>
<a href="/reconcile-juniors">[Junior Payment Reconciliation]</a>
<a href="/payments">[Payments Ledger]</a>
</div>
@@ -469,8 +471,12 @@
{% endif %}
</td>
{% endfor %}
<td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}">
<td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}" style="position: relative;">
{{ "%+d"|format(row.balance) if row.balance != 0 else "0" }}
{% if row.balance < 0 %}
<button class="pay-btn"
onclick="showPayQR('{{ row.name|e }}', {{ -row.balance }}, '{{ row.unpaid_periods|e }}')">Pay All</button>
{% endif %}
</td>
</tr>
{% endfor %}
@@ -574,6 +580,13 @@
</div>
</div>
<div class="modal-section" id="modalOtherSection" style="display: none;">
<div class="modal-section-title">Other Transactions</div>
<div id="modalOtherList" class="tx-list">
<!-- Filled by JS -->
</div>
</div>
<div class="modal-section">
<div class="modal-section-title">Payment History</div>
<div id="modalTxList" class="tx-list">
@@ -595,8 +608,11 @@
<script>
const memberData = {{ member_data| safe }};
const sortedMonths = {{ raw_months| tojson }};
const monthLabels = {{ month_labels_json| safe }};
let currentMemberName = null;
function showMemberDetails(name) {
currentMemberName = name;
const data = memberData[name];
if (!data) return;
@@ -631,9 +647,10 @@
? `<span style="color: #ffaa00;" title="Overridden from ${originalExpected}">${expected}*</span>`
: expected;
const displayMonth = monthLabels[m] || m;
const row = document.createElement('tr');
row.innerHTML = `
<td style="color: #888;">${m}</td>
<td style="color: #888;">${displayMonth}</td>
<td style="text-align: center; color: #ccc;">${attendance}</td>
<td style="text-align: center; color: #ccc;">${expectedCell}</td>
<td style="text-align: center; color: #ccc;">${paid}</td>
@@ -662,10 +679,11 @@
if (exceptions.length > 0) {
exSection.style.display = 'block';
exceptions.forEach(ex => {
const displayMonth = monthLabels[ex.month] || ex.month;
const item = document.createElement('div');
item.className = 'tx-item'; // Reuse style
item.innerHTML = `
<div class="tx-meta">${ex.month}</div>
<div class="tx-meta">${displayMonth}</div>
<div class="tx-main">
<span class="tx-amount" style="color: #ffaa00;">${ex.amount} CZK</span>
</div>
@@ -677,6 +695,30 @@
exSection.style.display = 'none';
}
const otherList = document.getElementById('modalOtherList');
const otherSection = document.getElementById('modalOtherSection');
otherList.innerHTML = '';
if (data.other_transactions && data.other_transactions.length > 0) {
otherSection.style.display = 'block';
data.other_transactions.forEach(tx => {
const displayPurpose = tx.purpose || 'Other';
const item = document.createElement('div');
item.className = 'tx-item';
item.innerHTML = `
<div class="tx-meta">${tx.date} | ${displayPurpose}</div>
<div class="tx-main">
<span class="tx-amount" style="color: #66ccff;">${tx.amount} CZK</span>
<span class="tx-sender">${tx.sender}</span>
</div>
<div class="tx-msg">${tx.message || ''}</div>
`;
otherList.appendChild(item);
});
} else {
otherSection.style.display = 'none';
}
const txList = document.getElementById('modalTxList');
txList.innerHTML = '';
@@ -684,10 +726,11 @@
txList.innerHTML = '<div style="color: #444; font-style: italic; padding: 10px 0;">No transactions matched to this member.</div>';
} else {
allTransactions.sort((a, b) => b.date.localeCompare(a.date)).forEach(tx => {
const displayMonth = monthLabels[tx.month] || tx.month;
const item = document.createElement('div');
item.className = 'tx-item';
item.innerHTML = `
<div class="tx-meta">${tx.date} | matched to ${tx.month}</div>
<div class="tx-meta">${tx.date} | matched to ${displayMonth}</div>
<div class="tx-main">
<span class="tx-amount">${tx.amount} CZK</span>
<span class="tx-sender">${tx.sender}</span>
@@ -728,10 +771,44 @@
});
});
// Close on Esc
// Close on Esc and Navigate with Arrows
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeModal();
if (e.key === 'Escape') {
closeModal();
closeModal('qrModal');
}
const modal = document.getElementById('memberModal');
if (modal.classList.contains('active')) {
if (e.key === 'ArrowUp') {
e.preventDefault();
navigateMember(-1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
navigateMember(1);
}
}
});
function navigateMember(direction) {
const rows = Array.from(document.querySelectorAll('.member-row'));
const visibleRows = rows.filter(row => row.style.display !== 'none');
let currentIndex = visibleRows.findIndex(row => {
const nameNode = row.querySelector('.member-name');
const name = nameNode.childNodes[0].textContent.trim();
return name === currentMemberName;
});
if (currentIndex === -1) return;
let nextIndex = currentIndex + direction;
if (nextIndex >= 0 && nextIndex < visibleRows.length) {
const nextRow = visibleRows[nextIndex];
const nextName = nextRow.querySelector('.member-name').childNodes[0].textContent.trim();
showMemberDetails(nextName);
}
}
function showPayQR(name, amount, month) {
const account = "{{ bank_account }}";
const message = `${name} / ${month}`;

View File

@@ -29,6 +29,25 @@ class TestWebApp(unittest.TestCase):
self.assertIn(b'FUJ Fees Dashboard', response.data)
self.assertIn(b'Test Member', response.data)
@patch('app.get_junior_members_with_fees')
def test_fees_juniors_route(self, mock_get_junior_members):
"""Test that /fees-juniors returns 200 and renders the junior dashboard"""
# Mock attendance data: one with string symbol '?', one with integer
mock_get_junior_members.return_value = (
[
('Test Junior 1', 'J', {'2026-01': ('?', 1, 0, 1)}),
('Test Junior 2', 'J', {'2026-01': (500, 4, 1, 3)})
],
['2026-01']
)
response = self.client.get('/fees-juniors')
self.assertEqual(response.status_code, 200)
self.assertIn(b'FUJ Junior Fees Dashboard', response.data)
self.assertIn(b'Test Junior 1', response.data)
self.assertIn(b'? / 1 (J)', response.data)
self.assertIn(b'500 CZK / 4 (1A+3J)', response.data)
@patch('app.fetch_sheet_data')
@patch('app.get_members_with_fees')
def test_reconcile_route(self, mock_get_members, mock_fetch_sheet):
@@ -67,12 +86,42 @@ class TestWebApp(unittest.TestCase):
'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)
@patch('app.fetch_sheet_data')
@patch('app.fetch_exceptions')
@patch('app.get_junior_members_with_fees')
def test_reconcile_juniors_route(self, mock_get_junior, mock_exceptions, mock_transactions):
"""Test that /reconcile-juniors correctly computes balances for juniors."""
mock_get_junior.return_value = (
[
('Junior One', 'J', {'2026-01': (500, 4, 2, 2)}),
('Junior Two', 'X', {'2026-01': ('?', 1, 0, 1)})
],
['2026-01']
)
mock_exceptions.return_value = {}
mock_transactions.return_value = [{
'date': '2026-01-15',
'amount': 500,
'person': 'Junior One',
'purpose': '2026-01',
'message': '',
'sender': 'Parent',
'inferred_amount': 500
}]
response = self.client.get('/reconcile-juniors')
self.assertEqual(response.status_code, 200)
self.assertIn(b'Junior Payment Reconciliation', response.data)
self.assertIn(b'Junior One', response.data)
self.assertIn(b'Junior Two', response.data)
self.assertIn(b'OK', response.data)
self.assertIn(b'?', response.data)
if __name__ == '__main__':
unittest.main()

79
uv.lock generated
View File

@@ -199,13 +199,14 @@ wheels = [
[[package]]
name = "fuj-management"
version = "0.6"
version = "0.10"
source = { virtual = "." }
dependencies = [
{ name = "flask" },
{ name = "google-api-python-client" },
{ name = "google-auth-httplib2" },
{ name = "google-auth-oauthlib" },
{ name = "qrcode", extra = ["pil"] },
]
[package.metadata]
@@ -214,6 +215,7 @@ requires-dist = [
{ name = "google-api-python-client", specifier = ">=2.162.0" },
{ name = "google-auth-httplib2", specifier = ">=0.2.0" },
{ name = "google-auth-oauthlib", specifier = ">=1.2.1" },
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" },
]
[[package]]
@@ -403,6 +405,64 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
]
[[package]]
name = "pillow"
version = "12.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
]
[[package]]
name = "proto-plus"
version = "1.27.1"
@@ -469,6 +529,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
]
[[package]]
name = "qrcode"
version = "8.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
]
[package.optional-dependencies]
pil = [
{ name = "pillow" },
]
[[package]]
name = "requests"
version = "2.32.5"