Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bb8c7420c | ||
|
|
b0276f68b3 | ||
|
|
7d05e3812c | ||
|
|
815b962dd7 | ||
|
|
99b23199b1 |
@@ -1,5 +1,7 @@
|
||||
# Antigravity Agent Configuration
|
||||
# This file provides global rules for the Antigravity agent when working on this repository.
|
||||
|
||||
- **Git Commits**: When making git commits, always append the following co-author trailer to the end of the commit message to indicate AI assistance:
|
||||
`Co-authored-by: Antigravity <antigravity@deepmind.com>`
|
||||
- **Identity**: Antigravity AI (Assistant)
|
||||
- **Git Commits**: Always follow [Conventional Commits](https://www.conventionalcommits.org/) and append the co-author trailer:
|
||||
`Co-authored-by: Antigravity <antigravity@google.com>`
|
||||
- **Workflow**: Prefer updating `task.md` and `walkthrough.md` in the `.gemini/antigravity/brain/` directory to track progress and document changes.
|
||||
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"makefile.configureOnOpen": false
|
||||
}
|
||||
148
app.py
148
app.py
@@ -2,17 +2,53 @@ import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import re
|
||||
from flask import Flask, render_template
|
||||
import time
|
||||
import os
|
||||
import io
|
||||
import qrcode
|
||||
from flask import Flask, render_template, g, send_file, request
|
||||
|
||||
# Add scripts directory to path to allow importing from it
|
||||
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__)
|
||||
|
||||
# Bank account for QR code payments (can be overridden by ENV)
|
||||
BANK_ACCOUNT = os.environ.get("BANK_ACCOUNT", "CZ8520100000002800359168")
|
||||
|
||||
@app.before_request
|
||||
def start_timer():
|
||||
g.start_time = time.perf_counter()
|
||||
g.steps = []
|
||||
|
||||
def record_step(name):
|
||||
g.steps.append((name, time.perf_counter()))
|
||||
|
||||
@app.context_processor
|
||||
def inject_render_time():
|
||||
def get_render_time():
|
||||
total = time.perf_counter() - g.start_time
|
||||
breakdown = []
|
||||
last_time = g.start_time
|
||||
for name, timestamp in g.steps:
|
||||
duration = timestamp - last_time
|
||||
breakdown.append(f"{name}:{duration:.3f}s")
|
||||
last_time = timestamp
|
||||
|
||||
# Add remaining time as 'render'
|
||||
render_duration = time.perf_counter() - last_time
|
||||
breakdown.append(f"render:{render_duration:.3f}s")
|
||||
|
||||
return {
|
||||
"total": f"{total:.3f}",
|
||||
"breakdown": " | ".join(breakdown)
|
||||
}
|
||||
return dict(get_render_time=get_render_time)
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
# Redirect root to /fees for convenience while there are no other apps
|
||||
@@ -24,6 +60,7 @@ def fees():
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
|
||||
members, sorted_months = get_members_with_fees()
|
||||
record_step("fetch_members")
|
||||
if not members:
|
||||
return "No data."
|
||||
|
||||
@@ -37,16 +74,35 @@ 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)
|
||||
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, count = month_fees.get(m, (0, 0))
|
||||
monthly_totals[m] += fee
|
||||
|
||||
# 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 "-"
|
||||
row["months"].append(cell)
|
||||
is_overridden = False
|
||||
row["months"].append({"cell": cell, "overridden": is_overridden})
|
||||
formatted_results.append(row)
|
||||
|
||||
record_step("process_data")
|
||||
|
||||
return render_template(
|
||||
"fees.html",
|
||||
months=[month_labels[m] for m in sorted_months],
|
||||
@@ -65,11 +121,16 @@ def reconcile_view():
|
||||
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
||||
|
||||
members, sorted_months = get_members_with_fees()
|
||||
record_step("fetch_members")
|
||||
if not members:
|
||||
return "No data."
|
||||
|
||||
transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
|
||||
result = reconcile(members, sorted_months, transactions)
|
||||
record_step("fetch_payments")
|
||||
exceptions = fetch_exceptions(PAYMENTS_SHEET_ID, credentials_path)
|
||||
record_step("fetch_exceptions")
|
||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||
record_step("reconcile")
|
||||
|
||||
# Format month labels
|
||||
month_labels = {
|
||||
@@ -84,21 +145,36 @@ 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"]
|
||||
paid = int(mdata["paid"])
|
||||
|
||||
cell_status = ""
|
||||
if expected == 0 and paid == 0:
|
||||
cell = "-"
|
||||
elif paid >= expected and expected > 0:
|
||||
cell = "OK"
|
||||
elif paid > 0:
|
||||
cell = f"{paid}/{expected}"
|
||||
else:
|
||||
cell = f"UNPAID {expected}"
|
||||
status = "empty"
|
||||
cell_text = "-"
|
||||
amount_to_pay = 0
|
||||
|
||||
row["months"].append(cell)
|
||||
if expected > 0:
|
||||
if paid >= expected:
|
||||
status = "ok"
|
||||
cell_text = "OK"
|
||||
elif paid > 0:
|
||||
status = "partial"
|
||||
cell_text = f"{paid}/{expected}"
|
||||
amount_to_pay = expected - paid
|
||||
else:
|
||||
status = "unpaid"
|
||||
cell_text = f"UNPAID {expected}"
|
||||
amount_to_pay = expected
|
||||
elif paid > 0:
|
||||
status = "surplus"
|
||||
cell_text = f"PAID {paid}"
|
||||
|
||||
row["months"].append({
|
||||
"text": cell_text,
|
||||
"status": status,
|
||||
"amount": amount_to_pay,
|
||||
"month": month_labels[m]
|
||||
})
|
||||
|
||||
row["balance"] = data["total_balance"] # Updated to use total_balance
|
||||
formatted_results.append(row)
|
||||
@@ -106,19 +182,24 @@ 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
|
||||
|
||||
record_step("process_data")
|
||||
|
||||
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,
|
||||
attendance_url=attendance_url,
|
||||
payments_url=payments_url
|
||||
payments_url=payments_url,
|
||||
bank_account=BANK_ACCOUNT
|
||||
)
|
||||
|
||||
@app.route("/payments")
|
||||
@@ -128,6 +209,7 @@ def payments():
|
||||
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
||||
|
||||
transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
|
||||
record_step("fetch_payments")
|
||||
|
||||
# Group transactions by person
|
||||
grouped = {}
|
||||
@@ -151,6 +233,7 @@ def payments():
|
||||
# Sort by date descending
|
||||
grouped[p].sort(key=lambda x: str(x.get("date", "")), reverse=True)
|
||||
|
||||
record_step("process_data")
|
||||
return render_template(
|
||||
"payments.html",
|
||||
grouped_payments=grouped,
|
||||
@@ -159,5 +242,36 @@ def payments():
|
||||
payments_url=payments_url
|
||||
)
|
||||
|
||||
@app.route("/qr")
|
||||
def qr_code():
|
||||
account = request.args.get("account", BANK_ACCOUNT)
|
||||
amount = request.args.get("amount", "0")
|
||||
message = request.args.get("message", "")
|
||||
|
||||
# QR Platba standard: SPD*1.0*ACC:accountNumber*BC:bankCode*AM:amount*CC:CZK*MSG:message
|
||||
acc_parts = account.split('/')
|
||||
if len(acc_parts) == 2:
|
||||
acc_str = f"{acc_parts[0]}*BC:{acc_parts[1]}"
|
||||
else:
|
||||
acc_str = account
|
||||
|
||||
try:
|
||||
amt_val = float(amount)
|
||||
amt_str = f"{amt_val:.2f}"
|
||||
except ValueError:
|
||||
amt_str = "0.00"
|
||||
|
||||
# Message max 60 characters
|
||||
msg_str = message[:60]
|
||||
|
||||
qr_data = f"SPD*1.0*ACC:{acc_str}*AM:{amt_str}*CC:CZK*MSG:{msg_str}"
|
||||
|
||||
img = qrcode.make(qr_data)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
buf.seek(0)
|
||||
|
||||
return send_file(buf, mimetype='image/png')
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True, host='0.0.0.0', port=5001)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -102,6 +102,11 @@
|
||||
/* Light gray for normal cells */
|
||||
}
|
||||
|
||||
.cell-overridden {
|
||||
color: #ffa500 !important;
|
||||
/* Orange for overrides */
|
||||
}
|
||||
|
||||
.nav {
|
||||
margin-bottom: 20px;
|
||||
font-size: 12px;
|
||||
@@ -143,6 +148,23 @@
|
||||
.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>
|
||||
|
||||
@@ -175,8 +197,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 %}
|
||||
@@ -191,6 +216,14 @@
|
||||
</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>
|
||||
@@ -137,6 +137,23 @@
|
||||
tr:hover {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@@ -183,6 +200,14 @@
|
||||
{% endfor %}
|
||||
</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>
|
||||
@@ -138,6 +138,28 @@
|
||||
|
||||
.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 {
|
||||
@@ -183,6 +205,219 @@
|
||||
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>
|
||||
|
||||
@@ -201,6 +436,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,14 +452,21 @@
|
||||
<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 %}">
|
||||
{{ cell }}
|
||||
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 %}">
|
||||
@@ -275,6 +522,245 @@
|
||||
</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">
|
||||
<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 }};
|
||||
|
||||
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(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
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
});
|
||||
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>
|
||||
```
|
||||
56
tests/test_reconcile_exceptions.py
Normal file
56
tests/test_reconcile_exceptions.py
Normal 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()
|
||||
Reference in New Issue
Block a user