3 Commits

Author SHA1 Message Date
Jan Novak
7d05e3812c fix: correctly extract exception amount on fees page
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Build and Push / build (push) Successful in 9s
2026-03-02 22:23:13 +01:00
Jan Novak
815b962dd7 feat: add member details popup with attendance and fee exceptions
All checks were successful
Deploy to K8s / deploy (push) Successful in 12s
Build and Push / build (push) Successful in 8s
2026-03-02 21:41:36 +01:00
Jan Novak
99b23199b1 feat: improve attendance parsing logic and fix payment date formatting
All checks were successful
Build and Push / build (push) Successful in 8s
Deploy to K8s / deploy (push) Successful in 12s
2026-03-02 15:06:28 +01:00
9 changed files with 557 additions and 22 deletions

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"makefile.configureOnOpen": false
}

32
app.py
View File

@@ -9,7 +9,7 @@ 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 match_payments import reconcile, fetch_sheet_data, DEFAULT_SPREADSHEET_ID as PAYMENTS_SHEET_ID
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize, DEFAULT_SPREADSHEET_ID as PAYMENTS_SHEET_ID
app = Flask(__name__)
@@ -37,14 +37,30 @@ def fees():
monthly_totals = {m: 0 for m in sorted_months}
# Get exceptions for formatting
credentials_path = ".secret/fuj-management-bot-credentials.json"
exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path)
formatted_results = []
for name, month_fees in results:
row = {"name": name, "months": []}
norm_name = normalize(name)
for m in sorted_months:
fee, count = month_fees.get(m, (0, 0))
monthly_totals[m] += fee
cell = f"{fee} CZK ({count})" if count > 0 else "-"
row["months"].append(cell)
# 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 override_amount is not None and override_amount != fee:
cell = f"{override_amount} ({fee}) CZK ({count})" if count > 0 else f"{override_amount} ({fee}) CZK"
is_overridden = True
else:
cell = f"{fee} CZK ({count})" if count > 0 else "-"
is_overridden = False
row["months"].append({"cell": cell, "overridden": is_overridden})
formatted_results.append(row)
return render_template(
@@ -69,7 +85,8 @@ def reconcile_view():
return "No data."
transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
result = reconcile(members, sorted_months, transactions)
exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path)
result = reconcile(members, sorted_months, transactions, exceptions)
# Format month labels
month_labels = {
@@ -84,8 +101,9 @@ def reconcile_view():
data = result["members"][name]
row = {"name": name, "months": [], "balance": data["total_balance"]}
for m in sorted_months:
mdata = data["months"].get(m, {"expected": 0, "paid": 0})
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0})
expected = mdata["expected"]
original = mdata["original_expected"]
paid = int(mdata["paid"])
cell_status = ""
@@ -106,14 +124,16 @@ def reconcile_view():
# 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"])
# Format unmatched
unmatched = result["unmatched"]
import json
return render_template(
"reconcile.html",
months=[month_labels[m] for m in sorted_months],
raw_months=sorted_months,
results=formatted_results,
member_data=json.dumps(result["members"]),
credits=credits,
debts=debts,
unmatched=unmatched,

View File

@@ -1,6 +1,6 @@
[project]
name = "fuj-management"
version = "0.05"
version = "0.06"
description = "Management tools for FUJ (Frisbee Ultimate Jablonec)"
dependencies = [
"flask>=3.1.3",

View File

@@ -58,14 +58,31 @@ def calculate_fee(attendance_count: int) -> int:
def get_members(rows: list[list[str]]) -> list[tuple[str, str, list[str]]]:
"""Parse member rows. Returns list of (name, tier, row)."""
"""Parse member rows. Returns list of (name, tier, row).
Stopped at row where first column contains '# last line'.
Skips rows starting with '#'.
"""
members = []
for row in rows[1:]:
name = row[COL_NAME].strip() if len(row) > COL_NAME else ""
if not name or name.lower() in ("jméno", "name", "jmeno"):
if not row or len(row) <= COL_NAME:
continue
first_col = row[COL_NAME].strip()
# Terminator for rows to process
if "# last line" 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 ""
members.append((name, tier, row))
members.append((first_col, tier, row))
return members

View File

@@ -159,6 +159,27 @@ def infer_transaction_details(tx: dict, member_names: list[str]) -> dict:
}
def format_date(val) -> str:
"""Normalize date from Google Sheet (handles serial numbers and strings)."""
if val is None or val == "":
return ""
# Handle Google Sheets serial dates (number of days since 1899-12-30)
if isinstance(val, (int, float)):
base_date = datetime(1899, 12, 30)
dt = base_date + timedelta(days=val)
return dt.strftime("%Y-%m-%d")
val_str = str(val).strip()
if not val_str:
return ""
# If already YYYY-MM-DD, return as is
if len(val_str) == 10 and val_str[4] == "-" and val_str[7] == "-":
return val_str
return val_str
def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
"""Fetch all rows from the Google Sheet and convert to a list of dicts."""
service = get_sheets_service(credentials_path)
@@ -197,7 +218,7 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
return row[idx] if idx != -1 and idx < len(row) else ""
tx = {
"date": get_val(idx_date),
"date": format_date(get_val(idx_date)),
"amount": get_val(idx_amount),
"manual_fix": get_val(idx_manual),
"person": get_val(idx_person),
@@ -212,10 +233,49 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
return transactions
def fetch_exceptions(spreadsheet_id: str, credentials_path: str) -> dict[tuple[str, str], dict]:
"""Fetch manual fee overrides from the 'exceptions' sheet.
Returns a dict mapping (member_name, period_YYYYMM) to {'amount': int, 'note': str}.
"""
service = get_sheets_service(credentials_path)
try:
result = service.spreadsheets().values().get(
spreadsheetId=spreadsheet_id,
range="'exceptions'!A2:D",
valueRenderOption="UNFORMATTED_VALUE"
).execute()
rows = result.get("values", [])
except Exception as e:
print(f"Warning: Could not fetch exceptions: {e}")
return {}
exceptions = {}
for row in rows:
if len(row) < 3 or str(row[0]).lower().startswith("name"):
continue
name = str(row[0]).strip()
period = str(row[1]).strip()
# Robust normalization using czech_utils.normalize
norm_name = normalize(name)
norm_period = normalize(period)
try:
amount = int(row[2])
note = str(row[3]).strip() if len(row) > 3 else ""
exceptions[(norm_name, norm_period)] = {"amount": amount, "note": note}
except (ValueError, TypeError):
continue
return exceptions
def reconcile(
members: list[tuple[str, str, dict[str, int]]],
sorted_months: list[str],
transactions: list[dict],
exceptions: dict[tuple[str, str], dict] = None,
) -> dict:
"""Match transactions to members and months.
@@ -230,11 +290,30 @@ def reconcile(
# Initialize ledger
ledger: dict[str, dict[str, dict]] = {}
exceptions = exceptions or {}
for name in member_names:
ledger[name] = {}
for m in sorted_months:
# Robust normalization for lookup
norm_name = normalize(name)
norm_period = normalize(m)
fee_data = member_fees[name].get(m, (0, 0))
original_expected = fee_data[0] if isinstance(fee_data, tuple) else fee_data
attendance_count = fee_data[1] if isinstance(fee_data, tuple) else 0
ex_data = exceptions.get((norm_name, norm_period))
if ex_data is not None:
expected = ex_data["amount"]
exception_info = ex_data
else:
expected = original_expected
exception_info = None
ledger[name][m] = {
"expected": member_fees[name].get(m, 0),
"expected": expected,
"original_expected": original_expected,
"attendance_count": attendance_count,
"exception": exception_info,
"paid": 0,
"transactions": [],
}
@@ -371,10 +450,12 @@ def print_report(result: dict, sorted_months: list[str]):
for m in sorted_months:
mdata = data["months"].get(m, {"expected": 0, "paid": 0})
expected = mdata["expected"]
original = mdata["original_expected"]
paid = int(mdata["paid"])
total_expected += expected
total_paid += paid
cell_status = ""
if expected == 0 and paid == 0:
cell = "-"
elif paid >= expected and expected > 0:
@@ -383,6 +464,7 @@ def print_report(result: dict, sorted_months: list[str]):
cell = f"{paid}/{expected}"
else:
cell = f"UNPAID {expected}"
member_balance += paid - expected
line += f" | {cell:>10}"
balance_str = f"{member_balance:+d}" if member_balance != 0 else "0"
@@ -488,7 +570,11 @@ def main():
print(f"Processing {len(transactions)} transactions.\n")
result = reconcile(members, sorted_months, transactions)
exceptions = fetch_exceptions(args.sheet_id, args.credentials)
if exceptions:
print(f"Loaded {len(exceptions)} fee exceptions.")
result = reconcile(members, sorted_months, transactions, exceptions)
print_report(result, sorted_months)

View File

@@ -102,6 +102,11 @@
/* Light gray for normal cells */
}
.cell-overridden {
color: #ffa500 !important;
/* Orange for overrides */
}
.nav {
margin-bottom: 20px;
font-size: 12px;
@@ -175,8 +180,11 @@
{% for row in results %}
<tr>
<td>{{ row.name }}</td>
{% for cell in row.months %}
<td class="{% if cell == '-' %}cell-empty{% else %}cell-paid{% endif %}">{{ cell }}</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 %}

View File

@@ -183,6 +183,166 @@
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;
}
</style>
</head>
@@ -201,6 +361,11 @@
<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>
@@ -212,10 +377,13 @@
<th>Balance</th>
</tr>
</thead>
<tbody>
<tbody id="reconcileBody">
{% for row in results %}
<tr>
<td>{{ row.name }}</td>
<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 == '-' %}cell-empty{% elif 'UNPAID' in cell %}cell-unpaid{% elif cell == 'OK' %}cell-ok{% endif %}">
@@ -275,6 +443,183 @@
</div>
{% endif %}
<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">
<div class="modal-section-title">Payment History</div>
<div id="modalTxList" class="tx-list">
<!-- Filled by JS -->
</div>
</div>
</div>
</div>
<script>
const memberData = {{ member_data| safe }};
const sortedMonths = {{ raw_months| tojson }};
function showMemberDetails(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 row = document.createElement('tr');
row.innerHTML = `
<td style="color: #888;">${m}</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 item = document.createElement('div');
item.className = 'tx-item'; // Reuse style
item.innerHTML = `
<div class="tx-meta">${ex.month}</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 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 item = document.createElement('div');
item.className = 'tx-item';
item.innerHTML = `
<div class="tx-meta">${tx.date} | matched to ${tx.month}</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() {
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
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeModal();
});
</script>
</body>
</html>
```

View File

@@ -0,0 +1,56 @@
import unittest
from scripts.match_payments import reconcile
class TestReconcileWithExceptions(unittest.TestCase):
def test_reconcile_applies_exceptions(self):
# 1. Setup mock data
# Member: Alice, Tier A, expected 750 (attendance-based)
members = [
('Alice', 'A', {'2026-01': (750, 4)})
]
sorted_months = ['2026-01']
# Exception: Alice should only pay 400 in 2026-01 (normalized keys, no accents)
exceptions = {
('alice', '2026-01'): {'amount': 400, 'note': 'Test exception'}
}
# Transaction: Alice paid 400
transactions = [{
'date': '2026-01-05',
'amount': 400,
'person': 'Alice',
'purpose': '2026-01',
'inferred_amount': 400,
'sender': 'Alice Sender',
'message': 'fee'
}]
# 2. Reconcile
result = reconcile(members, sorted_months, transactions, exceptions)
# 3. Assertions
alice_data = result['members']['Alice']
jan_data = alice_data['months']['2026-01']
self.assertEqual(jan_data['expected'], 400, "Expected amount should be overridden by exception")
self.assertEqual(jan_data['paid'], 400, "Paid amount should be 400")
self.assertEqual(alice_data['total_balance'], 0, "Balance should be 0 because 400/400")
def test_reconcile_fallback_to_attendance(self):
# Alice has attendance-based fee 750, NO exception
members = [
('Alice', 'A', {'2026-01': (750, 4)})
]
sorted_months = ['2026-01']
exceptions = {} # No exceptions
transactions = []
result = reconcile(members, sorted_months, transactions, exceptions)
alice_data = result['members']['Alice']
self.assertEqual(alice_data['months']['2026-01']['expected'], 750, "Should fallback to attendance fee")
if __name__ == '__main__':
unittest.main()

2
uv.lock generated
View File

@@ -199,7 +199,7 @@ wheels = [
[[package]]
name = "fuj-management"
version = "0.2"
version = "0.6"
source = { virtual = "." }
dependencies = [
{ name = "flask" },