feat: add reconciliation and ledger views to web dashboard with test suite
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
This commit is contained in:
114
app.py
114
app.py
@@ -1,13 +1,15 @@
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import re
|
||||||
from flask import Flask, render_template
|
from flask import Flask, render_template
|
||||||
|
|
||||||
# Add scripts directory to path to allow importing from it
|
# Add scripts directory to path to allow importing from it
|
||||||
scripts_dir = Path(__file__).parent / "scripts"
|
scripts_dir = Path(__file__).parent / "scripts"
|
||||||
sys.path.append(str(scripts_dir))
|
sys.path.append(str(scripts_dir))
|
||||||
|
|
||||||
from attendance import get_members_with_fees
|
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
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
@@ -18,6 +20,9 @@ def index():
|
|||||||
|
|
||||||
@app.route("/fees")
|
@app.route("/fees")
|
||||||
def fees():
|
def fees():
|
||||||
|
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||||
|
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||||
|
|
||||||
members, sorted_months = get_members_with_fees()
|
members, sorted_months = get_members_with_fees()
|
||||||
if not members:
|
if not members:
|
||||||
return "No data."
|
return "No data."
|
||||||
@@ -46,7 +51,112 @@ def fees():
|
|||||||
"fees.html",
|
"fees.html",
|
||||||
months=[month_labels[m] for m in sorted_months],
|
months=[month_labels[m] for m in sorted_months],
|
||||||
results=formatted_results,
|
results=formatted_results,
|
||||||
totals=[f"{monthly_totals[m]} CZK" for m in sorted_months]
|
totals=[f"{monthly_totals[m]} CZK" for m in sorted_months],
|
||||||
|
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"
|
||||||
|
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||||
|
|
||||||
|
# Use hardcoded credentials path for now, consistent with other scripts
|
||||||
|
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
||||||
|
|
||||||
|
members, sorted_months = get_members_with_fees()
|
||||||
|
if not members:
|
||||||
|
return "No data."
|
||||||
|
|
||||||
|
transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
|
||||||
|
result = reconcile(members, sorted_months, transactions)
|
||||||
|
|
||||||
|
# Format month labels
|
||||||
|
month_labels = {
|
||||||
|
m: datetime.strptime(m, "%Y-%m").strftime("%b %Y") for m in sorted_months
|
||||||
|
}
|
||||||
|
|
||||||
|
# Filter to adults for the main table
|
||||||
|
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
|
||||||
|
|
||||||
|
formatted_results = []
|
||||||
|
for name in adult_names:
|
||||||
|
data = result["members"][name]
|
||||||
|
row = {"name": name, "months": [], "balance": data["total_balance"]}
|
||||||
|
for m in sorted_months:
|
||||||
|
mdata = data["months"].get(m, {"expected": 0, "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}"
|
||||||
|
|
||||||
|
row["months"].append(cell)
|
||||||
|
|
||||||
|
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"])
|
||||||
|
|
||||||
|
# Format unmatched
|
||||||
|
unmatched = result["unmatched"]
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"reconcile.html",
|
||||||
|
months=[month_labels[m] for m in sorted_months],
|
||||||
|
results=formatted_results,
|
||||||
|
credits=credits,
|
||||||
|
debts=debts,
|
||||||
|
unmatched=unmatched,
|
||||||
|
attendance_url=attendance_url,
|
||||||
|
payments_url=payments_url
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/payments")
|
||||||
|
def payments():
|
||||||
|
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||||
|
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||||
|
credentials_path = ".secret/fuj-management-bot-credentials.json"
|
||||||
|
|
||||||
|
transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
|
||||||
|
|
||||||
|
# Group transactions by person
|
||||||
|
grouped = {}
|
||||||
|
for tx in transactions:
|
||||||
|
person = str(tx.get("person", "")).strip()
|
||||||
|
if not person:
|
||||||
|
person = "Unmatched / Unknown"
|
||||||
|
|
||||||
|
# Handle multiple people (comma separated)
|
||||||
|
people = [p.strip() for p in person.split(",") if p.strip()]
|
||||||
|
for p in people:
|
||||||
|
# Strip markers
|
||||||
|
clean_p = re.sub(r"\[\?\]\s*", "", p)
|
||||||
|
if clean_p not in grouped:
|
||||||
|
grouped[clean_p] = []
|
||||||
|
grouped[clean_p].append(tx)
|
||||||
|
|
||||||
|
# Sort people and their transactions
|
||||||
|
sorted_people = sorted(grouped.keys())
|
||||||
|
for p in sorted_people:
|
||||||
|
# Sort by date descending
|
||||||
|
grouped[p].sort(key=lambda x: str(x.get("date", "")), reverse=True)
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"payments.html",
|
||||||
|
grouped_payments=grouped,
|
||||||
|
sorted_people=sorted_people,
|
||||||
|
attendance_url=attendance_url,
|
||||||
|
payments_url=payments_url
|
||||||
)
|
)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -101,11 +101,66 @@
|
|||||||
color: #aaaaaa;
|
color: #aaaaaa;
|
||||||
/* Light gray for normal cells */
|
/* Light gray for normal cells */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
<div class="nav">
|
||||||
|
<a href="/fees" class="active">[Attendance/Fees]</a>
|
||||||
|
<a href="/reconcile">[Payment Reconciliation]</a>
|
||||||
|
<a href="/payments">[Payments Ledger]</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h1>FUJ Fees Dashboard</h1>
|
<h1>FUJ Fees Dashboard</h1>
|
||||||
|
|
||||||
|
<div class="description">
|
||||||
|
Calculated monthly fees based on attendance markers.<br>
|
||||||
|
Source: <a href="{{ attendance_url }}" target="_blank">Attendance Sheet</a> |
|
||||||
|
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
188
templates/payments.html
Normal file
188
templates/payments.html
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FUJ Payments Ledger</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: 5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-block {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.txn-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.txn-table th,
|
||||||
|
.txn-table td {
|
||||||
|
padding: 2px 8px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px dashed #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.txn-table th {
|
||||||
|
color: #555;
|
||||||
|
text-transform: lowercase;
|
||||||
|
font-weight: normal;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.txn-date {
|
||||||
|
min-width: 80px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.txn-amount {
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: right !important;
|
||||||
|
color: #00ff00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.txn-purpose {
|
||||||
|
min-width: 100px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.txn-message {
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="nav">
|
||||||
|
<a href="/fees">[Attendance/Fees]</a>
|
||||||
|
<a href="/reconcile">[Payment Reconciliation]</a>
|
||||||
|
<a href="/payments" class="active">[Payments Ledger]</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Payments Ledger</h1>
|
||||||
|
|
||||||
|
<div class="description">
|
||||||
|
All bank transactions from the Google Sheet, grouped by member.<br>
|
||||||
|
Source: <a href="{{ attendance_url }}" target="_blank">Attendance Sheet</a> |
|
||||||
|
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ledger-container">
|
||||||
|
{% for person in sorted_people %}
|
||||||
|
<div class="member-block">
|
||||||
|
<h2>{{ person }}</h2>
|
||||||
|
<table class="txn-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="txn-date">Date</th>
|
||||||
|
<th class="txn-amount">Amount</th>
|
||||||
|
<th class="txn-purpose">Purpose</th>
|
||||||
|
<th class="txn-message">Bank Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for tx in grouped_payments[person] %}
|
||||||
|
<tr>
|
||||||
|
<td class="txn-date">{{ tx.date }}</td>
|
||||||
|
<td class="txn-amount">{{ tx.amount }} CZK</td>
|
||||||
|
<td class="txn-purpose">{{ tx.purpose }}</td>
|
||||||
|
<td class="txn-message">{{ tx.message }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
280
templates/reconcile.html
Normal file
280
templates/reconcile.html
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FUJ 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="nav">
|
||||||
|
<a href="/fees">[Attendance/Fees]</a>
|
||||||
|
<a href="/reconcile" class="active">[Payment Reconciliation]</a>
|
||||||
|
<a href="/payments">[Payments Ledger]</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Payment Reconciliation</h1>
|
||||||
|
|
||||||
|
<div class="description">
|
||||||
|
Balances calculated by matching Google Sheet payments against 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="table-container">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Member</th>
|
||||||
|
{% for m in months %}
|
||||||
|
<th>{{ m }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th>Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in results %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ row.name }}</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 }}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}">
|
||||||
|
{{ "%+d"|format(row.balance) if row.balance != 0 else "0" }}
|
||||||
|
</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 %}
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
78
tests/test_app.py
Normal file
78
tests/test_app.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import unittest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from app import app
|
||||||
|
|
||||||
|
class TestWebApp(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
# Configure app for testing
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
self.client = app.test_client()
|
||||||
|
|
||||||
|
@patch('app.get_members_with_fees')
|
||||||
|
def test_index_page(self, mock_get_members):
|
||||||
|
"""Test that / returns the refresh meta tag"""
|
||||||
|
response = self.client.get('/')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b'url=/fees', response.data)
|
||||||
|
|
||||||
|
@patch('app.get_members_with_fees')
|
||||||
|
def test_fees_route(self, mock_get_members):
|
||||||
|
"""Test that /fees returns 200 and renders the dashboard"""
|
||||||
|
# Mock attendance data
|
||||||
|
mock_get_members.return_value = (
|
||||||
|
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||||
|
['2026-01']
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get('/fees')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b'FUJ Fees Dashboard', response.data)
|
||||||
|
self.assertIn(b'Test Member', response.data)
|
||||||
|
|
||||||
|
@patch('app.fetch_sheet_data')
|
||||||
|
@patch('app.get_members_with_fees')
|
||||||
|
def test_reconcile_route(self, mock_get_members, mock_fetch_sheet):
|
||||||
|
"""Test that /reconcile returns 200 and shows matches"""
|
||||||
|
# Mock attendance data
|
||||||
|
mock_get_members.return_value = (
|
||||||
|
[('Test Member', 'A', {'2026-01': (750, 4)})],
|
||||||
|
['2026-01']
|
||||||
|
)
|
||||||
|
# Mock sheet data - include all keys required by reconcile
|
||||||
|
mock_fetch_sheet.return_value = [{
|
||||||
|
'date': '2026-01-01',
|
||||||
|
'amount': 750,
|
||||||
|
'person': 'Test Member',
|
||||||
|
'purpose': '2026-01',
|
||||||
|
'message': 'test payment',
|
||||||
|
'sender': 'External Bank User',
|
||||||
|
'inferred_amount': 750
|
||||||
|
}]
|
||||||
|
|
||||||
|
response = self.client.get('/reconcile')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(b'Payment Reconciliation', response.data)
|
||||||
|
self.assertIn(b'Test Member', response.data)
|
||||||
|
self.assertIn(b'OK', response.data)
|
||||||
|
|
||||||
|
@patch('app.fetch_sheet_data')
|
||||||
|
def test_payments_route(self, mock_fetch_sheet):
|
||||||
|
"""Test that /payments returns 200 and groups transactions"""
|
||||||
|
# Mock sheet data
|
||||||
|
mock_fetch_sheet.return_value = [{
|
||||||
|
'date': '2026-01-01',
|
||||||
|
'amount': 750,
|
||||||
|
'person': 'Test Member',
|
||||||
|
'purpose': '2026-01',
|
||||||
|
'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)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user