3 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
8 changed files with 143 additions and 41 deletions

30
app.py
View File

@@ -12,15 +12,15 @@ from flask import Flask, render_template, g, send_file, request
scripts_dir = Path(__file__).parent / "scripts"
sys.path.append(str(scripts_dir))
from attendance import get_members_with_fees, get_junior_members_with_fees, SHEET_ID as ATTENDANCE_SHEET_ID, JUNIOR_SHEET_GID, MERGED_MONTHS
from attendance import get_members_with_fees, get_junior_members_with_fees, SHEET_ID as ATTENDANCE_SHEET_ID, JUNIOR_SHEET_GID, ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize, DEFAULT_SPREADSHEET_ID as PAYMENTS_SHEET_ID
def get_month_labels(sorted_months):
def get_month_labels(sorted_months, merged_months):
labels = {}
for m in sorted_months:
dt = datetime.strptime(m, "%Y-%m")
# Find which months were merged into m (e.g. 2026-01 is merged into 2026-02)
merged_in = sorted([k for k, v in MERGED_MONTHS.items() if v == m])
merged_in = sorted([k for k, v in merged_months.items() if v == m])
if merged_in:
all_dts = [datetime.strptime(x, "%Y-%m") for x in sorted(merged_in + [m])]
years = {d.year for d in all_dts}
@@ -87,7 +87,7 @@ def fees():
results = [(name, fees) for name, tier, fees in members if tier == "A"]
# Format month labels
month_labels = get_month_labels(sorted_months)
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
monthly_totals = {m: 0 for m in sorted_months}
@@ -144,7 +144,7 @@ def fees_juniors():
results = sorted([(name, fees) for name, tier, fees in members], key=lambda x: x[0])
# Format month labels
month_labels = get_month_labels(sorted_months)
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
monthly_totals = {m: 0 for m in sorted_months}
@@ -227,7 +227,7 @@ def reconcile_view():
record_step("reconcile")
# Format month labels
month_labels = get_month_labels(sorted_months)
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
# Filter to adults for the main table
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
@@ -235,7 +235,8 @@ def reconcile_view():
formatted_results = []
for name in adult_names:
data = result["members"][name]
row = {"name": name, "months": [], "balance": data["total_balance"]}
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""}
unpaid_months = []
for m in sorted_months:
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0})
expected = mdata["expected"]
@@ -253,10 +254,12 @@ def reconcile_view():
status = "partial"
cell_text = f"{paid}/{expected}"
amount_to_pay = expected - paid
unpaid_months.append(month_labels[m])
else:
status = "unpaid"
cell_text = f"UNPAID {expected}"
amount_to_pay = expected
unpaid_months.append(month_labels[m])
elif paid > 0:
status = "surplus"
cell_text = f"PAID {paid}"
@@ -268,12 +271,13 @@ def reconcile_view():
"month": month_labels[m]
})
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
row["balance"] = data["total_balance"] # Updated to use total_balance
formatted_results.append(row)
# Format credits and debts
credits = sorted([{"name": n, "amount": a["total_balance"]} for n, a in result["members"].items() if a["total_balance"] > 0], key=lambda x: x["name"])
debts = sorted([{"name": n, "amount": abs(a["total_balance"])} for n, a in result["members"].items() if a["total_balance"] < 0], key=lambda x: x["name"])
credits = sorted([{"name": n, "amount": a["total_balance"]} for n, a in result["members"].items() if a["total_balance"] > 0 and n in adult_names], key=lambda x: x["name"])
debts = sorted([{"name": n, "amount": abs(a["total_balance"])} for n, a in result["members"].items() if a["total_balance"] < 0 and n in adult_names], key=lambda x: x["name"])
# Format unmatched
unmatched = result["unmatched"]
import json
@@ -330,7 +334,7 @@ def reconcile_juniors_view():
record_step("reconcile")
# Format month labels
month_labels = get_month_labels(sorted_months)
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
# Filter to juniors for the main table
junior_names = sorted([name for name, tier, _ in adapted_members])
@@ -338,7 +342,8 @@ def reconcile_juniors_view():
formatted_results = []
for name in junior_names:
data = result["members"][name]
row = {"name": name, "months": [], "balance": data["total_balance"]}
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""}
unpaid_months = []
for m in sorted_months:
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0})
expected = mdata["expected"]
@@ -359,10 +364,12 @@ def reconcile_juniors_view():
status = "partial"
cell_text = f"{paid}/{expected}"
amount_to_pay = expected - paid
unpaid_months.append(month_labels[m])
else:
status = "unpaid"
cell_text = f"UNPAID {expected}"
amount_to_pay = expected
unpaid_months.append(month_labels[m])
elif paid > 0:
status = "surplus"
cell_text = f"PAID {paid}"
@@ -374,6 +381,7 @@ def reconcile_juniors_view():
"month": month_labels[m]
})
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
row["balance"] = data["total_balance"]
formatted_results.append(row)

View File

@@ -17,7 +17,12 @@ JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices
JUNIOR_MONTHLY_RATE = {
"2025-09": 250
}
MERGED_MONTHS = {
ADULT_MERGED_MONTHS = {
#"2025-12": "2026-01", # keys are merged into values
#"2025-09": "2025-10"
}
JUNIOR_MERGED_MONTHS = {
"2025-12": "2026-01", # keys are merged into values
"2025-09": "2025-10"
}
@@ -65,13 +70,13 @@ def parse_dates(header_row: list[str]) -> list[tuple[int, datetime]]:
return dates
def group_by_month(dates: list[tuple[int, datetime]]) -> dict[str, list[int]]:
def group_by_month(dates: list[tuple[int, datetime]], merged_months: dict[str, str]) -> dict[str, list[int]]:
"""Group column indices by YYYY-MM, handling merged months."""
months: dict[str, list[int]] = {}
for col, dt in dates:
key = dt.strftime("%Y-%m")
# Apply merged month mapping if configured
target_key = MERGED_MONTHS.get(key, key)
target_key = merged_months.get(key, key)
months.setdefault(target_key, []).append(col)
return months
@@ -172,7 +177,7 @@ def get_members_with_fees() -> tuple[list[tuple[str, str, dict[str, int]]], list
if not dates:
return [], []
months = group_by_month(dates)
months = group_by_month(dates, ADULT_MERGED_MONTHS)
sorted_months = sorted(months.keys())
members_raw = get_members(rows)
@@ -211,8 +216,8 @@ def get_junior_members_with_fees() -> tuple[list[tuple[str, str, dict[str, tuple
main_dates = parse_dates(main_rows[0])
junior_dates = parse_dates(junior_rows[0])
main_months = group_by_month(main_dates)
junior_months = group_by_month(junior_dates)
main_months = group_by_month(main_dates, JUNIOR_MERGED_MONTHS)
junior_months = group_by_month(junior_dates, JUNIOR_MERGED_MONTHS)
# Collect all unique sorted months
all_months = set(main_months.keys()).union(set(junior_months.keys()))

View File

@@ -290,9 +290,11 @@ def reconcile(
# Initialize ledger
ledger: dict[str, dict[str, dict]] = {}
other_ledger: dict[str, list] = {}
exceptions = exceptions or {}
for name in member_names:
ledger[name] = {}
other_ledger[name] = []
for m in sorted_months:
# Robust normalization for lookup
norm_name = normalize(name)
@@ -328,12 +330,13 @@ def reconcile(
# Strip markers like [?]
person_str = re.sub(r"\[\?\]\s*", "", person_str)
is_other = purpose_str.lower().startswith("other:")
if person_str and purpose_str:
# We have pre-matched data (either from script or manual)
# Support multiple people/months in the comma-separated string
matched_members = [(p.strip(), "auto") for p in person_str.split(",") if p.strip()]
matched_months = [m.strip() for m in purpose_str.split(",") if m.strip()]
matched_months = [purpose_str] if is_other else [m.strip() for m in purpose_str.split(",") if m.strip()]
# Use Inferred Amount if available, otherwise bank Amount
amount = tx.get("inferred_amount")
@@ -359,6 +362,21 @@ def reconcile(
continue
# Allocate payment across matched members and months
if is_other:
num_allocations = len(matched_members)
per_allocation = amount / num_allocations if num_allocations > 0 else 0
for member_name, confidence in matched_members:
if member_name in other_ledger:
other_ledger[member_name].append({
"amount": per_allocation,
"date": tx["date"],
"sender": tx["sender"],
"message": tx["message"],
"purpose": purpose_str,
"confidence": confidence,
})
continue
num_allocations = len(matched_members) * len(matched_months)
per_allocation = amount / num_allocations if num_allocations > 0 else 0
@@ -399,6 +417,7 @@ def reconcile(
name: {
"tier": member_tiers[name],
"months": ledger[name],
"other_transactions": other_ledger[name],
"total_balance": final_balances[name]
}
for name in member_names

View File

@@ -155,10 +155,10 @@
<body>
<div class="nav">
<a href="/fees">[Attendance/Fees]</a>
<a href="/fees-juniors" class="active">[Junior Fees]</a>
<a href="/reconcile">[Payment Reconciliation]</a>
<a href="/reconcile-juniors">[Junior Reconciliation]</a>
<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>

View File

@@ -170,10 +170,10 @@
<body>
<div class="nav">
<a href="/fees" class="active">[Attendance/Fees]</a>
<a href="/fees-juniors">[Junior Fees]</a>
<a href="/reconcile">[Payment Reconciliation]</a>
<a href="/reconcile-juniors">[Junior 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,10 +159,10 @@
<body>
<div class="nav">
<a href="/fees">[Attendance/Fees]</a>
<a href="/fees-juniors">[Junior Fees]</a>
<a href="/reconcile">[Payment Reconciliation]</a>
<a href="/reconcile-juniors">[Junior 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

@@ -423,10 +423,10 @@
<body>
<div class="nav">
<a href="/fees">[Attendance/Fees]</a>
<a href="/fees-juniors">[Junior Fees]</a>
<a href="/reconcile">[Payment Reconciliation]</a>
<a href="/reconcile-juniors" class="active">[Junior 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" class="active">[Junior Payment Reconciliation]</a>
<a href="/payments">[Payments Ledger]</a>
</div>
@@ -471,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 %}
@@ -576,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">
@@ -684,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 = '';

View File

@@ -423,10 +423,10 @@
<body>
<div class="nav">
<a href="/fees">[Attendance/Fees]</a>
<a href="/fees-juniors">[Junior Fees]</a>
<a href="/reconcile" class="active">[Payment Reconciliation]</a>
<a href="/reconcile-juniors">[Junior 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>
@@ -471,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 %}
@@ -576,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">
@@ -684,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 = '';