Compare commits
6 Commits
f0de300292
...
feat/go-m5
| Author | SHA1 | Date | |
|---|---|---|---|
| da5b82fcdb | |||
| f253e3fcb1 | |||
| 59223c0da4 | |||
| 32a16ff50d | |||
| 2eec51bb34 | |||
| b562ce3201 |
14
CHANGELOG.md
14
CHANGELOG.md
@@ -1,5 +1,19 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-05-07 17:37 CEST — feat(go): M5.1 — /api/* wire types + JSON Schemas
|
||||||
|
|
||||||
|
- New `go/internal/web/api/` package: `AdultsResponse`, `JuniorsResponse`, `PaymentsResponse`, `VersionResponse` with explicit `json:` tags matching Python view-model keys.
|
||||||
|
- `Expected{Value int; Unknown bool}` custom `MarshalJSON` emits integer or `"?"` for junior single-attendance months.
|
||||||
|
- `schemagen_test.go` golden-tests four JSON Schemas committed to `go/tests/fixtures/api-schema/`. `JSONSchema()` on `Expected` lives in the test file — production binary has no jsonschema dep.
|
||||||
|
- PR #16.
|
||||||
|
|
||||||
|
## 2026-05-07 15:26 CEST — refactor(app): extract view-model builders into scripts/views.py
|
||||||
|
|
||||||
|
- Pulled ~350 lines of inline per-row computation out of `adults_view`, `juniors_view`, and `payments` into three pure functions in `scripts/views.py`: `build_adults_view_model`, `build_juniors_view_model`, `build_payments_view_model`.
|
||||||
|
- Moved `get_month_labels`, `group_payments_by_person`, `adapt_junior_members` from `app.py` to `scripts/views.py`. Route handlers now ~25 lines each.
|
||||||
|
- Hotfixed missing `import re` that caused 500 on `/qr` after the refactor.
|
||||||
|
- No behaviour change; all 27 tests pass. Prep for `/api/*` shadow endpoints (M5).
|
||||||
|
|
||||||
## 2026-05-07 14:13 CEST — feat(go): --print-fio-table + Fio debug logging + date parser fix
|
## 2026-05-07 14:13 CEST — feat(go): --print-fio-table + Fio debug logging + date parser fix
|
||||||
|
|
||||||
- Added `--print-fio-table` flag to `fuj sync --dry-run`: prints an aligned table of every Fio transaction in the window with `STATUS=NEW/DUP`, using `text/tabwriter`. Key files: `go/internal/services/banksync/fio_table.go`, `sync.go`, `cmd/fuj/main.go`.
|
- Added `--print-fio-table` flag to `fuj sync --dry-run`: prints an aligned table of every Fio transaction in the window with `STATUS=NEW/DUP`, using `text/tabwriter`. Key files: `go/internal/services/banksync/fio_table.go`, `sync.go`, `cmd/fuj/main.go`.
|
||||||
|
|||||||
394
app.py
394
app.py
@@ -21,8 +21,14 @@ from config import (
|
|||||||
ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID,
|
ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID,
|
||||||
BANK_ACCOUNT, CREDENTIALS_PATH,
|
BANK_ACCOUNT, CREDENTIALS_PATH,
|
||||||
)
|
)
|
||||||
from attendance import get_members_with_fees, get_junior_members_with_fees, ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS
|
from attendance import get_members_with_fees, get_junior_members_with_fees
|
||||||
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize, canonical_member_key
|
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions
|
||||||
|
from views import (
|
||||||
|
build_adults_view_model,
|
||||||
|
build_juniors_view_model,
|
||||||
|
build_payments_view_model,
|
||||||
|
adapt_junior_members,
|
||||||
|
)
|
||||||
from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED, flush_cache
|
from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED, flush_cache
|
||||||
from sync_fio_to_sheets import sync_to_sheets
|
from sync_fio_to_sheets import sync_to_sheets
|
||||||
from infer_payments import infer_payments
|
from infer_payments import infer_payments
|
||||||
@@ -38,44 +44,6 @@ def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, dese
|
|||||||
write_cache(cache_key, mod_time, serialize(data) if serialize else data)
|
write_cache(cache_key, mod_time, serialize(data) if serialize else data)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def get_month_labels(sorted_months, merged_months):
|
|
||||||
labels = {}
|
|
||||||
for m in sorted_months:
|
|
||||||
dt = datetime.strptime(m, "%Y-%m")
|
|
||||||
# Find which months were merged into m (e.g. 2026-01 is merged into 2026-02)
|
|
||||||
merged_in = sorted([k for k, v in merged_months.items() if v == m])
|
|
||||||
if merged_in:
|
|
||||||
all_dts = [datetime.strptime(x, "%Y-%m") for x in sorted(merged_in + [m])]
|
|
||||||
years = {d.year for d in all_dts}
|
|
||||||
if len(years) > 1:
|
|
||||||
parts = [d.strftime("%b %Y") for d in all_dts]
|
|
||||||
labels[m] = "+".join(parts)
|
|
||||||
else:
|
|
||||||
parts = [d.strftime("%b") for d in all_dts]
|
|
||||||
labels[m] = f"{'+'.join(parts)} {dt.strftime('%Y')}"
|
|
||||||
else:
|
|
||||||
labels[m] = dt.strftime("%b %Y")
|
|
||||||
return labels
|
|
||||||
|
|
||||||
def group_payments_by_person(transactions, member_names=None):
|
|
||||||
canonical_by_key = (
|
|
||||||
{canonical_member_key(n): n for n in member_names} if member_names else {}
|
|
||||||
)
|
|
||||||
grouped = {}
|
|
||||||
for tx in transactions:
|
|
||||||
person = str(tx.get("person", "")).strip()
|
|
||||||
if not person:
|
|
||||||
continue
|
|
||||||
for p in person.split(","):
|
|
||||||
p = re.sub(r"\[\?\]\s*", "", p).strip()
|
|
||||||
if not p:
|
|
||||||
continue
|
|
||||||
key = canonical_by_key.get(canonical_member_key(p), p)
|
|
||||||
grouped.setdefault(key, []).append(tx)
|
|
||||||
for rows in grouped.values():
|
|
||||||
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
|
|
||||||
return grouped
|
|
||||||
|
|
||||||
def warmup_cache():
|
def warmup_cache():
|
||||||
"""Pre-fetch all cached data so first request is fast."""
|
"""Pre-fetch all cached data so first request is fast."""
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -200,155 +168,20 @@ def adults_view():
|
|||||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||||
record_step("reconcile")
|
record_step("reconcile")
|
||||||
|
|
||||||
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
|
vm = build_adults_view_model(
|
||||||
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
|
members, sorted_months, result, transactions,
|
||||||
current_month = datetime.now().strftime("%Y-%m")
|
datetime.now().strftime("%Y-%m"),
|
||||||
|
|
||||||
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
|
|
||||||
formatted_results = []
|
|
||||||
for name in adult_names:
|
|
||||||
data = result["members"][name]
|
|
||||||
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
|
|
||||||
unpaid_months = []
|
|
||||||
raw_unpaid_months = []
|
|
||||||
for m in sorted_months:
|
|
||||||
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
|
|
||||||
expected = mdata.get("expected", 0)
|
|
||||||
original_expected = mdata.get("original_expected", 0)
|
|
||||||
count = mdata.get("attendance_count", 0)
|
|
||||||
paid = int(mdata.get("paid", 0))
|
|
||||||
exception_info = mdata.get("exception", None)
|
|
||||||
|
|
||||||
monthly_totals[m]["expected"] += expected
|
|
||||||
monthly_totals[m]["paid"] += paid
|
|
||||||
|
|
||||||
override_amount = exception_info["amount"] if exception_info else None
|
|
||||||
|
|
||||||
if override_amount is not None and override_amount != original_expected:
|
|
||||||
is_overridden = True
|
|
||||||
fee_display = f"{override_amount} ({original_expected}) CZK ({count})" if count > 0 else f"{override_amount} ({original_expected}) CZK"
|
|
||||||
else:
|
|
||||||
is_overridden = False
|
|
||||||
fee_display = f"{expected} CZK ({count})" if count > 0 else f"{expected} CZK"
|
|
||||||
|
|
||||||
status = "empty"
|
|
||||||
cell_text = "-"
|
|
||||||
amount_to_pay = 0
|
|
||||||
|
|
||||||
if expected > 0:
|
|
||||||
amount_to_pay = max(0, expected - paid)
|
|
||||||
if paid >= expected:
|
|
||||||
status = "ok"
|
|
||||||
cell_text = f"{paid}/{fee_display}"
|
|
||||||
elif paid > 0:
|
|
||||||
status = "partial"
|
|
||||||
cell_text = f"{paid}/{fee_display}"
|
|
||||||
if m < current_month:
|
|
||||||
unpaid_months.append(month_labels[m])
|
|
||||||
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
|
||||||
else:
|
|
||||||
status = "unpaid"
|
|
||||||
cell_text = f"0/{fee_display}"
|
|
||||||
if m < current_month:
|
|
||||||
unpaid_months.append(month_labels[m])
|
|
||||||
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
|
||||||
elif paid > 0:
|
|
||||||
status = "surplus"
|
|
||||||
cell_text = f"PAID {paid}"
|
|
||||||
else:
|
|
||||||
cell_text = "-"
|
|
||||||
amount_to_pay = 0
|
|
||||||
|
|
||||||
if expected > 0 or paid > 0:
|
|
||||||
tooltip = f"Received: {paid}, Expected: {expected}"
|
|
||||||
else:
|
|
||||||
tooltip = ""
|
|
||||||
|
|
||||||
row["months"].append({
|
|
||||||
"text": cell_text,
|
|
||||||
"overridden": is_overridden,
|
|
||||||
"status": status,
|
|
||||||
"amount": amount_to_pay,
|
|
||||||
"month": month_labels[m],
|
|
||||||
"raw_month": m,
|
|
||||||
"tooltip": tooltip
|
|
||||||
})
|
|
||||||
|
|
||||||
# Balance = sum of (paid - expected) for past months only; current/future months ignored.
|
|
||||||
settled_balance = 0
|
|
||||||
for m, mdata in data["months"].items():
|
|
||||||
if m >= current_month:
|
|
||||||
continue
|
|
||||||
exp = mdata.get("expected", 0)
|
|
||||||
if isinstance(exp, int):
|
|
||||||
settled_balance += int(mdata.get("paid", 0)) - exp
|
|
||||||
|
|
||||||
payable_amount = max(0, -settled_balance)
|
|
||||||
row["unpaid_periods"] = ", ".join(unpaid_months)
|
|
||||||
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
|
|
||||||
row["balance"] = settled_balance
|
|
||||||
row["payable_amount"] = payable_amount
|
|
||||||
formatted_results.append(row)
|
|
||||||
|
|
||||||
formatted_totals = []
|
|
||||||
for m in sorted_months:
|
|
||||||
t = monthly_totals[m]
|
|
||||||
status = "empty"
|
|
||||||
if t["expected"] > 0 or t["paid"] > 0:
|
|
||||||
if t["paid"] == t["expected"]:
|
|
||||||
status = "ok"
|
|
||||||
elif t["paid"] < t["expected"]:
|
|
||||||
status = "unpaid"
|
|
||||||
else:
|
|
||||||
status = "surplus"
|
|
||||||
|
|
||||||
formatted_totals.append({
|
|
||||||
"text": f"{t['paid']} / {t['expected']} CZK",
|
|
||||||
"status": status
|
|
||||||
})
|
|
||||||
|
|
||||||
def settled_balance(name):
|
|
||||||
data = result["members"][name]
|
|
||||||
total = 0
|
|
||||||
for m, mdata in data["months"].items():
|
|
||||||
if m >= current_month:
|
|
||||||
continue
|
|
||||||
exp = mdata.get("expected", 0)
|
|
||||||
if isinstance(exp, int):
|
|
||||||
total += int(mdata.get("paid", 0)) - exp
|
|
||||||
return total
|
|
||||||
|
|
||||||
credits = sorted([{"name": n, "amount": settled_balance(n)} for n in adult_names if settled_balance(n) > 0], key=lambda x: x["name"])
|
|
||||||
debts = sorted([{"name": n, "amount": abs(settled_balance(n))} for n in adult_names if settled_balance(n) < 0], key=lambda x: x["name"])
|
|
||||||
unmatched = result["unmatched"]
|
|
||||||
import json
|
|
||||||
|
|
||||||
raw_payments_by_person = group_payments_by_person(transactions, [name for name, _, _ in members])
|
|
||||||
record_step("process_data")
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"adults.html",
|
|
||||||
months=[month_labels[m] for m in sorted_months],
|
|
||||||
raw_months=sorted_months,
|
|
||||||
results=formatted_results,
|
|
||||||
totals=formatted_totals,
|
|
||||||
member_data=json.dumps(result["members"]),
|
|
||||||
month_labels_json=json.dumps(month_labels),
|
|
||||||
raw_payments_json=json.dumps(raw_payments_by_person),
|
|
||||||
credits=credits,
|
|
||||||
debts=debts,
|
|
||||||
unmatched=unmatched,
|
|
||||||
attendance_url=attendance_url,
|
attendance_url=attendance_url,
|
||||||
payments_url=payments_url,
|
payments_url=payments_url,
|
||||||
bank_account=BANK_ACCOUNT,
|
bank_account=BANK_ACCOUNT,
|
||||||
current_month=current_month
|
|
||||||
)
|
)
|
||||||
|
record_step("process_data")
|
||||||
|
return render_template("adults.html", **vm)
|
||||||
|
|
||||||
@app.route("/juniors")
|
@app.route("/juniors")
|
||||||
def juniors_view():
|
def juniors_view():
|
||||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
|
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
|
||||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||||
|
|
||||||
credentials_path = CREDENTIALS_PATH
|
credentials_path = CREDENTIALS_PATH
|
||||||
|
|
||||||
junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
||||||
@@ -367,188 +200,19 @@ def juniors_view():
|
|||||||
)
|
)
|
||||||
record_step("fetch_exceptions")
|
record_step("fetch_exceptions")
|
||||||
|
|
||||||
# Adapt junior tuple format (name, tier, {month: (fee, total_count, adult_count, junior_count)})
|
adapted_members = adapt_junior_members(junior_members)
|
||||||
# to what match_payments expects: (name, tier, {month: (expected_fee, attendance_count)})
|
|
||||||
adapted_members = []
|
|
||||||
for name, tier, fees_dict in junior_members:
|
|
||||||
adapted_fees = {}
|
|
||||||
for m, fee_data in fees_dict.items():
|
|
||||||
if len(fee_data) == 4:
|
|
||||||
fee, total_count, _, _ = fee_data
|
|
||||||
adapted_fees[m] = (fee, total_count)
|
|
||||||
else:
|
|
||||||
fee, count = fee_data
|
|
||||||
adapted_fees[m] = (fee, count)
|
|
||||||
adapted_members.append((name, tier, adapted_fees))
|
|
||||||
|
|
||||||
result = reconcile(adapted_members, sorted_months, transactions, exceptions)
|
result = reconcile(adapted_members, sorted_months, transactions, exceptions)
|
||||||
record_step("reconcile")
|
record_step("reconcile")
|
||||||
|
|
||||||
# Format month labels
|
vm = build_juniors_view_model(
|
||||||
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
|
junior_members, adapted_members, sorted_months, result, transactions,
|
||||||
junior_names = sorted([name for name, tier, _ in adapted_members])
|
datetime.now().strftime("%Y-%m"),
|
||||||
junior_members_dict = {name: fees_dict for name, _, fees_dict in junior_members}
|
|
||||||
current_month = datetime.now().strftime("%Y-%m")
|
|
||||||
|
|
||||||
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
|
|
||||||
formatted_results = []
|
|
||||||
for name in junior_names:
|
|
||||||
data = result["members"][name]
|
|
||||||
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
|
|
||||||
unpaid_months = []
|
|
||||||
raw_unpaid_months = []
|
|
||||||
for m in sorted_months:
|
|
||||||
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
|
|
||||||
expected = mdata.get("expected", 0)
|
|
||||||
original_expected = mdata.get("original_expected", 0)
|
|
||||||
count = mdata.get("attendance_count", 0)
|
|
||||||
paid = int(mdata.get("paid", 0))
|
|
||||||
exception_info = mdata.get("exception", None)
|
|
||||||
|
|
||||||
if expected != "?" and isinstance(expected, int):
|
|
||||||
monthly_totals[m]["expected"] += expected
|
|
||||||
monthly_totals[m]["paid"] += paid
|
|
||||||
|
|
||||||
orig_fee_data = junior_members_dict.get(name, {}).get(m)
|
|
||||||
adult_count = 0
|
|
||||||
junior_count = 0
|
|
||||||
if orig_fee_data and len(orig_fee_data) == 4:
|
|
||||||
_, _, adult_count, junior_count = orig_fee_data
|
|
||||||
|
|
||||||
breakdown = ""
|
|
||||||
if adult_count > 0 and junior_count > 0:
|
|
||||||
breakdown = f":{junior_count}J,{adult_count}A"
|
|
||||||
elif junior_count > 0:
|
|
||||||
breakdown = f":{junior_count}J"
|
|
||||||
elif adult_count > 0:
|
|
||||||
breakdown = f":{adult_count}A"
|
|
||||||
|
|
||||||
count_str = f" ({count}{breakdown})" if count > 0 else ""
|
|
||||||
|
|
||||||
override_amount = exception_info["amount"] if exception_info else None
|
|
||||||
|
|
||||||
if override_amount is not None and override_amount != original_expected:
|
|
||||||
is_overridden = True
|
|
||||||
fee_display = f"{override_amount} ({original_expected}) CZK{count_str}"
|
|
||||||
else:
|
|
||||||
is_overridden = False
|
|
||||||
fee_display = f"{expected} CZK{count_str}"
|
|
||||||
|
|
||||||
status = "empty"
|
|
||||||
cell_text = "-"
|
|
||||||
amount_to_pay = 0
|
|
||||||
|
|
||||||
if expected == "?" or (isinstance(expected, int) and expected > 0):
|
|
||||||
if expected == "?":
|
|
||||||
status = "empty"
|
|
||||||
cell_text = f"?{count_str}"
|
|
||||||
elif paid >= expected:
|
|
||||||
status = "ok"
|
|
||||||
cell_text = f"{paid}/{fee_display}"
|
|
||||||
elif paid > 0:
|
|
||||||
status = "partial"
|
|
||||||
cell_text = f"{paid}/{fee_display}"
|
|
||||||
amount_to_pay = expected - paid
|
|
||||||
if m < current_month:
|
|
||||||
unpaid_months.append(month_labels[m])
|
|
||||||
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
|
||||||
else:
|
|
||||||
status = "unpaid"
|
|
||||||
cell_text = f"0/{fee_display}"
|
|
||||||
amount_to_pay = expected
|
|
||||||
if m < current_month:
|
|
||||||
unpaid_months.append(month_labels[m])
|
|
||||||
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
|
|
||||||
elif paid > 0:
|
|
||||||
status = "surplus"
|
|
||||||
cell_text = f"PAID {paid}"
|
|
||||||
|
|
||||||
if (isinstance(expected, int) and expected > 0) or paid > 0:
|
|
||||||
tooltip = f"Received: {paid}, Expected: {expected}"
|
|
||||||
else:
|
|
||||||
tooltip = ""
|
|
||||||
|
|
||||||
row["months"].append({
|
|
||||||
"text": cell_text,
|
|
||||||
"overridden": is_overridden,
|
|
||||||
"status": status,
|
|
||||||
"amount": amount_to_pay,
|
|
||||||
"month": month_labels[m],
|
|
||||||
"raw_month": m,
|
|
||||||
"tooltip": tooltip
|
|
||||||
})
|
|
||||||
|
|
||||||
# Balance = sum of (paid - expected) for past months only; current/future months ignored.
|
|
||||||
settled_balance = 0
|
|
||||||
for m, mdata in data["months"].items():
|
|
||||||
if m >= current_month:
|
|
||||||
continue
|
|
||||||
exp = mdata.get("expected", 0)
|
|
||||||
if isinstance(exp, int):
|
|
||||||
settled_balance += int(mdata.get("paid", 0)) - exp
|
|
||||||
|
|
||||||
payable_amount = max(0, -settled_balance)
|
|
||||||
row["unpaid_periods"] = ", ".join(unpaid_months)
|
|
||||||
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
|
|
||||||
row["balance"] = settled_balance
|
|
||||||
row["payable_amount"] = payable_amount
|
|
||||||
formatted_results.append(row)
|
|
||||||
|
|
||||||
formatted_totals = []
|
|
||||||
for m in sorted_months:
|
|
||||||
t = monthly_totals[m]
|
|
||||||
status = "empty"
|
|
||||||
if t["expected"] > 0 or t["paid"] > 0:
|
|
||||||
if t["paid"] == t["expected"]:
|
|
||||||
status = "ok"
|
|
||||||
elif t["paid"] < t["expected"]:
|
|
||||||
status = "unpaid"
|
|
||||||
else:
|
|
||||||
status = "surplus"
|
|
||||||
|
|
||||||
formatted_totals.append({
|
|
||||||
"text": f"{t['paid']} / {t['expected']} CZK",
|
|
||||||
"status": status
|
|
||||||
})
|
|
||||||
|
|
||||||
# Format credits and debts
|
|
||||||
def junior_settled_balance(name):
|
|
||||||
data = result["members"][name]
|
|
||||||
total = 0
|
|
||||||
for m, mdata in data["months"].items():
|
|
||||||
if m >= current_month:
|
|
||||||
continue
|
|
||||||
exp = mdata.get("expected", 0)
|
|
||||||
if isinstance(exp, int):
|
|
||||||
total += int(mdata.get("paid", 0)) - exp
|
|
||||||
return total
|
|
||||||
|
|
||||||
junior_all_names = [name for name, _, _ in adapted_members]
|
|
||||||
credits = sorted([{"name": n, "amount": junior_settled_balance(n)} for n in junior_all_names if junior_settled_balance(n) > 0], key=lambda x: x["name"])
|
|
||||||
debts = sorted([{"name": n, "amount": abs(junior_settled_balance(n))} for n in junior_all_names if junior_settled_balance(n) < 0], key=lambda x: x["name"])
|
|
||||||
unmatched = result["unmatched"]
|
|
||||||
raw_payments_by_person = group_payments_by_person(transactions, [name for name, _, _ in adapted_members])
|
|
||||||
import json
|
|
||||||
|
|
||||||
record_step("process_data")
|
|
||||||
|
|
||||||
return render_template(
|
|
||||||
"juniors.html",
|
|
||||||
months=[month_labels[m] for m in sorted_months],
|
|
||||||
raw_months=sorted_months,
|
|
||||||
results=formatted_results,
|
|
||||||
totals=formatted_totals,
|
|
||||||
member_data=json.dumps(result["members"]),
|
|
||||||
month_labels_json=json.dumps(month_labels),
|
|
||||||
raw_payments_json=json.dumps(raw_payments_by_person),
|
|
||||||
credits=credits,
|
|
||||||
debts=debts,
|
|
||||||
unmatched=unmatched,
|
|
||||||
attendance_url=attendance_url,
|
attendance_url=attendance_url,
|
||||||
payments_url=payments_url,
|
payments_url=payments_url,
|
||||||
bank_account=BANK_ACCOUNT,
|
bank_account=BANK_ACCOUNT,
|
||||||
current_month=current_month
|
|
||||||
)
|
)
|
||||||
|
record_step("process_data")
|
||||||
|
return render_template("juniors.html", **vm)
|
||||||
|
|
||||||
@app.route("/payments")
|
@app.route("/payments")
|
||||||
def payments():
|
def payments():
|
||||||
@@ -567,23 +231,13 @@ def payments():
|
|||||||
if juniors_data:
|
if juniors_data:
|
||||||
member_names.extend(name for name, _, _ in juniors_data[0])
|
member_names.extend(name for name, _, _ in juniors_data[0])
|
||||||
|
|
||||||
grouped = group_payments_by_person(transactions, member_names)
|
vm = build_payments_view_model(
|
||||||
# payments page also groups unmatched rows under a fallback key
|
transactions, member_names,
|
||||||
for tx in transactions:
|
|
||||||
if not str(tx.get("person", "")).strip():
|
|
||||||
grouped.setdefault("Unmatched / Unknown", []).append(tx)
|
|
||||||
for rows in grouped.values():
|
|
||||||
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
|
|
||||||
sorted_people = sorted(grouped.keys())
|
|
||||||
|
|
||||||
record_step("process_data")
|
|
||||||
return render_template(
|
|
||||||
"payments.html",
|
|
||||||
grouped_payments=grouped,
|
|
||||||
sorted_people=sorted_people,
|
|
||||||
attendance_url=attendance_url,
|
attendance_url=attendance_url,
|
||||||
payments_url=payments_url
|
payments_url=payments_url,
|
||||||
)
|
)
|
||||||
|
record_step("process_data")
|
||||||
|
return render_template("payments.html", **vm)
|
||||||
|
|
||||||
@app.route("/qr")
|
@app.route("/qr")
|
||||||
def qr_code():
|
def qr_code():
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ Goal: every external IO (Sheets, Drive, Fio, file cache) accessed through a narr
|
|||||||
|
|
||||||
Goal: byte-equal JSON between Python and Go for every route. This is the parity contract.
|
Goal: byte-equal JSON between Python and Go for every route. This is the parity contract.
|
||||||
|
|
||||||
- [ ] **M5.1** Hand-author Go structs for `/api/adults`, `/api/juniors`, `/api/payments`, `/api/version` with explicit `json:` tags matching Python keys; emit JSON Schemas via `github.com/invopop/jsonschema` to `tests/fixtures/api-schema/`
|
- [x] **M5.1** Hand-author Go structs for `/api/adults`, `/api/juniors`, `/api/payments`, `/api/version` with explicit `json:` tags matching Python keys; emit JSON Schemas via `github.com/invopop/jsonschema` to `tests/fixtures/api-schema/` — `f253e3f`
|
||||||
- [ ] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs
|
- [ ] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs
|
||||||
- [ ] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation
|
- [ ] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation
|
||||||
- [ ] **M5.4** Build `cmd/parity/main.go`: hits both backends' `/api/X`, normalizes allowlist (`render_time.total`, `build_meta`), prints `cmp.Diff`. Add `make parity` target
|
- [ ] **M5.4** Build `cmd/parity/main.go`: hits both backends' `/api/X`, normalizes allowlist (`render_time.total`, `build_meta`), prints `cmp.Diff`. Add `make parity` target
|
||||||
|
|||||||
185
docs/plans/2026-05-07-1431-m5-json-api-parity.md
Normal file
185
docs/plans/2026-05-07-1431-m5-json-api-parity.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# Python view-model cleanup (M5 prep — Python side only)
|
||||||
|
|
||||||
|
Scoped-down precursor to M5 of the Go rewrite. See:
|
||||||
|
- [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md)
|
||||||
|
- [2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The Python app is still production. Before adding `/api/X` shadow
|
||||||
|
routes, JSON DTOs, parity tooling, and Go handlers, get the Python
|
||||||
|
side into a clean shape. Today the `/adults` and `/juniors` routes
|
||||||
|
each carry 150–200 lines of inline view-model construction inside
|
||||||
|
the Flask handler:
|
||||||
|
|
||||||
|
- [adults_view](app.py#L179) — 167 LOC, computes per-row
|
||||||
|
status/cell_text/balance/credits/debts inline.
|
||||||
|
- [juniors_view](app.py#L347) — 205 LOC, same plus `"?"` sentinel
|
||||||
|
branching and `:NJ,MA` breakdown.
|
||||||
|
- [payments](app.py#L553) — 35 LOC, lighter but still mixes IO and
|
||||||
|
grouping.
|
||||||
|
|
||||||
|
Pulling that into pure builder functions:
|
||||||
|
- shrinks `app.py` and makes each route handler do only what a route
|
||||||
|
handler should (IO + cache + step timing + render);
|
||||||
|
- gives us **already-tested** pure functions that future M5 work can
|
||||||
|
call from a `/api/X` shadow endpoint with one line of `jsonify(...)`;
|
||||||
|
- has zero behavioural change (existing tests in
|
||||||
|
[test_app.py](tests/test_app.py) act as the regression guard).
|
||||||
|
|
||||||
|
This is a Python-only change. No Go work, no JSON contract, no shadow
|
||||||
|
routes — those come after.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
Three pure builder functions in a new `scripts/views.py` module. Each
|
||||||
|
takes already-loaded, deserialized inputs (no Flask globals, no IO,
|
||||||
|
no cache calls) and returns the exact dict that today's route passes
|
||||||
|
to `render_template`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def build_adults_view_model(
|
||||||
|
members, sorted_months, transactions, exceptions, current_month,
|
||||||
|
*, attendance_url, payments_url, bank_account,
|
||||||
|
) -> dict: ...
|
||||||
|
|
||||||
|
def build_juniors_view_model(
|
||||||
|
junior_members, sorted_months, transactions, exceptions, current_month,
|
||||||
|
*, attendance_url, payments_url, bank_account,
|
||||||
|
) -> dict: ...
|
||||||
|
|
||||||
|
def build_payments_view_model(
|
||||||
|
transactions, member_names,
|
||||||
|
*, attendance_url, payments_url,
|
||||||
|
) -> dict: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The route handlers shrink to: load data (with `get_cached_data` and
|
||||||
|
`record_step` calls staying in `app.py`), call the builder, render.
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.route("/adults")
|
||||||
|
def adults_view():
|
||||||
|
members, sorted_months = ... # cached loads + record_step calls
|
||||||
|
transactions = ...
|
||||||
|
exceptions = ...
|
||||||
|
result_meta = ...
|
||||||
|
record_step("process_data")
|
||||||
|
vm = build_adults_view_model(
|
||||||
|
members, sorted_months, transactions, exceptions,
|
||||||
|
datetime.now().strftime("%Y-%m"),
|
||||||
|
attendance_url=..., payments_url=..., bank_account=BANK_ACCOUNT,
|
||||||
|
)
|
||||||
|
return render_template("adults.html", **vm)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Pure builders, no Flask state.** No `record_step`, no `g.*`, no
|
||||||
|
`get_cached_data` inside builders. They take plain args, return
|
||||||
|
plain dicts. This is what makes them trivially unit-testable and,
|
||||||
|
later, trivially reusable from `/api/X`.
|
||||||
|
2. **Preserve byte-equal behaviour.** The dicts returned must match
|
||||||
|
today's `render_template(...)` kwargs key-for-key, value-for-value
|
||||||
|
— including the existing `json.dumps(member_data)` /
|
||||||
|
`json.dumps(month_labels_json)` / `json.dumps(raw_payments_json)`
|
||||||
|
wrappers. Those wrappers exist for inline JS in templates; the
|
||||||
|
refactor doesn't touch them. (When `/api/X` lands later, that route
|
||||||
|
will produce a sibling dict with the wrappers stripped, but that's
|
||||||
|
future work.)
|
||||||
|
3. **`reconcile()` stays inside the route, not the builder.** It
|
||||||
|
crosses the IO/cache boundary in spirit (its inputs come from
|
||||||
|
cache); but it's also pure-domain and called by both adults and
|
||||||
|
juniors. Keep the call site in the route so `record_step("reconcile")`
|
||||||
|
timing isn't lost. Builder takes `result` as an argument.
|
||||||
|
4. **Shared helpers stay in `app.py`** for now —
|
||||||
|
[get_month_labels](app.py#L41) and
|
||||||
|
[group_payments_by_person](app.py#L60) are already module-level
|
||||||
|
pure functions, used by routes and now by builders. Either leave
|
||||||
|
them in `app.py` and import into `scripts/views.py`, or move them
|
||||||
|
into `scripts/views.py`. **Choose: move into `scripts/views.py`**
|
||||||
|
— they're view-model concerns, not Flask concerns, and `app.py`
|
||||||
|
should keep shrinking.
|
||||||
|
5. **No new test file needed.** Existing
|
||||||
|
[tests/test_app.py](tests/test_app.py) tests
|
||||||
|
`test_adults_route`, `test_juniors_route`, `test_payments_route`
|
||||||
|
exercise the rendered HTML end-to-end. If they pass after the
|
||||||
|
refactor, behaviour is preserved. Adding builder-level unit tests
|
||||||
|
is a *nice-to-have* but not required for this iteration.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### 1. Create `scripts/views.py` with the three builders + shared helpers
|
||||||
|
|
||||||
|
- Move `get_month_labels` and `group_payments_by_person` from
|
||||||
|
[app.py:41–77](app.py#L41-L77) into `scripts/views.py`. Update the
|
||||||
|
import in `app.py`.
|
||||||
|
- Implement `build_adults_view_model` by extracting
|
||||||
|
[app.py:200–344](app.py#L200-L344) (everything between `result =
|
||||||
|
reconcile(...)` and `return render_template(...)`). Take `result`
|
||||||
|
as a parameter; emit the same dict that's currently passed as
|
||||||
|
`**kwargs` to `render_template`.
|
||||||
|
- Implement `build_juniors_view_model` by extracting
|
||||||
|
[app.py:370–550](app.py#L370-L550). Same shape — including the
|
||||||
|
`adapted_members` adapter loop, the junior `?`-sentinel branches,
|
||||||
|
and the `:NJ,MA` breakdown.
|
||||||
|
- Implement `build_payments_view_model` by extracting
|
||||||
|
[app.py:570–586](app.py#L570-L586) (the `group_payments_by_person`
|
||||||
|
call + `Unmatched / Unknown` bucket + sort).
|
||||||
|
|
||||||
|
### 2. Slim down the route handlers in `app.py`
|
||||||
|
|
||||||
|
Each handler keeps:
|
||||||
|
- `attendance_url`/`payments_url` URL building
|
||||||
|
- `get_cached_data` calls (with `record_step` between)
|
||||||
|
- `reconcile(...)` call (adults/juniors) with `record_step("reconcile")`
|
||||||
|
- `record_step("process_data")` after the builder call
|
||||||
|
- `return render_template("X.html", **view_model)`
|
||||||
|
|
||||||
|
Each handler **drops** all the per-row computation, totals
|
||||||
|
formatting, credits/debts sorting, `raw_payments_by_person` building,
|
||||||
|
and `import json` lines.
|
||||||
|
|
||||||
|
Target post-refactor LOC for each route handler: ~25–30 lines
|
||||||
|
(currently 167 / 205 / 35).
|
||||||
|
|
||||||
|
### 3. Run the existing test suite + manual smoke
|
||||||
|
|
||||||
|
```
|
||||||
|
make test # all tests green
|
||||||
|
make web-py # browse /adults /juniors /payments visually
|
||||||
|
```
|
||||||
|
|
||||||
|
## Critical files
|
||||||
|
|
||||||
|
- [app.py](app.py) — routes shrink dramatically; `get_month_labels`
|
||||||
|
and `group_payments_by_person` get moved out.
|
||||||
|
- New: `scripts/views.py` — three builders + the two helpers.
|
||||||
|
- [tests/test_app.py](tests/test_app.py) — unchanged; serves as the
|
||||||
|
regression guard.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `make test` — all four `TestWebApp` tests pass unchanged.
|
||||||
|
2. `make web-py` and visit `/adults`, `/juniors`, `/payments` — each
|
||||||
|
renders identically to before (same table contents, same totals,
|
||||||
|
same credits/debts, same `?` rendering on juniors).
|
||||||
|
3. `git diff app.py` shows substantial deletions (route bodies
|
||||||
|
shrink) and only thin glue calling the new builders.
|
||||||
|
4. Optional sanity check: temporarily add `print(repr(view_model))`
|
||||||
|
in the route before `render_template` on `main` and on the branch,
|
||||||
|
diff for one fixture run — should be byte-identical dicts.
|
||||||
|
|
||||||
|
## Out of scope (future iterations, not this plan)
|
||||||
|
|
||||||
|
- `/api/<route>` shadow endpoints in Flask
|
||||||
|
- Go `internal/web/api/` DTOs and JSON Schemas
|
||||||
|
- Go `internal/services/membership/views.go` aggregators
|
||||||
|
- Go HTTP handlers for `/api/X`
|
||||||
|
- `cmd/parity/main.go` and `make parity` target
|
||||||
|
- Junior breakdown sidecar work in
|
||||||
|
`go/internal/services/membership/sources.go`
|
||||||
|
|
||||||
|
These are the original M5 tasks (M5.1–M5.4 in the progress tracker).
|
||||||
|
They become much easier once today's refactor lands, because the
|
||||||
|
shadow `/api/X` routes will be one-liners over the new builders.
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
# M5.1 — Hand-author Go API structs + emit JSON Schemas
|
||||||
|
|
||||||
|
Companion to:
|
||||||
|
- [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md) (master design)
|
||||||
|
- [2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) (progress tracker — M5.1 row)
|
||||||
|
- [2026-05-07-1431-m5-json-api-parity.md](2026-05-07-1431-m5-json-api-parity.md) (Python view-model extraction prep — already merged as `b562ce3` / `32a16ff` / `59223c0`)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
M4 (IO layer behind interfaces) just landed. M5 is the JSON-parity contract phase — byte-equal JSON between Python and Go for `/api/adults`, `/api/juniors`, `/api/payments`, `/api/version`. Within M5, the work splits four ways:
|
||||||
|
|
||||||
|
- **M5.1 — this plan.** Define the wire contract: hand-authored Go structs with explicit `json:` tags matching Python keys, plus committed JSON Schemas generated by `github.com/invopop/jsonschema`. **Schemas only — no handlers, no Python `/api/X` routes, no parity tool.**
|
||||||
|
- M5.2 — Implement Go handlers that compose `services/*` results into these structs.
|
||||||
|
- M5.3 — Add Python `/api/X` shadow endpoints in [app.py](app.py).
|
||||||
|
- M5.4 — `cmd/parity/main.go` + `make parity` target.
|
||||||
|
|
||||||
|
The recent Python view-model extraction (`scripts/views.py`) lays the groundwork: every Python builder now returns a plain dict that an `/api/X` shadow can `jsonify` (with one minor unwrap step — see decision #1 below). M5.1 is the matching Go side: types and schemas that pin down the contract before any code writes JSON to a wire.
|
||||||
|
|
||||||
|
## Key design decisions
|
||||||
|
|
||||||
|
1. **Wire format is nested objects, not strings-of-JSON.** The Python view-model dicts contain three template-only fields that are pre-serialized JSON strings: `member_data`, `month_labels_json`, `raw_payments_json`. Those exist purely to feed inline `<script>` blocks in Jinja templates ([scripts/views.py:227-237](scripts/views.py#L227-L237)). The `/api/X` JSON contract uses the **un-stringified** nested form. Rationale:
|
||||||
|
- The master design doc references `total_balance`, `original_expected`, `attendance_count` as struct fields ([2026-05-03-2349-go-backend-rewrite.md:223-225](docs/plans/2026-05-03-2349-go-backend-rewrite.md#L223-L225)), which are *inside* `member_data` — only meaningful if it's a nested object.
|
||||||
|
- The `Expected` `MarshalJSON` design (emit `42` or `"?"`) ([same doc:233-238](docs/plans/2026-05-03-2349-go-backend-rewrite.md#L233-L238)) only fires inside `member_data`; pointless if that field is a string.
|
||||||
|
- Strings-of-JSON make `invopop/jsonschema` schemas useless for those fields (`{"type": "string"}` describes nothing).
|
||||||
|
- The Python prep plan ([2026-05-07-1431-m5-json-api-parity.md:87-89](docs/plans/2026-05-07-1431-m5-json-api-parity.md#L87-L89)) already anticipates this: "that route will produce a sibling dict with the wrappers stripped".
|
||||||
|
|
||||||
|
This refines (does not contradict) M5.3's "no transformation" wording. M5.3's `/api/X` will be `jsonify(unwrap_json_strings(view_model_dict))` — a 4-line shim, not real transformation logic.
|
||||||
|
|
||||||
|
2. **New package: `internal/web/api/`.** The Go side has no `api/` package today — only [internal/web/server.go](go/internal/web/server.go) with one hello route. The api package owns wire types and (in M5.2) handlers. Sub-files per route keep diffs small.
|
||||||
|
|
||||||
|
3. **Wire types are separate from `domain/reconcile.Result`.** [domain/reconcile/reconcile.go:88-92](go/internal/domain/reconcile/reconcile.go#L88-L92) defines `Result`, `MemberResult`, `MonthData`, `TxEntry`, etc. — none have `json:` tags. **Don't tag the domain types**: that bleeds wire concerns into pure logic and locks the JSON contract to internal field names. Define wire types in `internal/web/api/` and convert in M5.2's handlers.
|
||||||
|
|
||||||
|
4. **`Expected{Value int; Unknown bool}` with custom `MarshalJSON`/`UnmarshalJSON`** for junior `expected` and `original_expected`. Already prescribed by the master design.
|
||||||
|
|
||||||
|
5. **Transaction `amount` / `inferred_amount` may be `int` or `""`** (Sheets `UNFORMATTED_VALUE` returns empty string for blank cells per `scripts/match_payments.py` rows 250-260 in the views.py exploration). Use a custom `SheetsNumber` type with `MarshalJSON`/`UnmarshalJSON` that emits `0`/`null` for empty and the number otherwise. Document in code comment with a one-liner. Verify exact behavior by inspecting one or two existing scrubbed reconcile fixtures before coding.
|
||||||
|
|
||||||
|
6. **Schema generation lives in `internal/web/api/schemagen_test.go`** with `-update` flag, à la golden tests. Default test run (`go test ./internal/web/api/...`) re-generates schemas in memory and asserts byte-equality vs the committed files in `tests/fixtures/api-schema/`. `go test -update ./internal/web/api/...` rewrites the committed files. Avoids a separate `cmd/gen-api-schema` binary; CI catches drift automatically.
|
||||||
|
|
||||||
|
7. **One schema per route**, named to match the Go type:
|
||||||
|
```
|
||||||
|
go/tests/fixtures/api-schema/adults.schema.json
|
||||||
|
go/tests/fixtures/api-schema/juniors.schema.json
|
||||||
|
go/tests/fixtures/api-schema/payments.schema.json
|
||||||
|
go/tests/fixtures/api-schema/version.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Adults and Juniors are *not* the same type.** Their outer shapes match (same keys), but `MonthCell.Text` semantics differ (juniors render `"?"`, `"?(3)"`, `:NJ,MA` breakdowns) and `member_data` semantics differ (juniors carry `Expected` sentinel). Define `AdultsResponse` and `JuniorsResponse` separately even if they share most field types — clearer schemas and easier to evolve independently. Share scalar types (`Transaction`, `Exception`, `MonthCell`, `TotalCell`) in `types.go`.
|
||||||
|
|
||||||
|
9. **Money is integer CZK** ([master design:55-56](docs/plans/2026-05-03-2349-go-backend-rewrite.md#L55-L56)). All amount fields use `int`. The one exception: `member_data[name].months[YYYY-MM].paid` is a `float64` in the Python output (proportional allocation produces fractional CZK like `33.333333`). Use `float64` only there; document the why in a one-line comment.
|
||||||
|
|
||||||
|
## Files to create
|
||||||
|
|
||||||
|
```
|
||||||
|
go/internal/web/api/
|
||||||
|
├── types.go # SheetsNumber, Expected, Exception, Transaction, MonthCell, TotalCell, MemberRow
|
||||||
|
├── adults.go # AdultsResponse + adults-specific MemberData
|
||||||
|
├── juniors.go # JuniorsResponse + juniors-specific MemberData (with Expected fields)
|
||||||
|
├── payments.go # PaymentsResponse
|
||||||
|
├── version.go # VersionResponse
|
||||||
|
└── schemagen_test.go # generates + golden-asserts schemas
|
||||||
|
|
||||||
|
go/tests/fixtures/api-schema/
|
||||||
|
├── adults.schema.json
|
||||||
|
├── juniors.schema.json
|
||||||
|
├── payments.schema.json
|
||||||
|
└── version.schema.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Struct skeleton (illustrative, not final wording)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/web/api/types.go
|
||||||
|
|
||||||
|
// SheetsNumber wraps a value that may arrive from Google Sheets as either
|
||||||
|
// a JSON number or "" (empty string for blank cells). Marshals as 0 when
|
||||||
|
// missing; the consumer treats Missing+Value=0 as "no data".
|
||||||
|
type SheetsNumber struct {
|
||||||
|
Value float64
|
||||||
|
Missing bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expected carries a junior's expected fee or the "?" sentinel
|
||||||
|
// (single-attendance month requires manual review).
|
||||||
|
type Expected struct {
|
||||||
|
Value int
|
||||||
|
Unknown bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Exception struct {
|
||||||
|
Amount int `json:"amount"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Transaction struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Amount SheetsNumber `json:"amount"`
|
||||||
|
ManualFix string `json:"manual_fix"`
|
||||||
|
Person string `json:"person"`
|
||||||
|
Purpose string `json:"purpose"`
|
||||||
|
InferredAmount SheetsNumber `json:"inferred_amount"`
|
||||||
|
Sender string `json:"sender"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
BankID string `json:"bank_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MonthCell struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Overridden bool `json:"overridden"`
|
||||||
|
Status string `json:"status"` // "empty"|"ok"|"partial"|"unpaid"|"surplus"
|
||||||
|
Amount int `json:"amount"`
|
||||||
|
Month string `json:"month"` // display label, e.g. "Apr+May 2025"
|
||||||
|
RawMonth string `json:"raw_month"` // YYYY-MM
|
||||||
|
Tooltip string `json:"tooltip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TotalCell struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemberRow struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Months []MonthCell `json:"months"`
|
||||||
|
Balance int `json:"balance"`
|
||||||
|
UnpaidPeriods string `json:"unpaid_periods"`
|
||||||
|
RawUnpaidPeriods string `json:"raw_unpaid_periods"`
|
||||||
|
PayableAmount int `json:"payable_amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Credit struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Amount int `json:"amount"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/web/api/adults.go
|
||||||
|
type AdultsMonthData struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Expected int `json:"expected"`
|
||||||
|
Paid float64 `json:"paid"` // float — proportional allocator can produce 33.333…
|
||||||
|
Exception *Exception `json:"exception"`
|
||||||
|
AmountToPay int `json:"amount_to_pay"`
|
||||||
|
// ... transactions, others, etc — match exact keys from result["members"][name]["months"][YYYY-MM]
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdultsMemberData struct {
|
||||||
|
TotalBalance int `json:"total_balance"`
|
||||||
|
Months map[string]AdultsMonthData `json:"months"` // YYYY-MM key
|
||||||
|
Transactions []Transaction `json:"transactions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdultsResponse struct {
|
||||||
|
Months []string `json:"months"`
|
||||||
|
RawMonths []string `json:"raw_months"`
|
||||||
|
Results []MemberRow `json:"results"`
|
||||||
|
Totals []TotalCell `json:"totals"`
|
||||||
|
MemberData map[string]AdultsMemberData `json:"member_data"`
|
||||||
|
MonthLabels map[string]string `json:"month_labels"` // was month_labels_json (string)
|
||||||
|
RawPayments map[string][]Transaction `json:"raw_payments"` // was raw_payments_json (string)
|
||||||
|
Credits []Credit `json:"credits"`
|
||||||
|
Debts []Credit `json:"debts"`
|
||||||
|
Unmatched []Transaction `json:"unmatched"`
|
||||||
|
AttendanceURL string `json:"attendance_url"`
|
||||||
|
PaymentsURL string `json:"payments_url"`
|
||||||
|
BankAccount string `json:"bank_account"`
|
||||||
|
CurrentMonth string `json:"current_month"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`JuniorsResponse` mirrors `AdultsResponse` but the inner `MonthData` carries `Expected` and `OriginalExpected` (both `Expected` type), and adds the `:NJ,MA` breakdown fields produced by [scripts/views.py:290-298](scripts/views.py#L290-L298).
|
||||||
|
|
||||||
|
`PaymentsResponse`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type PaymentsResponse struct {
|
||||||
|
GroupedPayments map[string][]Transaction `json:"grouped_payments"`
|
||||||
|
SortedPeople []string `json:"sorted_people"`
|
||||||
|
AttendanceURL string `json:"attendance_url"`
|
||||||
|
PaymentsURL string `json:"payments_url"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`VersionResponse` mirrors Python's `BUILD_META` ([app.py:67](app.py#L67)):
|
||||||
|
|
||||||
|
```go
|
||||||
|
type VersionResponse struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
BuildDate string `json:"build_date"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Exact fields inside the per-month `MonthData`/`MemberData` will be finalized by inspecting **one** scrubbed `member_data` JSON dump from a current `/adults` and `/juniors` call (see Verification step 1) — names and types have to be identical.
|
||||||
|
|
||||||
|
## Reusable existing code
|
||||||
|
|
||||||
|
- `domain/reconcile.Result` ([go/internal/domain/reconcile/reconcile.go:88](go/internal/domain/reconcile/reconcile.go#L88)) — source data for adults/juniors. M5.2 maps it to wire types; M5.1 only needs to know the field set.
|
||||||
|
- `web.BuildInfo` ([go/internal/web/server.go:11-15](go/internal/web/server.go#L11-L15)) — already wired through `cmd/fuj/main.go:79`. The `VersionResponse` type is a thin renaming of this with json tags. Decide in M5.2 whether to *also* tag `BuildInfo` directly (it's not really domain) or keep `VersionResponse` separate. M5.1 just defines the wire struct.
|
||||||
|
- `scripts/views.py` builders — the spec for every key/type. Treat as authoritative.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
1. **Add `github.com/invopop/jsonschema` dependency.** `cd go && go get github.com/invopop/jsonschema && go mod tidy`. Single commit.
|
||||||
|
2. **Capture one fresh `member_data` dump for adults and juniors** to pin down inner-dict field names and types precisely (especially `paid` precision, `transactions[]` shape, `others[]` if present). Run `make web-py` on a known-good cache, hit `/adults` and `/juniors`, dump `view_model["member_data"]` to a scratch file (gitignored), and inspect. **Do not commit raw dumps** — PII rule. This is for shape inspection only.
|
||||||
|
3. **Author `internal/web/api/types.go`** with `SheetsNumber`, `Expected`, `Exception`, `Transaction`, `MonthCell`, `TotalCell`, `MemberRow`, `Credit`. Implement `MarshalJSON`/`UnmarshalJSON` for `SheetsNumber` and `Expected`. Use struct tags `json:"snake_case_key"` matching Python exactly.
|
||||||
|
4. **Author `internal/web/api/adults.go`** with `AdultsMonthData`, `AdultsMemberData`, `AdultsResponse`. Cross-check every key against the dump from step 2.
|
||||||
|
5. **Author `internal/web/api/juniors.go`** similarly, with `Expected` fields and the J/A breakdown.
|
||||||
|
6. **Author `internal/web/api/payments.go` and `version.go`** (small).
|
||||||
|
7. **Author `internal/web/api/schemagen_test.go`** that:
|
||||||
|
- Imports `github.com/invopop/jsonschema`.
|
||||||
|
- Defines a `schemaCases` slice: `{name: "adults", typ: AdultsResponse{}, ...}` etc.
|
||||||
|
- For each case, generates a schema, marshals indented JSON, compares against committed file at `../../tests/fixtures/api-schema/<name>.schema.json`.
|
||||||
|
- Honors a `-update` flag (`flag.Bool`) that rewrites the committed file instead of asserting.
|
||||||
|
- One `t.Run(case.name, ...)` per route for clear failure output.
|
||||||
|
8. **Run with `-update` once to populate** `tests/fixtures/api-schema/*.schema.json`. Eyeball each schema: required fields list, oneOf for `Expected`, additionalProperties on map types. Commit.
|
||||||
|
9. **Lint + test:** `cd go && go vet ./... && make go-test && make go-lint`. Fix any issues. (Expect zero — this is read-only data structures.)
|
||||||
|
10. **CHANGELOG entry** + tick `M5.1` in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:100](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L100) with the merge SHA.
|
||||||
|
11. **Branch + MR per CLAUDE.md workflow:** branch `feat/go-m5-1-api-structs`, push with `-u`, open MR via `tea pr create`. Do not merge from CLI.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `cd go && go test ./internal/web/api/...` — **schemas regenerate identically** to committed files.
|
||||||
|
2. `cd go && go test ./internal/web/api/... -update` then `git diff go/tests/fixtures/api-schema/` — diff is empty (idempotent generation).
|
||||||
|
3. `cd go && make go-lint` — clean.
|
||||||
|
4. `cd go && go vet ./...` — clean.
|
||||||
|
5. Manual schema inspection: open `adults.schema.json`, confirm:
|
||||||
|
- Top-level `required` list contains every Python key.
|
||||||
|
- `member_data` is `additionalProperties: { ... AdultsMemberData ... }` (a map keyed by name).
|
||||||
|
- `expected` and `original_expected` (juniors only) are `oneOf: [{type: integer}, {const: "?"}]`.
|
||||||
|
- `amount` and `inferred_amount` on `Transaction` accept number or empty/null.
|
||||||
|
6. **No production code paths exercised yet** — handlers come in M5.2. Compile-time success + schema golden-test = M5.1 done.
|
||||||
|
|
||||||
|
## Out of scope (later M5 tasks)
|
||||||
|
|
||||||
|
- Wiring Go HTTP handlers for `/api/X` (M5.2).
|
||||||
|
- Adding Python `/api/X` shadow endpoints (M5.3) — including the `unwrap_json_strings(view_model)` shim noted in design decision #1.
|
||||||
|
- `cmd/parity/main.go` and `make parity` target (M5.4).
|
||||||
|
- Tagging `domain/reconcile.Result` with `json:` tags — explicitly avoided.
|
||||||
|
- Refactoring the strings-of-JSON fields out of the Python view-model — they stay in `views.py` for the template path, the `/api/X` shadow unwraps them.
|
||||||
@@ -3,6 +3,7 @@ module fuj-management/go
|
|||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/invopop/jsonschema v0.14.0
|
||||||
golang.org/x/net v0.53.0
|
golang.org/x/net v0.53.0
|
||||||
golang.org/x/text v0.36.0
|
golang.org/x/text v0.36.0
|
||||||
google.golang.org/api v0.278.0
|
google.golang.org/api v0.278.0
|
||||||
@@ -12,6 +13,8 @@ require (
|
|||||||
cloud.google.com/go/auth v0.20.0 // indirect
|
cloud.google.com/go/auth v0.20.0 // indirect
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||||
|
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
|
github.com/buger/jsonparser v1.1.2 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
@@ -20,11 +23,13 @@ require (
|
|||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect
|
github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect
|
||||||
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
|
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
|
||||||
|
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||||
|
go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect
|
||||||
golang.org/x/crypto v0.50.0 // indirect
|
golang.org/x/crypto v0.50.0 // indirect
|
||||||
golang.org/x/oauth2 v0.36.0 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
|
|||||||
10
go/go.sum
10
go/go.sum
@@ -4,6 +4,10 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
|
|||||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||||
|
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||||
|
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||||
|
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
|
||||||
|
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
@@ -27,6 +31,10 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5Ugt
|
|||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||||
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
|
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
|
||||||
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
|
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
|
||||||
|
github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg=
|
||||||
|
github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I=
|
||||||
|
github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY=
|
||||||
|
github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
@@ -45,6 +53,8 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC
|
|||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||||
|
go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s=
|
||||||
|
go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
|
|||||||
42
go/internal/web/api/adults.go
Normal file
42
go/internal/web/api/adults.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// AdultsMonthData is the reconciled ledger for one adult member in one month.
|
||||||
|
// Keys match Python's result["members"][name]["months"][YYYY-MM].
|
||||||
|
type AdultsMonthData struct {
|
||||||
|
Expected int `json:"expected"`
|
||||||
|
OriginalExpected int `json:"original_expected"`
|
||||||
|
AttendanceCount int `json:"attendance_count"`
|
||||||
|
Exception *ExceptionData `json:"exception"`
|
||||||
|
Paid float64 `json:"paid"` // float: proportional allocator may produce fractional CZK
|
||||||
|
Transactions []MemberTxEntry `json:"transactions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdultsMemberData is the reconciled ledger for one adult member.
|
||||||
|
// Keys match Python's result["members"][name].
|
||||||
|
type AdultsMemberData struct {
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
Months map[string]AdultsMonthData `json:"months"` // YYYY-MM → month data
|
||||||
|
OtherTransactions []MemberOtherEntry `json:"other_transactions"`
|
||||||
|
TotalBalance int `json:"total_balance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdultsResponse is the JSON contract for GET /api/adults.
|
||||||
|
// MemberData, MonthLabels, and RawPayments correspond to the Python view-model
|
||||||
|
// fields member_data, month_labels_json, and raw_payments_json respectively,
|
||||||
|
// but as nested objects rather than pre-serialised JSON strings.
|
||||||
|
type AdultsResponse struct {
|
||||||
|
Months []string `json:"months"` // display labels
|
||||||
|
RawMonths []string `json:"raw_months"` // "YYYY-MM"
|
||||||
|
Results []MemberRow `json:"results"`
|
||||||
|
Totals []TotalCell `json:"totals"`
|
||||||
|
MemberData map[string]AdultsMemberData `json:"member_data"` // name → ledger
|
||||||
|
MonthLabels map[string]string `json:"month_labels"` // YYYY-MM → display label
|
||||||
|
RawPayments map[string][]RawTransaction `json:"raw_payments"` // name → raw sheet rows
|
||||||
|
Credits []Credit `json:"credits"`
|
||||||
|
Debts []Credit `json:"debts"`
|
||||||
|
Unmatched []RawTransaction `json:"unmatched"`
|
||||||
|
AttendanceURL string `json:"attendance_url"`
|
||||||
|
PaymentsURL string `json:"payments_url"`
|
||||||
|
BankAccount string `json:"bank_account"`
|
||||||
|
CurrentMonth string `json:"current_month"`
|
||||||
|
}
|
||||||
41
go/internal/web/api/juniors.go
Normal file
41
go/internal/web/api/juniors.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// JuniorsMonthData is the reconciled ledger for one junior member in one month.
|
||||||
|
// expected and original_expected may be the "?" sentinel (single-attendance month
|
||||||
|
// requiring manual review); they are carried via the Expected type.
|
||||||
|
type JuniorsMonthData struct {
|
||||||
|
Expected Expected `json:"expected"`
|
||||||
|
OriginalExpected Expected `json:"original_expected"`
|
||||||
|
AttendanceCount int `json:"attendance_count"`
|
||||||
|
Exception *ExceptionData `json:"exception"`
|
||||||
|
Paid float64 `json:"paid"`
|
||||||
|
Transactions []MemberTxEntry `json:"transactions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JuniorsMemberData is the reconciled ledger for one junior member.
|
||||||
|
type JuniorsMemberData struct {
|
||||||
|
Tier string `json:"tier"`
|
||||||
|
Months map[string]JuniorsMonthData `json:"months"`
|
||||||
|
OtherTransactions []MemberOtherEntry `json:"other_transactions"`
|
||||||
|
TotalBalance int `json:"total_balance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JuniorsResponse is the JSON contract for GET /api/juniors.
|
||||||
|
// Same outer shape as AdultsResponse; differs in that member_data carries
|
||||||
|
// Expected (int or "?") for expected/original_expected fields.
|
||||||
|
type JuniorsResponse struct {
|
||||||
|
Months []string `json:"months"`
|
||||||
|
RawMonths []string `json:"raw_months"`
|
||||||
|
Results []MemberRow `json:"results"`
|
||||||
|
Totals []TotalCell `json:"totals"`
|
||||||
|
MemberData map[string]JuniorsMemberData `json:"member_data"`
|
||||||
|
MonthLabels map[string]string `json:"month_labels"`
|
||||||
|
RawPayments map[string][]RawTransaction `json:"raw_payments"`
|
||||||
|
Credits []Credit `json:"credits"`
|
||||||
|
Debts []Credit `json:"debts"`
|
||||||
|
Unmatched []RawTransaction `json:"unmatched"`
|
||||||
|
AttendanceURL string `json:"attendance_url"`
|
||||||
|
PaymentsURL string `json:"payments_url"`
|
||||||
|
BankAccount string `json:"bank_account"`
|
||||||
|
CurrentMonth string `json:"current_month"`
|
||||||
|
}
|
||||||
9
go/internal/web/api/payments.go
Normal file
9
go/internal/web/api/payments.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// PaymentsResponse is the JSON contract for GET /api/payments.
|
||||||
|
type PaymentsResponse struct {
|
||||||
|
GroupedPayments map[string][]RawTransaction `json:"grouped_payments"` // person name → rows
|
||||||
|
SortedPeople []string `json:"sorted_people"`
|
||||||
|
AttendanceURL string `json:"attendance_url"`
|
||||||
|
PaymentsURL string `json:"payments_url"`
|
||||||
|
}
|
||||||
81
go/internal/web/api/schemagen_test.go
Normal file
81
go/internal/web/api/schemagen_test.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// schemagen_test.go generates and golden-compares JSON Schema files for every
|
||||||
|
// /api/X response type.
|
||||||
|
//
|
||||||
|
// Normal run (CI): go test ./internal/web/api/... — asserts schemas match committed files.
|
||||||
|
// Regenerate: go test -run TestGenerateSchemas -update ./internal/web/api/...
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/invopop/jsonschema"
|
||||||
|
)
|
||||||
|
|
||||||
|
var updateFlag = flag.Bool("update", false, "overwrite api-schema fixture files with freshly generated schemas")
|
||||||
|
|
||||||
|
// JSONSchema makes Expected self-describing for the reflector at test time.
|
||||||
|
// The method is in a test file and is not compiled into production binaries.
|
||||||
|
// It emits oneOf [integer, "?"] to match the custom MarshalJSON behaviour.
|
||||||
|
func (Expected) JSONSchema() *jsonschema.Schema {
|
||||||
|
return &jsonschema.Schema{
|
||||||
|
OneOf: []*jsonschema.Schema{
|
||||||
|
{Type: "integer"},
|
||||||
|
{Enum: []any{"?"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSchemas(t *testing.T) {
|
||||||
|
r := &jsonschema.Reflector{
|
||||||
|
AllowAdditionalProperties: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
val any
|
||||||
|
}{
|
||||||
|
{"adults", &AdultsResponse{}},
|
||||||
|
{"juniors", &JuniorsResponse{}},
|
||||||
|
{"payments", &PaymentsResponse{}},
|
||||||
|
{"version", &VersionResponse{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
schema := r.Reflect(tc.val)
|
||||||
|
|
||||||
|
got, err := json.MarshalIndent(schema, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal schema: %v", err)
|
||||||
|
}
|
||||||
|
got = append(got, '\n')
|
||||||
|
|
||||||
|
// Path: go/internal/web/api/ → ../../.. → go/ → tests/fixtures/api-schema/
|
||||||
|
path := filepath.Join("..", "..", "..", "tests", "fixtures", "api-schema", tc.name+".schema.json")
|
||||||
|
|
||||||
|
if *updateFlag {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, got, 0o644); err != nil {
|
||||||
|
t.Fatalf("write schema: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("wrote %s", path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
want, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture %s: %v (re-run with -update to generate)", path, err)
|
||||||
|
}
|
||||||
|
if string(got) != string(want) {
|
||||||
|
t.Errorf("schema mismatch for %s; re-run with -update to regenerate", tc.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
122
go/internal/web/api/types.go
Normal file
122
go/internal/web/api/types.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
// Package api defines wire types for the JSON API contract (/api/...).
|
||||||
|
// These structs have explicit json: tags matching the Python view-model dict
|
||||||
|
// keys so that M5 parity tests can do byte-equal comparison between backends.
|
||||||
|
//
|
||||||
|
// The three Python template-only JSON-string fields (member_data,
|
||||||
|
// month_labels_json, raw_payments_json) are represented here as nested objects;
|
||||||
|
// the Python /api/X shadow endpoint strips the json.dumps wrappers before
|
||||||
|
// serialising.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Expected holds a junior fee expectation: either a concrete integer or the
|
||||||
|
// "?" sentinel (single-attendance month requiring manual review).
|
||||||
|
// MarshalJSON emits the integer or the JSON string "?".
|
||||||
|
type Expected struct {
|
||||||
|
Value int
|
||||||
|
Unknown bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Expected) MarshalJSON() ([]byte, error) {
|
||||||
|
if e.Unknown {
|
||||||
|
return []byte(`"?"`), nil
|
||||||
|
}
|
||||||
|
return json.Marshal(e.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Expected) UnmarshalJSON(data []byte) error {
|
||||||
|
if len(data) > 0 && data[0] == '"' {
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s == "?" {
|
||||||
|
e.Unknown = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("api.Expected: unexpected string %q", s)
|
||||||
|
}
|
||||||
|
e.Unknown = false
|
||||||
|
return json.Unmarshal(data, &e.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExceptionData is a manual fee override for one member in one month.
|
||||||
|
type ExceptionData struct {
|
||||||
|
Amount int `json:"amount"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemberTxEntry is one payment allocation to a member+month, as stored in
|
||||||
|
// member_data.months[YYYY-MM].transactions.
|
||||||
|
type MemberTxEntry struct {
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
Sender string `json:"sender"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Confidence string `json:"confidence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemberOtherEntry is an "other:…" purpose payment allocated to a member.
|
||||||
|
type MemberOtherEntry struct {
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
Sender string `json:"sender"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Purpose string `json:"purpose"`
|
||||||
|
Confidence string `json:"confidence"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawTransaction is a full payments-sheet row.
|
||||||
|
// Used for unmatched transactions and raw_payments groupings.
|
||||||
|
// Columns match the sheet layout: Date|Amount|manual fix|Person|Purpose|
|
||||||
|
// Inferred Amount|Sender|VS|Message|Bank ID|Sync ID.
|
||||||
|
type RawTransaction struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Amount float64 `json:"amount"`
|
||||||
|
ManualFix string `json:"manual_fix"`
|
||||||
|
Person string `json:"person"`
|
||||||
|
Purpose string `json:"purpose"`
|
||||||
|
InferredAmount float64 `json:"inferred_amount"`
|
||||||
|
Sender string `json:"sender"`
|
||||||
|
VS string `json:"vs"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
BankID string `json:"bank_id"`
|
||||||
|
SyncID string `json:"sync_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonthCell is one cell in a member's month column on the dashboard.
|
||||||
|
type MonthCell struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Overridden bool `json:"overridden"`
|
||||||
|
Status string `json:"status"` // "empty"|"ok"|"partial"|"unpaid"|"surplus"
|
||||||
|
Amount int `json:"amount"`
|
||||||
|
Month string `json:"month"` // display label, e.g. "Apr+May 2025"
|
||||||
|
RawMonth string `json:"raw_month"` // "YYYY-MM"
|
||||||
|
Tooltip string `json:"tooltip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TotalCell is one cell in the monthly totals row.
|
||||||
|
type TotalCell struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemberRow is one member's summary row in the dashboard results table.
|
||||||
|
type MemberRow struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Months []MonthCell `json:"months"`
|
||||||
|
Balance int `json:"balance"`
|
||||||
|
UnpaidPeriods string `json:"unpaid_periods"`
|
||||||
|
RawUnpaidPeriods string `json:"raw_unpaid_periods"`
|
||||||
|
PayableAmount int `json:"payable_amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credit is one entry in the credits or debts lists.
|
||||||
|
type Credit struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Amount int `json:"amount"`
|
||||||
|
}
|
||||||
9
go/internal/web/api/version.go
Normal file
9
go/internal/web/api/version.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// VersionResponse is the JSON contract for GET /api/version.
|
||||||
|
// Keys match Python's BUILD_META dict (see app.py).
|
||||||
|
type VersionResponse struct {
|
||||||
|
Tag string `json:"tag"`
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
BuildDate string `json:"build_date"`
|
||||||
|
}
|
||||||
399
go/tests/fixtures/api-schema/adults.schema.json
vendored
Normal file
399
go/tests/fixtures/api-schema/adults.schema.json
vendored
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$ref": "#/$defs/AdultsResponse",
|
||||||
|
"$defs": {
|
||||||
|
"AdultsMemberData": {
|
||||||
|
"properties": {
|
||||||
|
"tier": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"months": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"$ref": "#/$defs/AdultsMonthData"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"other_transactions": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/MemberOtherEntry"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"total_balance": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"tier",
|
||||||
|
"months",
|
||||||
|
"other_transactions",
|
||||||
|
"total_balance"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"AdultsMonthData": {
|
||||||
|
"properties": {
|
||||||
|
"expected": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"original_expected": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"attendance_count": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"exception": {
|
||||||
|
"$ref": "#/$defs/ExceptionData"
|
||||||
|
},
|
||||||
|
"paid": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"transactions": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/MemberTxEntry"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"expected",
|
||||||
|
"original_expected",
|
||||||
|
"attendance_count",
|
||||||
|
"exception",
|
||||||
|
"paid",
|
||||||
|
"transactions"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"AdultsResponse": {
|
||||||
|
"properties": {
|
||||||
|
"months": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"raw_months": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"results": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/MemberRow"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"totals": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/TotalCell"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"member_data": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"$ref": "#/$defs/AdultsMemberData"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"month_labels": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"raw_payments": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/RawTransaction"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"credits": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/Credit"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"debts": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/Credit"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"unmatched": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/RawTransaction"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"attendance_url": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"payments_url": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"bank_account": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"current_month": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"months",
|
||||||
|
"raw_months",
|
||||||
|
"results",
|
||||||
|
"totals",
|
||||||
|
"member_data",
|
||||||
|
"month_labels",
|
||||||
|
"raw_payments",
|
||||||
|
"credits",
|
||||||
|
"debts",
|
||||||
|
"unmatched",
|
||||||
|
"attendance_url",
|
||||||
|
"payments_url",
|
||||||
|
"bank_account",
|
||||||
|
"current_month"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Credit": {
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"amount"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ExceptionData": {
|
||||||
|
"properties": {
|
||||||
|
"amount": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"note": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"amount",
|
||||||
|
"note"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"MemberOtherEntry": {
|
||||||
|
"properties": {
|
||||||
|
"amount": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sender": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"purpose": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"amount",
|
||||||
|
"date",
|
||||||
|
"sender",
|
||||||
|
"message",
|
||||||
|
"purpose",
|
||||||
|
"confidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"MemberRow": {
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"months": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/MonthCell"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"balance": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"unpaid_periods": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"raw_unpaid_periods": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"payable_amount": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"months",
|
||||||
|
"balance",
|
||||||
|
"unpaid_periods",
|
||||||
|
"raw_unpaid_periods",
|
||||||
|
"payable_amount"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"MemberTxEntry": {
|
||||||
|
"properties": {
|
||||||
|
"amount": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sender": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"amount",
|
||||||
|
"date",
|
||||||
|
"sender",
|
||||||
|
"message",
|
||||||
|
"confidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"MonthCell": {
|
||||||
|
"properties": {
|
||||||
|
"text": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"overridden": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"month": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"raw_month": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"text",
|
||||||
|
"overridden",
|
||||||
|
"status",
|
||||||
|
"amount",
|
||||||
|
"month",
|
||||||
|
"raw_month",
|
||||||
|
"tooltip"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"RawTransaction": {
|
||||||
|
"properties": {
|
||||||
|
"date": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"manual_fix": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"person": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"purpose": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"inferred_amount": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"sender": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"vs": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"bank_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sync_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"date",
|
||||||
|
"amount",
|
||||||
|
"manual_fix",
|
||||||
|
"person",
|
||||||
|
"purpose",
|
||||||
|
"inferred_amount",
|
||||||
|
"sender",
|
||||||
|
"vs",
|
||||||
|
"message",
|
||||||
|
"bank_id",
|
||||||
|
"sync_id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"TotalCell": {
|
||||||
|
"properties": {
|
||||||
|
"text": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"text",
|
||||||
|
"status"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
411
go/tests/fixtures/api-schema/juniors.schema.json
vendored
Normal file
411
go/tests/fixtures/api-schema/juniors.schema.json
vendored
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$ref": "#/$defs/JuniorsResponse",
|
||||||
|
"$defs": {
|
||||||
|
"Credit": {
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"amount"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ExceptionData": {
|
||||||
|
"properties": {
|
||||||
|
"amount": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"note": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"amount",
|
||||||
|
"note"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Expected": {
|
||||||
|
"oneOf": [
|
||||||
|
{
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"?"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"JuniorsMemberData": {
|
||||||
|
"properties": {
|
||||||
|
"tier": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"months": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"$ref": "#/$defs/JuniorsMonthData"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"other_transactions": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/MemberOtherEntry"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"total_balance": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"tier",
|
||||||
|
"months",
|
||||||
|
"other_transactions",
|
||||||
|
"total_balance"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"JuniorsMonthData": {
|
||||||
|
"properties": {
|
||||||
|
"expected": {
|
||||||
|
"$ref": "#/$defs/Expected"
|
||||||
|
},
|
||||||
|
"original_expected": {
|
||||||
|
"$ref": "#/$defs/Expected"
|
||||||
|
},
|
||||||
|
"attendance_count": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"exception": {
|
||||||
|
"$ref": "#/$defs/ExceptionData"
|
||||||
|
},
|
||||||
|
"paid": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"transactions": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/MemberTxEntry"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"expected",
|
||||||
|
"original_expected",
|
||||||
|
"attendance_count",
|
||||||
|
"exception",
|
||||||
|
"paid",
|
||||||
|
"transactions"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"JuniorsResponse": {
|
||||||
|
"properties": {
|
||||||
|
"months": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"raw_months": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"results": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/MemberRow"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"totals": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/TotalCell"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"member_data": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"$ref": "#/$defs/JuniorsMemberData"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"month_labels": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"raw_payments": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/RawTransaction"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"credits": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/Credit"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"debts": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/Credit"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"unmatched": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/RawTransaction"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"attendance_url": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"payments_url": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"bank_account": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"current_month": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"months",
|
||||||
|
"raw_months",
|
||||||
|
"results",
|
||||||
|
"totals",
|
||||||
|
"member_data",
|
||||||
|
"month_labels",
|
||||||
|
"raw_payments",
|
||||||
|
"credits",
|
||||||
|
"debts",
|
||||||
|
"unmatched",
|
||||||
|
"attendance_url",
|
||||||
|
"payments_url",
|
||||||
|
"bank_account",
|
||||||
|
"current_month"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"MemberOtherEntry": {
|
||||||
|
"properties": {
|
||||||
|
"amount": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sender": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"purpose": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"amount",
|
||||||
|
"date",
|
||||||
|
"sender",
|
||||||
|
"message",
|
||||||
|
"purpose",
|
||||||
|
"confidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"MemberRow": {
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"months": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/MonthCell"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"balance": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"unpaid_periods": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"raw_unpaid_periods": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"payable_amount": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"months",
|
||||||
|
"balance",
|
||||||
|
"unpaid_periods",
|
||||||
|
"raw_unpaid_periods",
|
||||||
|
"payable_amount"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"MemberTxEntry": {
|
||||||
|
"properties": {
|
||||||
|
"amount": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sender": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"amount",
|
||||||
|
"date",
|
||||||
|
"sender",
|
||||||
|
"message",
|
||||||
|
"confidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"MonthCell": {
|
||||||
|
"properties": {
|
||||||
|
"text": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"overridden": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"month": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"raw_month": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"text",
|
||||||
|
"overridden",
|
||||||
|
"status",
|
||||||
|
"amount",
|
||||||
|
"month",
|
||||||
|
"raw_month",
|
||||||
|
"tooltip"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"RawTransaction": {
|
||||||
|
"properties": {
|
||||||
|
"date": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"manual_fix": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"person": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"purpose": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"inferred_amount": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"sender": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"vs": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"bank_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sync_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"date",
|
||||||
|
"amount",
|
||||||
|
"manual_fix",
|
||||||
|
"person",
|
||||||
|
"purpose",
|
||||||
|
"inferred_amount",
|
||||||
|
"sender",
|
||||||
|
"vs",
|
||||||
|
"message",
|
||||||
|
"bank_id",
|
||||||
|
"sync_id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"TotalCell": {
|
||||||
|
"properties": {
|
||||||
|
"text": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"text",
|
||||||
|
"status"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
go/tests/fixtures/api-schema/payments.schema.json
vendored
Normal file
91
go/tests/fixtures/api-schema/payments.schema.json
vendored
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$ref": "#/$defs/PaymentsResponse",
|
||||||
|
"$defs": {
|
||||||
|
"PaymentsResponse": {
|
||||||
|
"properties": {
|
||||||
|
"grouped_payments": {
|
||||||
|
"additionalProperties": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/RawTransaction"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"sorted_people": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
|
"attendance_url": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"payments_url": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"grouped_payments",
|
||||||
|
"sorted_people",
|
||||||
|
"attendance_url",
|
||||||
|
"payments_url"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"RawTransaction": {
|
||||||
|
"properties": {
|
||||||
|
"date": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"amount": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"manual_fix": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"person": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"purpose": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"inferred_amount": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"sender": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"vs": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"bank_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sync_id": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"date",
|
||||||
|
"amount",
|
||||||
|
"manual_fix",
|
||||||
|
"person",
|
||||||
|
"purpose",
|
||||||
|
"inferred_amount",
|
||||||
|
"sender",
|
||||||
|
"vs",
|
||||||
|
"message",
|
||||||
|
"bank_id",
|
||||||
|
"sync_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
26
go/tests/fixtures/api-schema/version.schema.json
vendored
Normal file
26
go/tests/fixtures/api-schema/version.schema.json
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$ref": "#/$defs/VersionResponse",
|
||||||
|
"$defs": {
|
||||||
|
"VersionResponse": {
|
||||||
|
"properties": {
|
||||||
|
"tag": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"commit": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"build_date": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"tag",
|
||||||
|
"commit",
|
||||||
|
"build_date"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
445
scripts/views.py
Normal file
445
scripts/views.py
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from attendance import ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS
|
||||||
|
from match_payments import canonical_member_key
|
||||||
|
|
||||||
|
|
||||||
|
def get_month_labels(sorted_months, merged_months):
|
||||||
|
labels = {}
|
||||||
|
for m in sorted_months:
|
||||||
|
dt = datetime.strptime(m, "%Y-%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}
|
||||||
|
if len(years) > 1:
|
||||||
|
parts = [d.strftime("%b %Y") for d in all_dts]
|
||||||
|
labels[m] = "+".join(parts)
|
||||||
|
else:
|
||||||
|
parts = [d.strftime("%b") for d in all_dts]
|
||||||
|
labels[m] = f"{'+'.join(parts)} {dt.strftime('%Y')}"
|
||||||
|
else:
|
||||||
|
labels[m] = dt.strftime("%b %Y")
|
||||||
|
return labels
|
||||||
|
|
||||||
|
|
||||||
|
def group_payments_by_person(transactions, member_names=None):
|
||||||
|
canonical_by_key = (
|
||||||
|
{canonical_member_key(n): n for n in member_names} if member_names else {}
|
||||||
|
)
|
||||||
|
grouped = {}
|
||||||
|
for tx in transactions:
|
||||||
|
person = str(tx.get("person", "")).strip()
|
||||||
|
if not person:
|
||||||
|
continue
|
||||||
|
for p in person.split(","):
|
||||||
|
p = re.sub(r"\[\?\]\s*", "", p).strip()
|
||||||
|
if not p:
|
||||||
|
continue
|
||||||
|
key = canonical_by_key.get(canonical_member_key(p), p)
|
||||||
|
grouped.setdefault(key, []).append(tx)
|
||||||
|
for rows in grouped.values():
|
||||||
|
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
def adapt_junior_members(junior_members):
|
||||||
|
"""Convert 4-tuple junior fee data to (fee, total_count) for reconcile."""
|
||||||
|
adapted = []
|
||||||
|
for name, tier, fees_dict in junior_members:
|
||||||
|
adapted_fees = {}
|
||||||
|
for m, fee_data in fees_dict.items():
|
||||||
|
if len(fee_data) == 4:
|
||||||
|
fee, total_count, _, _ = fee_data
|
||||||
|
adapted_fees[m] = (fee, total_count)
|
||||||
|
else:
|
||||||
|
fee, count = fee_data
|
||||||
|
adapted_fees[m] = (fee, count)
|
||||||
|
adapted.append((name, tier, adapted_fees))
|
||||||
|
return adapted
|
||||||
|
|
||||||
|
|
||||||
|
def build_adults_view_model(
|
||||||
|
members,
|
||||||
|
sorted_months,
|
||||||
|
result,
|
||||||
|
transactions,
|
||||||
|
current_month,
|
||||||
|
*,
|
||||||
|
attendance_url,
|
||||||
|
payments_url,
|
||||||
|
bank_account,
|
||||||
|
):
|
||||||
|
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
|
||||||
|
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
|
||||||
|
|
||||||
|
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
|
||||||
|
formatted_results = []
|
||||||
|
for name in adult_names:
|
||||||
|
data = result["members"][name]
|
||||||
|
row = {
|
||||||
|
"name": name,
|
||||||
|
"months": [],
|
||||||
|
"balance": data["total_balance"],
|
||||||
|
"unpaid_periods": "",
|
||||||
|
"raw_unpaid_periods": "",
|
||||||
|
}
|
||||||
|
unpaid_months = []
|
||||||
|
raw_unpaid_months = []
|
||||||
|
for m in sorted_months:
|
||||||
|
mdata = data["months"].get(m, {
|
||||||
|
"expected": 0, "original_expected": 0,
|
||||||
|
"attendance_count": 0, "paid": 0, "exception": None,
|
||||||
|
})
|
||||||
|
expected = mdata.get("expected", 0)
|
||||||
|
original_expected = mdata.get("original_expected", 0)
|
||||||
|
count = mdata.get("attendance_count", 0)
|
||||||
|
paid = int(mdata.get("paid", 0))
|
||||||
|
exception_info = mdata.get("exception", None)
|
||||||
|
|
||||||
|
monthly_totals[m]["expected"] += expected
|
||||||
|
monthly_totals[m]["paid"] += paid
|
||||||
|
|
||||||
|
override_amount = exception_info["amount"] if exception_info else None
|
||||||
|
|
||||||
|
if override_amount is not None and override_amount != original_expected:
|
||||||
|
is_overridden = True
|
||||||
|
fee_display = (
|
||||||
|
f"{override_amount} ({original_expected}) CZK ({count})"
|
||||||
|
if count > 0
|
||||||
|
else f"{override_amount} ({original_expected}) CZK"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
is_overridden = False
|
||||||
|
fee_display = (
|
||||||
|
f"{expected} CZK ({count})" if count > 0 else f"{expected} CZK"
|
||||||
|
)
|
||||||
|
|
||||||
|
status = "empty"
|
||||||
|
cell_text = "-"
|
||||||
|
amount_to_pay = 0
|
||||||
|
|
||||||
|
if expected > 0:
|
||||||
|
amount_to_pay = max(0, expected - paid)
|
||||||
|
if paid >= expected:
|
||||||
|
status = "ok"
|
||||||
|
cell_text = f"{paid}/{fee_display}"
|
||||||
|
elif paid > 0:
|
||||||
|
status = "partial"
|
||||||
|
cell_text = f"{paid}/{fee_display}"
|
||||||
|
if m < current_month:
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(
|
||||||
|
datetime.strptime(m, "%Y-%m").strftime("%m/%Y")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
status = "unpaid"
|
||||||
|
cell_text = f"0/{fee_display}"
|
||||||
|
if m < current_month:
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(
|
||||||
|
datetime.strptime(m, "%Y-%m").strftime("%m/%Y")
|
||||||
|
)
|
||||||
|
elif paid > 0:
|
||||||
|
status = "surplus"
|
||||||
|
cell_text = f"PAID {paid}"
|
||||||
|
else:
|
||||||
|
cell_text = "-"
|
||||||
|
amount_to_pay = 0
|
||||||
|
|
||||||
|
if expected > 0 or paid > 0:
|
||||||
|
tooltip = f"Received: {paid}, Expected: {expected}"
|
||||||
|
else:
|
||||||
|
tooltip = ""
|
||||||
|
|
||||||
|
row["months"].append({
|
||||||
|
"text": cell_text,
|
||||||
|
"overridden": is_overridden,
|
||||||
|
"status": status,
|
||||||
|
"amount": amount_to_pay,
|
||||||
|
"month": month_labels[m],
|
||||||
|
"raw_month": m,
|
||||||
|
"tooltip": tooltip,
|
||||||
|
})
|
||||||
|
|
||||||
|
settled_balance = 0
|
||||||
|
for m, mdata in data["months"].items():
|
||||||
|
if m >= current_month:
|
||||||
|
continue
|
||||||
|
exp = mdata.get("expected", 0)
|
||||||
|
if isinstance(exp, int):
|
||||||
|
settled_balance += int(mdata.get("paid", 0)) - exp
|
||||||
|
|
||||||
|
payable_amount = max(0, -settled_balance)
|
||||||
|
row["unpaid_periods"] = ", ".join(unpaid_months)
|
||||||
|
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
|
||||||
|
row["balance"] = settled_balance
|
||||||
|
row["payable_amount"] = payable_amount
|
||||||
|
formatted_results.append(row)
|
||||||
|
|
||||||
|
formatted_totals = []
|
||||||
|
for m in sorted_months:
|
||||||
|
t = monthly_totals[m]
|
||||||
|
status = "empty"
|
||||||
|
if t["expected"] > 0 or t["paid"] > 0:
|
||||||
|
if t["paid"] == t["expected"]:
|
||||||
|
status = "ok"
|
||||||
|
elif t["paid"] < t["expected"]:
|
||||||
|
status = "unpaid"
|
||||||
|
else:
|
||||||
|
status = "surplus"
|
||||||
|
formatted_totals.append({
|
||||||
|
"text": f"{t['paid']} / {t['expected']} CZK",
|
||||||
|
"status": status,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _settled_balance(name):
|
||||||
|
data = result["members"][name]
|
||||||
|
total = 0
|
||||||
|
for m, mdata in data["months"].items():
|
||||||
|
if m >= current_month:
|
||||||
|
continue
|
||||||
|
exp = mdata.get("expected", 0)
|
||||||
|
if isinstance(exp, int):
|
||||||
|
total += int(mdata.get("paid", 0)) - exp
|
||||||
|
return total
|
||||||
|
|
||||||
|
credits = sorted(
|
||||||
|
[{"name": n, "amount": _settled_balance(n)} for n in adult_names if _settled_balance(n) > 0],
|
||||||
|
key=lambda x: x["name"],
|
||||||
|
)
|
||||||
|
debts = sorted(
|
||||||
|
[{"name": n, "amount": abs(_settled_balance(n))} for n in adult_names if _settled_balance(n) < 0],
|
||||||
|
key=lambda x: x["name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_payments_by_person = group_payments_by_person(
|
||||||
|
transactions, [name for name, _, _ in members]
|
||||||
|
)
|
||||||
|
|
||||||
|
return dict(
|
||||||
|
months=[month_labels[m] for m in sorted_months],
|
||||||
|
raw_months=sorted_months,
|
||||||
|
results=formatted_results,
|
||||||
|
totals=formatted_totals,
|
||||||
|
member_data=json.dumps(result["members"]),
|
||||||
|
month_labels_json=json.dumps(month_labels),
|
||||||
|
raw_payments_json=json.dumps(raw_payments_by_person),
|
||||||
|
credits=credits,
|
||||||
|
debts=debts,
|
||||||
|
unmatched=result["unmatched"],
|
||||||
|
attendance_url=attendance_url,
|
||||||
|
payments_url=payments_url,
|
||||||
|
bank_account=bank_account,
|
||||||
|
current_month=current_month,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_juniors_view_model(
|
||||||
|
junior_members,
|
||||||
|
adapted_members,
|
||||||
|
sorted_months,
|
||||||
|
result,
|
||||||
|
transactions,
|
||||||
|
current_month,
|
||||||
|
*,
|
||||||
|
attendance_url,
|
||||||
|
payments_url,
|
||||||
|
bank_account,
|
||||||
|
):
|
||||||
|
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
|
||||||
|
junior_names = sorted([name for name, tier, _ in adapted_members])
|
||||||
|
junior_members_dict = {name: fees_dict for name, _, fees_dict in junior_members}
|
||||||
|
|
||||||
|
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
|
||||||
|
formatted_results = []
|
||||||
|
for name in junior_names:
|
||||||
|
data = result["members"][name]
|
||||||
|
row = {
|
||||||
|
"name": name,
|
||||||
|
"months": [],
|
||||||
|
"balance": data["total_balance"],
|
||||||
|
"unpaid_periods": "",
|
||||||
|
"raw_unpaid_periods": "",
|
||||||
|
}
|
||||||
|
unpaid_months = []
|
||||||
|
raw_unpaid_months = []
|
||||||
|
for m in sorted_months:
|
||||||
|
mdata = data["months"].get(m, {
|
||||||
|
"expected": 0, "original_expected": 0,
|
||||||
|
"attendance_count": 0, "paid": 0, "exception": None,
|
||||||
|
})
|
||||||
|
expected = mdata.get("expected", 0)
|
||||||
|
original_expected = mdata.get("original_expected", 0)
|
||||||
|
count = mdata.get("attendance_count", 0)
|
||||||
|
paid = int(mdata.get("paid", 0))
|
||||||
|
exception_info = mdata.get("exception", None)
|
||||||
|
|
||||||
|
if expected != "?" and isinstance(expected, int):
|
||||||
|
monthly_totals[m]["expected"] += expected
|
||||||
|
monthly_totals[m]["paid"] += paid
|
||||||
|
|
||||||
|
orig_fee_data = junior_members_dict.get(name, {}).get(m)
|
||||||
|
adult_count = 0
|
||||||
|
junior_count = 0
|
||||||
|
if orig_fee_data and len(orig_fee_data) == 4:
|
||||||
|
_, _, adult_count, junior_count = orig_fee_data
|
||||||
|
|
||||||
|
breakdown = ""
|
||||||
|
if adult_count > 0 and junior_count > 0:
|
||||||
|
breakdown = f":{junior_count}J,{adult_count}A"
|
||||||
|
elif junior_count > 0:
|
||||||
|
breakdown = f":{junior_count}J"
|
||||||
|
elif adult_count > 0:
|
||||||
|
breakdown = f":{adult_count}A"
|
||||||
|
|
||||||
|
count_str = f" ({count}{breakdown})" if count > 0 else ""
|
||||||
|
|
||||||
|
override_amount = exception_info["amount"] if exception_info else None
|
||||||
|
|
||||||
|
if override_amount is not None and override_amount != original_expected:
|
||||||
|
is_overridden = True
|
||||||
|
fee_display = f"{override_amount} ({original_expected}) CZK{count_str}"
|
||||||
|
else:
|
||||||
|
is_overridden = False
|
||||||
|
fee_display = f"{expected} CZK{count_str}"
|
||||||
|
|
||||||
|
status = "empty"
|
||||||
|
cell_text = "-"
|
||||||
|
amount_to_pay = 0
|
||||||
|
|
||||||
|
if expected == "?" or (isinstance(expected, int) and expected > 0):
|
||||||
|
if expected == "?":
|
||||||
|
status = "empty"
|
||||||
|
cell_text = f"?{count_str}"
|
||||||
|
elif paid >= expected:
|
||||||
|
status = "ok"
|
||||||
|
cell_text = f"{paid}/{fee_display}"
|
||||||
|
elif paid > 0:
|
||||||
|
status = "partial"
|
||||||
|
cell_text = f"{paid}/{fee_display}"
|
||||||
|
amount_to_pay = expected - paid
|
||||||
|
if m < current_month:
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(
|
||||||
|
datetime.strptime(m, "%Y-%m").strftime("%m/%Y")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
status = "unpaid"
|
||||||
|
cell_text = f"0/{fee_display}"
|
||||||
|
amount_to_pay = expected
|
||||||
|
if m < current_month:
|
||||||
|
unpaid_months.append(month_labels[m])
|
||||||
|
raw_unpaid_months.append(
|
||||||
|
datetime.strptime(m, "%Y-%m").strftime("%m/%Y")
|
||||||
|
)
|
||||||
|
elif paid > 0:
|
||||||
|
status = "surplus"
|
||||||
|
cell_text = f"PAID {paid}"
|
||||||
|
|
||||||
|
if (isinstance(expected, int) and expected > 0) or paid > 0:
|
||||||
|
tooltip = f"Received: {paid}, Expected: {expected}"
|
||||||
|
else:
|
||||||
|
tooltip = ""
|
||||||
|
|
||||||
|
row["months"].append({
|
||||||
|
"text": cell_text,
|
||||||
|
"overridden": is_overridden,
|
||||||
|
"status": status,
|
||||||
|
"amount": amount_to_pay,
|
||||||
|
"month": month_labels[m],
|
||||||
|
"raw_month": m,
|
||||||
|
"tooltip": tooltip,
|
||||||
|
})
|
||||||
|
|
||||||
|
settled_balance = 0
|
||||||
|
for m, mdata in data["months"].items():
|
||||||
|
if m >= current_month:
|
||||||
|
continue
|
||||||
|
exp = mdata.get("expected", 0)
|
||||||
|
if isinstance(exp, int):
|
||||||
|
settled_balance += int(mdata.get("paid", 0)) - exp
|
||||||
|
|
||||||
|
payable_amount = max(0, -settled_balance)
|
||||||
|
row["unpaid_periods"] = ", ".join(unpaid_months)
|
||||||
|
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
|
||||||
|
row["balance"] = settled_balance
|
||||||
|
row["payable_amount"] = payable_amount
|
||||||
|
formatted_results.append(row)
|
||||||
|
|
||||||
|
formatted_totals = []
|
||||||
|
for m in sorted_months:
|
||||||
|
t = monthly_totals[m]
|
||||||
|
status = "empty"
|
||||||
|
if t["expected"] > 0 or t["paid"] > 0:
|
||||||
|
if t["paid"] == t["expected"]:
|
||||||
|
status = "ok"
|
||||||
|
elif t["paid"] < t["expected"]:
|
||||||
|
status = "unpaid"
|
||||||
|
else:
|
||||||
|
status = "surplus"
|
||||||
|
formatted_totals.append({
|
||||||
|
"text": f"{t['paid']} / {t['expected']} CZK",
|
||||||
|
"status": status,
|
||||||
|
})
|
||||||
|
|
||||||
|
junior_all_names = [name for name, _, _ in adapted_members]
|
||||||
|
|
||||||
|
def _junior_settled_balance(name):
|
||||||
|
data = result["members"][name]
|
||||||
|
total = 0
|
||||||
|
for m, mdata in data["months"].items():
|
||||||
|
if m >= current_month:
|
||||||
|
continue
|
||||||
|
exp = mdata.get("expected", 0)
|
||||||
|
if isinstance(exp, int):
|
||||||
|
total += int(mdata.get("paid", 0)) - exp
|
||||||
|
return total
|
||||||
|
|
||||||
|
credits = sorted(
|
||||||
|
[{"name": n, "amount": _junior_settled_balance(n)} for n in junior_all_names if _junior_settled_balance(n) > 0],
|
||||||
|
key=lambda x: x["name"],
|
||||||
|
)
|
||||||
|
debts = sorted(
|
||||||
|
[{"name": n, "amount": abs(_junior_settled_balance(n))} for n in junior_all_names if _junior_settled_balance(n) < 0],
|
||||||
|
key=lambda x: x["name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_payments_by_person = group_payments_by_person(
|
||||||
|
transactions, [name for name, _, _ in adapted_members]
|
||||||
|
)
|
||||||
|
|
||||||
|
return dict(
|
||||||
|
months=[month_labels[m] for m in sorted_months],
|
||||||
|
raw_months=sorted_months,
|
||||||
|
results=formatted_results,
|
||||||
|
totals=formatted_totals,
|
||||||
|
member_data=json.dumps(result["members"]),
|
||||||
|
month_labels_json=json.dumps(month_labels),
|
||||||
|
raw_payments_json=json.dumps(raw_payments_by_person),
|
||||||
|
credits=credits,
|
||||||
|
debts=debts,
|
||||||
|
unmatched=result["unmatched"],
|
||||||
|
attendance_url=attendance_url,
|
||||||
|
payments_url=payments_url,
|
||||||
|
bank_account=bank_account,
|
||||||
|
current_month=current_month,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_payments_view_model(transactions, member_names, *, attendance_url, payments_url):
|
||||||
|
grouped = group_payments_by_person(transactions, member_names)
|
||||||
|
for tx in transactions:
|
||||||
|
if not str(tx.get("person", "")).strip():
|
||||||
|
grouped.setdefault("Unmatched / Unknown", []).append(tx)
|
||||||
|
for rows in grouped.values():
|
||||||
|
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
|
||||||
|
sorted_people = sorted(grouped.keys())
|
||||||
|
return dict(
|
||||||
|
grouped_payments=grouped,
|
||||||
|
sorted_people=sorted_people,
|
||||||
|
attendance_url=attendance_url,
|
||||||
|
payments_url=payments_url,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user