Compare commits
11 Commits
feat/fuj-s
...
feat/go-m5
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b7eff14c4 | |||
| 7d48e8f607 | |||
| be4ecef20f | |||
| da5b82fcdb | |||
| f253e3fcb1 | |||
| 59223c0da4 | |||
| 32a16ff50d | |||
| 2eec51bb34 | |||
| b562ce3201 | |||
| f0de300292 | |||
| 2164e99866 |
30
CHANGELOG.md
30
CHANGELOG.md
@@ -1,5 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-05-07 20:13 CEST — feat(go): M5.2 — HTTP handlers for /api/adults, /api/juniors, /api/payments, /api/version
|
||||
|
||||
- `web/api/handler.go`: `Handler` struct + `ServeAdults`, `ServeJuniors`, `ServePayments`, `ServeVersion` using `membership.Sources`.
|
||||
- `web/api/build_{adults,juniors,payments,common}.go`: ports of `scripts/views.py` view-model builders; `buildJuniorMemberRow` handles `"?"` sentinel, `:NJ,MA` breakdown, unknown-month skip.
|
||||
- Extended `reconcile.FeeData`/`MonthData` with `IsUnknown`, `JuniorAttendance`, `AdultAttendance`; `Transaction` with `ManualFix`, `VS`, `BankID`, `SyncID`.
|
||||
- `sources.go` exports `AdultMergedMonths`/`JuniorMergedMonths`; parses new FeeData and transaction columns.
|
||||
- `web/server.go` + `cmd/fuj/main.go` wired to register `/api/*` routes.
|
||||
- PR #17.
|
||||
|
||||
## 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
|
||||
|
||||
- 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 `LOG_LEVEL=DEBUG` debug logging on the Fio fetch path: client variant selected, full GET URL (token redacted on API path), HTTP status, body bytes, and per-parse drop-reason counters (`raw_rows`, `kept`, `dropped_bad_date`, `dropped_nonpositive_amount`). Key files: `go/internal/io/fio/{client,api,transparent}.go`.
|
||||
- Fixed `parseCzechDate` to accept `DD.MM.YY` (2-digit year) in addition to the 4-digit variant — Fio's transparent page now serves this format. Key file: `go/internal/io/fio/transparent.go`.
|
||||
- Added `make go-sync-debug [DAYS=N]` Makefile target (default 30 days).
|
||||
|
||||
## 2026-05-07 10:32 CEST — feat(go): --dry-run for fuj sync
|
||||
|
||||
- `SyncOpts.DryRun bool` added; when true, `SyncToSheets` prints planned writes (`would write header row`, `would append date=… amount=… sender=…`, `would sort by date`) and returns without calling `WriteHeader`, `AppendValues`, or `SortByDateColumn`.
|
||||
|
||||
424
app.py
424
app.py
@@ -21,8 +21,14 @@ from config import (
|
||||
ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID,
|
||||
BANK_ACCOUNT, CREDENTIALS_PATH,
|
||||
)
|
||||
from attendance import get_members_with_fees, get_junior_members_with_fees, ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS
|
||||
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize, canonical_member_key
|
||||
from attendance import get_members_with_fees, get_junior_members_with_fees
|
||||
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 sync_fio_to_sheets import sync_to_sheets
|
||||
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)
|
||||
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():
|
||||
"""Pre-fetch all cached data so first request is fast."""
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -181,381 +149,77 @@ def adults_view():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
credentials_path = CREDENTIALS_PATH
|
||||
|
||||
|
||||
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
|
||||
record_step("fetch_members")
|
||||
if not members_data:
|
||||
return "No data."
|
||||
members, sorted_months = members_data
|
||||
|
||||
|
||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
||||
record_step("fetch_payments")
|
||||
exceptions = get_cached_data(
|
||||
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||
PAYMENTS_SHEET_ID, credentials_path,
|
||||
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||
)
|
||||
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||
PAYMENTS_SHEET_ID, credentials_path,
|
||||
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||
)
|
||||
record_step("fetch_exceptions")
|
||||
result = reconcile(members, sorted_months, transactions, exceptions)
|
||||
record_step("reconcile")
|
||||
|
||||
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
|
||||
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
|
||||
current_month = 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,
|
||||
vm = build_adults_view_model(
|
||||
members, sorted_months, result, transactions,
|
||||
datetime.now().strftime("%Y-%m"),
|
||||
attendance_url=attendance_url,
|
||||
payments_url=payments_url,
|
||||
bank_account=BANK_ACCOUNT,
|
||||
current_month=current_month
|
||||
)
|
||||
record_step("process_data")
|
||||
return render_template("adults.html", **vm)
|
||||
|
||||
@app.route("/juniors")
|
||||
def juniors_view():
|
||||
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"
|
||||
|
||||
credentials_path = CREDENTIALS_PATH
|
||||
|
||||
|
||||
junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
|
||||
record_step("fetch_junior_members")
|
||||
if not junior_members_data:
|
||||
return "No data."
|
||||
junior_members, sorted_months = junior_members_data
|
||||
|
||||
|
||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
||||
record_step("fetch_payments")
|
||||
exceptions = get_cached_data(
|
||||
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||
PAYMENTS_SHEET_ID, credentials_path,
|
||||
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||
)
|
||||
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
|
||||
PAYMENTS_SHEET_ID, credentials_path,
|
||||
serialize=lambda d: [[list(k), v] for k, v in d.items()],
|
||||
deserialize=lambda c: {tuple(k): v for k, v in c},
|
||||
)
|
||||
record_step("fetch_exceptions")
|
||||
|
||||
# Adapt junior tuple format (name, tier, {month: (fee, total_count, adult_count, junior_count)})
|
||||
# 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))
|
||||
|
||||
adapted_members = adapt_junior_members(junior_members)
|
||||
result = reconcile(adapted_members, sorted_months, transactions, exceptions)
|
||||
record_step("reconcile")
|
||||
|
||||
# Format month labels
|
||||
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}
|
||||
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,
|
||||
vm = build_juniors_view_model(
|
||||
junior_members, adapted_members, sorted_months, result, transactions,
|
||||
datetime.now().strftime("%Y-%m"),
|
||||
attendance_url=attendance_url,
|
||||
payments_url=payments_url,
|
||||
bank_account=BANK_ACCOUNT,
|
||||
current_month=current_month
|
||||
)
|
||||
record_step("process_data")
|
||||
return render_template("juniors.html", **vm)
|
||||
|
||||
@app.route("/payments")
|
||||
def payments():
|
||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
|
||||
credentials_path = CREDENTIALS_PATH
|
||||
|
||||
|
||||
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
|
||||
record_step("fetch_payments")
|
||||
|
||||
@@ -567,23 +231,13 @@ def payments():
|
||||
if juniors_data:
|
||||
member_names.extend(name for name, _, _ in juniors_data[0])
|
||||
|
||||
grouped = group_payments_by_person(transactions, member_names)
|
||||
# payments page also groups unmatched rows under a fallback key
|
||||
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,
|
||||
vm = build_payments_view_model(
|
||||
transactions, member_names,
|
||||
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")
|
||||
def qr_code():
|
||||
|
||||
@@ -97,8 +97,8 @@ 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.
|
||||
|
||||
- [ ] **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/`
|
||||
- [ ] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs
|
||||
- [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`
|
||||
- [x] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs — `7d48e8f`
|
||||
- [ ] **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
|
||||
|
||||
|
||||
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.
|
||||
@@ -73,10 +73,18 @@ func serverCmd(args []string) {
|
||||
cfg.ServerAddr = *addr
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
logger := logging.New(cfg.LogLevel)
|
||||
|
||||
sources, err := membership.NewSources(ctx, cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fuj server: init sources: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
|
||||
|
||||
if err := web.Run(logger, cfg.ServerAddr, build); err != nil {
|
||||
if err := web.Run(logger, cfg.ServerAddr, build, sources, cfg); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ module fuj-management/go
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/invopop/jsonschema v0.14.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/text v0.36.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/oauth2adapt v0.2.8 // 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/felixge/httpsnoop v1.0.4 // 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/googleapis/enterprise-certificate-proxy v0.3.15 // 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/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric 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/oauth2 v0.36.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/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
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/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
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/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/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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
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/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.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/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
|
||||
@@ -20,10 +20,13 @@ type Exception struct {
|
||||
Note string
|
||||
}
|
||||
|
||||
// FeeData holds the expected fee and attendance count for one member in one month.
|
||||
// FeeData holds the expected fee and attendance data for one member in one month.
|
||||
type FeeData struct {
|
||||
Expected int
|
||||
Attendance int
|
||||
Expected int
|
||||
IsUnknown bool // true when junior has exactly 1 session (manual review; Python sentinel "?")
|
||||
Attendance int
|
||||
JuniorAttendance int // junior-tab sessions; used for the :NJ,MA breakdown in the juniors view
|
||||
AdultAttendance int // adult-tab sessions for J-tier members; used for the :NJ,MA breakdown
|
||||
}
|
||||
|
||||
// Member is one row from the attendance sheet.
|
||||
@@ -39,11 +42,15 @@ type Member struct {
|
||||
type Transaction struct {
|
||||
Date string
|
||||
Amount float64
|
||||
ManualFix string // "manual fix" column; non-empty disables re-inference
|
||||
Person string // comma-separated canonical names (empty → use inference)
|
||||
Purpose string // comma-separated "YYYY-MM" or "other:…" (empty → use inference)
|
||||
InferredAmount *float64 // nil → fall back to Amount
|
||||
Sender string
|
||||
VS string // Variabilní symbol (Czech variable payment symbol)
|
||||
Message string
|
||||
BankID string
|
||||
SyncID string
|
||||
UserID string
|
||||
}
|
||||
|
||||
@@ -69,8 +76,11 @@ type OtherEntry struct {
|
||||
// MonthData is the ledger state for one member in one month.
|
||||
type MonthData struct {
|
||||
Expected int
|
||||
IsUnknown bool // mirrors FeeData.IsUnknown; not overridden by exceptions
|
||||
OriginalExpected int
|
||||
AttendanceCount int
|
||||
JuniorAttendance int // junior-tab sessions; for :NJ,MA breakdown in juniors view
|
||||
AdultAttendance int // adult-tab sessions; for :NJ,MA breakdown
|
||||
Exception *Exception
|
||||
Paid float64
|
||||
Transactions []TxEntry
|
||||
@@ -173,8 +183,11 @@ func Reconcile(
|
||||
|
||||
ledger[name][m] = MonthData{
|
||||
Expected: expected,
|
||||
IsUnknown: fd.IsUnknown,
|
||||
OriginalExpected: originalExpected,
|
||||
AttendanceCount: attendanceCount,
|
||||
JuniorAttendance: fd.JuniorAttendance,
|
||||
AdultAttendance: fd.AdultAttendance,
|
||||
Exception: exInfo,
|
||||
Paid: 0,
|
||||
Transactions: []TxEntry{},
|
||||
|
||||
@@ -29,7 +29,7 @@ func tx(person, purpose string, amount float64) Transaction {
|
||||
|
||||
func TestReconcileExceptionOverride(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 4}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 4}}}}
|
||||
exceptions := map[ExceptionKey]Exception{
|
||||
{Name: "alice", Period: "2026-01"}: {Amount: 400, Note: "Test exception"},
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func TestReconcileExceptionOverride(t *testing.T) {
|
||||
|
||||
func TestReconcileFallbackToAttendance(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 4}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 4}}}}
|
||||
|
||||
result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear)
|
||||
|
||||
@@ -68,9 +68,9 @@ func TestReconcileGreedyExactMatch(t *testing.T) {
|
||||
members := []Member{{
|
||||
Name: "Alice", Tier: "A",
|
||||
Fees: map[string]FeeData{
|
||||
"2026-02": {750, 3},
|
||||
"2026-03": {350, 3},
|
||||
"2026-04": {150, 2},
|
||||
"2026-02": {Expected: 750, Attendance: 3},
|
||||
"2026-03": {Expected: 350, Attendance: 3},
|
||||
"2026-04": {Expected: 150, Attendance: 2},
|
||||
},
|
||||
}}
|
||||
sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
|
||||
@@ -93,7 +93,7 @@ func TestReconcileGreedyOverpaymentGoesToCredit(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{
|
||||
Name: "Alice", Tier: "A",
|
||||
Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {750, 3}},
|
||||
Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 750, Attendance: 3}},
|
||||
}}
|
||||
sortedMonths := []string{"2026-01", "2026-02"}
|
||||
|
||||
@@ -115,7 +115,7 @@ func TestReconcileProportionalUnderpayment(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{
|
||||
Name: "Alice", Tier: "A",
|
||||
Fees: map[string]FeeData{"2026-02": {750, 3}, "2026-03": {350, 3}, "2026-04": {750, 3}},
|
||||
Fees: map[string]FeeData{"2026-02": {Expected: 750, Attendance: 3}, "2026-03": {Expected: 350, Attendance: 3}, "2026-04": {Expected: 750, Attendance: 3}},
|
||||
}}
|
||||
sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
|
||||
amount := 1250.0
|
||||
@@ -146,7 +146,7 @@ func TestReconcileProportionalUnderpayment(t *testing.T) {
|
||||
|
||||
func TestReconcileSingleMonthUnchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||
|
||||
result := Reconcile(members, []string{"2026-01"}, []Transaction{tx("Alice", "2026-01", 750)}, nil, defaultYear)
|
||||
|
||||
@@ -158,8 +158,8 @@ func TestReconcileSingleMonthUnchanged(t *testing.T) {
|
||||
func TestReconcileTwoMembersMultiMonth(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{
|
||||
{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 3}}},
|
||||
{Name: "Bob", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 3}}},
|
||||
{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 350, Attendance: 3}}},
|
||||
{Name: "Bob", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}, "2026-02": {Expected: 350, Attendance: 3}}},
|
||||
}
|
||||
sortedMonths := []string{"2026-01", "2026-02"}
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestReconcileEvenSplitFallbackWhenNoExpected(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{
|
||||
Name: "Alice", Tier: "A",
|
||||
Fees: map[string]FeeData{"2026-01": {0, 0}, "2026-02": {0, 0}},
|
||||
Fees: map[string]FeeData{"2026-01": {Expected: 0, Attendance: 0}, "2026-02": {Expected: 0, Attendance: 0}},
|
||||
}}
|
||||
sortedMonths := []string{"2026-01", "2026-02"}
|
||||
|
||||
@@ -197,7 +197,7 @@ func TestReconcileEvenSplitFallbackWhenNoExpected(t *testing.T) {
|
||||
|
||||
func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 4}}}}
|
||||
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 4}}}}
|
||||
txFn := func(person string) Transaction {
|
||||
return Transaction{
|
||||
Date: "2026-04-15", Amount: 750, Person: person, Purpose: "2026-04",
|
||||
@@ -232,7 +232,7 @@ func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) {
|
||||
|
||||
func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 4}}}}
|
||||
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 4}}}}
|
||||
txs := []Transaction{{
|
||||
Date: "2026-04-15", Amount: 750,
|
||||
Person: "Někdo Neznámý", Purpose: "2026-04",
|
||||
@@ -252,7 +252,7 @@ func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) {
|
||||
// [Go] Test that [?] markers are stripped from the Person field before lookup.
|
||||
func TestReconcileQuestionMarkMarkerStripped(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||
txs := []Transaction{{
|
||||
Date: "2026-01-01", Amount: 750,
|
||||
Person: "[?] Alice", Purpose: "2026-01",
|
||||
@@ -269,7 +269,7 @@ func TestReconcileQuestionMarkMarkerStripped(t *testing.T) {
|
||||
// [Go] Purpose "other:shirt" puts payment in OtherTransactions, not in month ledger.
|
||||
func TestReconcileOtherPurpose(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||
txs := []Transaction{{
|
||||
Date: "2026-01-01", Amount: 300,
|
||||
Person: "Alice", Purpose: "other:shirt",
|
||||
@@ -297,7 +297,7 @@ func TestReconcileOtherPurpose(t *testing.T) {
|
||||
func TestReconcileOutOfWindowGoesToCredit(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Window shows only 2026-01. Transaction references 2026-01 (in) and 2026-02 (out).
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {600, 3}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 600, Attendance: 3}}}}
|
||||
txs := []Transaction{{
|
||||
Date: "2026-01-01", Amount: 1200,
|
||||
Person: "Alice", Purpose: "2026-01, 2026-02",
|
||||
@@ -322,7 +322,7 @@ func TestReconcileOutOfWindowGoesToCredit(t *testing.T) {
|
||||
// [Go] No person/purpose → inference fallback resolves sender name and date month.
|
||||
func TestReconcileInferenceFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Tomáš Němeček", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 3}}}}
|
||||
members := []Member{{Name: "Tomáš Němeček", Tier: "A", Fees: map[string]FeeData{"2026-04": {Expected: 750, Attendance: 3}}}}
|
||||
txs := []Transaction{{
|
||||
Date: "2026-04-15", Amount: 750,
|
||||
// Person and Purpose are empty → inference path
|
||||
@@ -340,7 +340,7 @@ func TestReconcileInferenceFallback(t *testing.T) {
|
||||
// [Go] Transaction with no match at all ends up in Unmatched; ledger unchanged.
|
||||
func TestReconcileNoMatchGoesToUnmatched(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||
txs := []Transaction{{
|
||||
Date: "2026-01-01", Amount: 500,
|
||||
// empty person+purpose and sender name not matching any member
|
||||
@@ -360,7 +360,7 @@ func TestReconcileNoMatchGoesToUnmatched(t *testing.T) {
|
||||
// [Go] Empty transaction list leaves every month at paid=0 and balance=–expected.
|
||||
func TestReconcileNoTransactionsAllUnpaid(t *testing.T) {
|
||||
t.Parallel()
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
|
||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||
|
||||
result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear)
|
||||
|
||||
|
||||
@@ -98,8 +98,8 @@ func TestParseCzechDate(t *testing.T) {
|
||||
{"7.5.2026", "2026-05-07"}, // non-padded — real Fio transparent page format
|
||||
{"3.12.2025", "2025-12-03"}, // non-padded single-digit day, double-digit month
|
||||
{"07.05.26", "2026-05-07"}, // padded 2-digit year — current Fio transparent page format
|
||||
{"7.5.26", "2026-05-07"}, // non-padded 2-digit year
|
||||
{"07/05/26", "2026-05-07"}, // slash variant
|
||||
{"7.5.26", "2026-05-07"}, // non-padded 2-digit year
|
||||
{"07/05/26", "2026-05-07"}, // slash variant
|
||||
{"", ""},
|
||||
{"invalid", ""},
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@ package banksync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fuj-management/go/internal/io/fio"
|
||||
"io"
|
||||
"text/tabwriter"
|
||||
|
||||
"fuj-management/go/internal/io/fio"
|
||||
)
|
||||
|
||||
func printFioTable(w io.Writer, txns []fio.Transaction, syncIDs []string, existing map[string]bool) {
|
||||
|
||||
@@ -25,12 +25,12 @@ const (
|
||||
firstDateCol = 3
|
||||
)
|
||||
|
||||
// adultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py.
|
||||
// AdultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py.
|
||||
// Source month → target month (source attendance accumulated into target).
|
||||
var adultMergedMonths = map[string]string{}
|
||||
var AdultMergedMonths = map[string]string{}
|
||||
|
||||
// juniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py.
|
||||
var juniorMergedMonths = map[string]string{
|
||||
// JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py.
|
||||
var JuniorMergedMonths = map[string]string{
|
||||
"2025-12": "2026-01",
|
||||
"2025-09": "2025-10",
|
||||
}
|
||||
@@ -195,7 +195,7 @@ func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
dates := parseDates(rows[0])
|
||||
months := groupByMonth(dates, adultMergedMonths)
|
||||
months := groupByMonth(dates, AdultMergedMonths)
|
||||
sortedMonths := sortedKeys(months)
|
||||
|
||||
var members []reconcile.Member
|
||||
@@ -243,8 +243,8 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st
|
||||
|
||||
mainDates := parseDates(adultRows[0])
|
||||
juniorDates := parseDates(juniorRows[0])
|
||||
mainMonths := groupByMonth(mainDates, juniorMergedMonths)
|
||||
jrMonths := groupByMonth(juniorDates, juniorMergedMonths)
|
||||
mainMonths := groupByMonth(mainDates, JuniorMergedMonths)
|
||||
jrMonths := groupByMonth(juniorDates, JuniorMergedMonths)
|
||||
|
||||
allMonths := make(map[string]bool)
|
||||
for m := range mainMonths {
|
||||
@@ -337,7 +337,13 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st
|
||||
if !exp.Unknown {
|
||||
fee = exp.Value
|
||||
}
|
||||
feeMap[m] = reconcile.FeeData{Expected: fee, Attendance: total}
|
||||
feeMap[m] = reconcile.FeeData{
|
||||
Expected: fee,
|
||||
IsUnknown: exp.Unknown,
|
||||
Attendance: total,
|
||||
JuniorAttendance: c.junior,
|
||||
AdultAttendance: c.adult,
|
||||
}
|
||||
}
|
||||
members = append(members, reconcile.Member{Name: name, Tier: data.tier, Fees: feeMap})
|
||||
}
|
||||
@@ -365,11 +371,15 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
||||
}
|
||||
idxDate := idx("date")
|
||||
idxAmount := idx("amount")
|
||||
idxManualFix := idx("manual fix")
|
||||
idxPerson := idx("person")
|
||||
idxPurpose := idx("purpose")
|
||||
idxInferred := idx("inferred amount")
|
||||
idxSender := idx("sender")
|
||||
idxVS := idx("vs")
|
||||
idxMessage := idx("message")
|
||||
idxBankID := idx("bank id")
|
||||
idxSyncID := idx("sync id")
|
||||
|
||||
for _, label := range []string{"date", "amount", "person", "purpose"} {
|
||||
if idx(label) == -1 {
|
||||
@@ -403,11 +413,15 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
||||
txns = append(txns, reconcile.Transaction{
|
||||
Date: dateStr,
|
||||
Amount: amount,
|
||||
ManualFix: getVal(row, idxManualFix),
|
||||
Person: getVal(row, idxPerson),
|
||||
Purpose: getVal(row, idxPurpose),
|
||||
InferredAmount: inferredAmount,
|
||||
Sender: getVal(row, idxSender),
|
||||
VS: getVal(row, idxVS),
|
||||
Message: getVal(row, idxMessage),
|
||||
BankID: getVal(row, idxBankID),
|
||||
SyncID: getVal(row, idxSyncID),
|
||||
})
|
||||
}
|
||||
return txns, nil
|
||||
|
||||
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"`
|
||||
}
|
||||
263
go/internal/web/api/build_adults.go
Normal file
263
go/internal/web/api/build_adults.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fuj-management/go/internal/config"
|
||||
"fuj-management/go/internal/services/membership"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||
)
|
||||
|
||||
type monthSums struct{ expected, paid int }
|
||||
|
||||
// buildAdultsResponse constructs the AdultsResponse wire type from reconcile output.
|
||||
// Mirrors scripts/views.py:build_adults_view_model.
|
||||
func buildAdultsResponse(
|
||||
members []domreconcile.Member,
|
||||
sortedMonths []string,
|
||||
result domreconcile.Result,
|
||||
txns []domreconcile.Transaction,
|
||||
cfg config.Config,
|
||||
currentMonth string,
|
||||
) AdultsResponse {
|
||||
monthLabels := getMonthLabels(sortedMonths, membership.AdultMergedMonths)
|
||||
|
||||
// Collect tier-A names, sorted.
|
||||
var adultNames []string
|
||||
allNames := make([]string, 0, len(members))
|
||||
for _, m := range members {
|
||||
allNames = append(allNames, m.Name)
|
||||
if m.Tier == "A" {
|
||||
adultNames = append(adultNames, m.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(adultNames)
|
||||
|
||||
// Per-month aggregate totals (expected and paid integers).
|
||||
monthlyTotals := make(map[string]*monthSums, len(sortedMonths))
|
||||
for _, m := range sortedMonths {
|
||||
monthlyTotals[m] = &monthSums{}
|
||||
}
|
||||
|
||||
var results []MemberRow
|
||||
for _, name := range adultNames {
|
||||
mr := result.Members[name]
|
||||
row, unpaidMonths, rawUnpaidMonths := buildAdultMemberRow(name, mr, sortedMonths, monthLabels, currentMonth, monthlyTotals)
|
||||
row.UnpaidPeriods = joinComma(unpaidMonths)
|
||||
row.RawUnpaidPeriods = joinPlus(rawUnpaidMonths)
|
||||
row.Balance = settledBalance(mr, currentMonth)
|
||||
row.PayableAmount = max(0, -row.Balance)
|
||||
results = append(results, row)
|
||||
}
|
||||
|
||||
// Totals row.
|
||||
totalsCells := make([]TotalCell, len(sortedMonths))
|
||||
for i, m := range sortedMonths {
|
||||
t := monthlyTotals[m] // *monthSums, never nil (initialised above)
|
||||
status := "empty"
|
||||
if t.expected > 0 || t.paid > 0 {
|
||||
switch {
|
||||
case t.paid == t.expected:
|
||||
status = "ok"
|
||||
case t.paid < t.expected:
|
||||
status = "unpaid"
|
||||
default:
|
||||
status = "surplus"
|
||||
}
|
||||
}
|
||||
totalsCells[i] = TotalCell{
|
||||
Text: fmt.Sprintf("%d / %d CZK", t.paid, t.expected),
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
// Credits and debts (settled balance, past months only).
|
||||
var credits, debts []Credit
|
||||
for _, name := range adultNames {
|
||||
bal := settledBalance(result.Members[name], currentMonth)
|
||||
if bal > 0 {
|
||||
credits = append(credits, Credit{Name: name, Amount: bal})
|
||||
} else if bal < 0 {
|
||||
debts = append(debts, Credit{Name: name, Amount: -bal})
|
||||
}
|
||||
}
|
||||
sort.Slice(credits, func(i, j int) bool { return credits[i].Name < credits[j].Name })
|
||||
sort.Slice(debts, func(i, j int) bool { return debts[i].Name < debts[j].Name })
|
||||
|
||||
// member_data: full reconcile output for all members (not just adults).
|
||||
memberData := make(map[string]AdultsMemberData, len(result.Members))
|
||||
for name, mr := range result.Members {
|
||||
months := make(map[string]AdultsMonthData, len(mr.Months))
|
||||
for m, md := range mr.Months {
|
||||
var exc *ExceptionData
|
||||
if md.Exception != nil {
|
||||
exc = &ExceptionData{Amount: md.Exception.Amount, Note: md.Exception.Note}
|
||||
}
|
||||
txEntries := make([]MemberTxEntry, len(md.Transactions))
|
||||
for i, te := range md.Transactions {
|
||||
txEntries[i] = memberTxFromDomain(te)
|
||||
}
|
||||
months[m] = AdultsMonthData{
|
||||
Expected: md.Expected,
|
||||
OriginalExpected: md.OriginalExpected,
|
||||
AttendanceCount: md.AttendanceCount,
|
||||
Exception: exc,
|
||||
Paid: md.Paid,
|
||||
Transactions: txEntries,
|
||||
}
|
||||
}
|
||||
otherTxs := make([]MemberOtherEntry, len(mr.OtherTransactions))
|
||||
for i, oe := range mr.OtherTransactions {
|
||||
otherTxs[i] = memberOtherFromDomain(oe)
|
||||
}
|
||||
memberData[name] = AdultsMemberData{
|
||||
Tier: mr.Tier,
|
||||
Months: months,
|
||||
OtherTransactions: otherTxs,
|
||||
TotalBalance: mr.TotalBalance,
|
||||
}
|
||||
}
|
||||
|
||||
unmatched := make([]RawTransaction, len(result.Unmatched))
|
||||
for i, tx := range result.Unmatched {
|
||||
unmatched[i] = rawTxFromDomain(tx)
|
||||
}
|
||||
|
||||
return AdultsResponse{
|
||||
Months: labelsForMonths(sortedMonths, monthLabels),
|
||||
RawMonths: sortedMonths,
|
||||
Results: ensureSlice(results),
|
||||
Totals: totalsCells,
|
||||
MemberData: memberData,
|
||||
MonthLabels: monthLabels,
|
||||
RawPayments: groupRawPaymentsByPerson(txns, allNames),
|
||||
Credits: ensureSlice(credits),
|
||||
Debts: ensureSlice(debts),
|
||||
Unmatched: unmatched,
|
||||
AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit",
|
||||
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
||||
BankAccount: cfg.BankAccount,
|
||||
CurrentMonth: currentMonth,
|
||||
}
|
||||
}
|
||||
|
||||
func buildAdultMemberRow(
|
||||
name string,
|
||||
mr domreconcile.MemberResult,
|
||||
sortedMonths []string,
|
||||
monthLabels map[string]string,
|
||||
currentMonth string,
|
||||
monthlyTotals map[string]*monthSums,
|
||||
) (row MemberRow, unpaidMonths, rawUnpaidMonths []string) {
|
||||
row = MemberRow{Name: name}
|
||||
for _, m := range sortedMonths {
|
||||
md, ok := mr.Months[m]
|
||||
if !ok {
|
||||
md = domreconcile.MonthData{}
|
||||
}
|
||||
paid := int(md.Paid)
|
||||
expected := md.Expected
|
||||
|
||||
if t := monthlyTotals[m]; t != nil {
|
||||
t.expected += expected
|
||||
t.paid += paid
|
||||
}
|
||||
|
||||
var feeDisplay string
|
||||
var isOverridden bool
|
||||
if md.Exception != nil && md.Exception.Amount != md.OriginalExpected {
|
||||
isOverridden = true
|
||||
if md.AttendanceCount > 0 {
|
||||
feeDisplay = fmt.Sprintf("%d (%d) CZK (%d)", md.Exception.Amount, md.OriginalExpected, md.AttendanceCount)
|
||||
} else {
|
||||
feeDisplay = fmt.Sprintf("%d (%d) CZK", md.Exception.Amount, md.OriginalExpected)
|
||||
}
|
||||
} else {
|
||||
if md.AttendanceCount > 0 {
|
||||
feeDisplay = fmt.Sprintf("%d CZK (%d)", expected, md.AttendanceCount)
|
||||
} else {
|
||||
feeDisplay = fmt.Sprintf("%d CZK", expected)
|
||||
}
|
||||
}
|
||||
|
||||
status := "empty"
|
||||
cellText := "-"
|
||||
amountToPay := 0
|
||||
|
||||
switch {
|
||||
case expected > 0:
|
||||
amountToPay = max(0, expected-paid)
|
||||
switch {
|
||||
case paid >= expected:
|
||||
status = "ok"
|
||||
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
||||
case paid > 0:
|
||||
status = "partial"
|
||||
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
||||
if m < currentMonth {
|
||||
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
||||
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
||||
}
|
||||
default:
|
||||
status = "unpaid"
|
||||
cellText = fmt.Sprintf("0/%s", feeDisplay)
|
||||
if m < currentMonth {
|
||||
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
||||
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
||||
}
|
||||
}
|
||||
case paid > 0:
|
||||
status = "surplus"
|
||||
cellText = fmt.Sprintf("PAID %d", paid)
|
||||
}
|
||||
|
||||
tooltip := ""
|
||||
if expected > 0 || paid > 0 {
|
||||
tooltip = fmt.Sprintf("Received: %d, Expected: %d", paid, expected)
|
||||
}
|
||||
|
||||
row.Months = append(row.Months, MonthCell{
|
||||
Text: cellText,
|
||||
Overridden: isOverridden,
|
||||
Status: status,
|
||||
Amount: amountToPay,
|
||||
Month: monthLabels[m],
|
||||
RawMonth: m,
|
||||
Tooltip: tooltip,
|
||||
})
|
||||
}
|
||||
return row, unpaidMonths, rawUnpaidMonths
|
||||
}
|
||||
|
||||
// rawMonthLabel converts "YYYY-MM" to "MM/YYYY" matching Python's strftime("%m/%Y").
|
||||
func rawMonthLabel(m string) string {
|
||||
dt, err := time.Parse("2006-01", m)
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
return dt.Format("01/2006")
|
||||
}
|
||||
|
||||
func joinComma(parts []string) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
result := parts[0]
|
||||
for _, p := range parts[1:] {
|
||||
result += ", " + p
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func joinPlus(parts []string) string {
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
result := parts[0]
|
||||
for _, p := range parts[1:] {
|
||||
result += "+" + p
|
||||
}
|
||||
return result
|
||||
}
|
||||
184
go/internal/web/api/build_common.go
Normal file
184
go/internal/web/api/build_common.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fuj-management/go/internal/domain/czech"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||
)
|
||||
|
||||
// getMonthLabels builds display labels for sortedMonths, merging month names
|
||||
// (e.g. "Dec+Jan 2026") when mergedMonths maps a source month into this target.
|
||||
// Mirrors scripts/views.py:get_month_labels.
|
||||
func getMonthLabels(sortedMonths []string, mergedMonths map[string]string) map[string]string {
|
||||
labels := make(map[string]string, len(sortedMonths))
|
||||
for _, m := range sortedMonths {
|
||||
dt, err := time.Parse("2006-01", m)
|
||||
if err != nil {
|
||||
labels[m] = m
|
||||
continue
|
||||
}
|
||||
var mergedIn []string
|
||||
for src, dst := range mergedMonths {
|
||||
if dst == m {
|
||||
mergedIn = append(mergedIn, src)
|
||||
}
|
||||
}
|
||||
sort.Strings(mergedIn)
|
||||
if len(mergedIn) == 0 {
|
||||
labels[m] = dt.Format("Jan 2006")
|
||||
continue
|
||||
}
|
||||
allMonths := append(mergedIn, m) //nolint:gocritic // intentional: mergedIn already owned
|
||||
sort.Strings(allMonths)
|
||||
years := map[int]bool{}
|
||||
for _, x := range allMonths {
|
||||
if d, err2 := time.Parse("2006-01", x); err2 == nil {
|
||||
years[d.Year()] = true
|
||||
}
|
||||
}
|
||||
parts := make([]string, 0, len(allMonths))
|
||||
if len(years) > 1 {
|
||||
for _, x := range allMonths {
|
||||
if d, err2 := time.Parse("2006-01", x); err2 == nil {
|
||||
parts = append(parts, d.Format("Jan 2006"))
|
||||
}
|
||||
}
|
||||
labels[m] = strings.Join(parts, "+")
|
||||
} else {
|
||||
for _, x := range allMonths {
|
||||
if d, err2 := time.Parse("2006-01", x); err2 == nil {
|
||||
parts = append(parts, d.Format("Jan"))
|
||||
}
|
||||
}
|
||||
labels[m] = strings.Join(parts, "+") + " " + dt.Format("2006")
|
||||
}
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
// labelsForMonths returns the display labels for sortedMonths in slice order.
|
||||
func labelsForMonths(sortedMonths []string, labels map[string]string) []string {
|
||||
out := make([]string, len(sortedMonths))
|
||||
for i, m := range sortedMonths {
|
||||
out[i] = labels[m]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
|
||||
|
||||
// canonicalKey returns a normalized form of a person name used for deduplication.
|
||||
// Mirrors scripts/match_payments.py:canonical_member_key.
|
||||
func canonicalKey(name string) string {
|
||||
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
|
||||
}
|
||||
|
||||
// groupRawPaymentsByPerson groups transactions by the "person" column,
|
||||
// canonicalizing names against memberNames where possible.
|
||||
// Mirrors scripts/views.py:group_payments_by_person (without the
|
||||
// "Unmatched / Unknown" bucket that is payments-view-specific).
|
||||
func groupRawPaymentsByPerson(txns []domreconcile.Transaction, memberNames []string) map[string][]RawTransaction {
|
||||
canonicalByKey := make(map[string]string, len(memberNames))
|
||||
for _, n := range memberNames {
|
||||
k := canonicalKey(n)
|
||||
if _, exists := canonicalByKey[k]; !exists {
|
||||
canonicalByKey[k] = n
|
||||
}
|
||||
}
|
||||
grouped := make(map[string][]RawTransaction)
|
||||
for _, tx := range txns {
|
||||
person := strings.TrimSpace(tx.Person)
|
||||
if person == "" {
|
||||
continue
|
||||
}
|
||||
for _, p := range strings.Split(person, ",") {
|
||||
p = questionMarkRe.ReplaceAllString(p, "")
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
key := p
|
||||
if canonical, ok := canonicalByKey[canonicalKey(p)]; ok {
|
||||
key = canonical
|
||||
}
|
||||
grouped[key] = append(grouped[key], rawTxFromDomain(tx))
|
||||
}
|
||||
}
|
||||
for k := range grouped {
|
||||
sort.Slice(grouped[k], func(i, j int) bool {
|
||||
return grouped[k][i].Date > grouped[k][j].Date
|
||||
})
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
// rawTxFromDomain converts a domain Transaction to the wire RawTransaction.
|
||||
func rawTxFromDomain(tx domreconcile.Transaction) RawTransaction {
|
||||
inferredAmount := 0.0
|
||||
if tx.InferredAmount != nil {
|
||||
inferredAmount = *tx.InferredAmount
|
||||
}
|
||||
return RawTransaction{
|
||||
Date: tx.Date,
|
||||
Amount: tx.Amount,
|
||||
ManualFix: tx.ManualFix,
|
||||
Person: tx.Person,
|
||||
Purpose: tx.Purpose,
|
||||
InferredAmount: inferredAmount,
|
||||
Sender: tx.Sender,
|
||||
VS: tx.VS,
|
||||
Message: tx.Message,
|
||||
BankID: tx.BankID,
|
||||
SyncID: tx.SyncID,
|
||||
}
|
||||
}
|
||||
|
||||
// memberTxFromDomain converts a domain TxEntry to a wire MemberTxEntry.
|
||||
func memberTxFromDomain(te domreconcile.TxEntry) MemberTxEntry {
|
||||
return MemberTxEntry{
|
||||
Amount: te.Amount,
|
||||
Date: te.Date,
|
||||
Sender: te.Sender,
|
||||
Message: te.Message,
|
||||
Confidence: te.Confidence,
|
||||
}
|
||||
}
|
||||
|
||||
// memberOtherFromDomain converts a domain OtherEntry to a wire MemberOtherEntry.
|
||||
func memberOtherFromDomain(oe domreconcile.OtherEntry) MemberOtherEntry {
|
||||
return MemberOtherEntry{
|
||||
Amount: oe.Amount,
|
||||
Date: oe.Date,
|
||||
Sender: oe.Sender,
|
||||
Message: oe.Message,
|
||||
Purpose: oe.Purpose,
|
||||
Confidence: oe.Confidence,
|
||||
}
|
||||
}
|
||||
|
||||
// settledBalance computes the settled balance: sum of (paid − expected) for months
|
||||
// strictly before currentMonth. Months with IsUnknown=true are excluded to match
|
||||
// Python's isinstance(exp, int) guard (skips "?" months).
|
||||
func settledBalance(mr domreconcile.MemberResult, currentMonth string) int {
|
||||
total := 0
|
||||
for m, md := range mr.Months {
|
||||
if m >= currentMonth || md.IsUnknown {
|
||||
continue
|
||||
}
|
||||
total += int(md.Paid) - md.Expected
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// ensureSlice returns s unchanged when non-nil, or an empty (non-nil) slice so
|
||||
// json.Marshal emits [] instead of null.
|
||||
func ensureSlice[T any](s []T) []T {
|
||||
if s == nil {
|
||||
return []T{}
|
||||
}
|
||||
return s
|
||||
}
|
||||
276
go/internal/web/api/build_juniors.go
Normal file
276
go/internal/web/api/build_juniors.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fuj-management/go/internal/config"
|
||||
"fuj-management/go/internal/services/membership"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||
)
|
||||
|
||||
// buildJuniorsResponse constructs the JuniorsResponse wire type from reconcile output.
|
||||
// Mirrors scripts/views.py:build_juniors_view_model.
|
||||
func buildJuniorsResponse(
|
||||
members []domreconcile.Member,
|
||||
sortedMonths []string,
|
||||
result domreconcile.Result,
|
||||
txns []domreconcile.Transaction,
|
||||
cfg config.Config,
|
||||
currentMonth string,
|
||||
) JuniorsResponse {
|
||||
monthLabels := getMonthLabels(sortedMonths, membership.JuniorMergedMonths)
|
||||
|
||||
allNames := make([]string, 0, len(members))
|
||||
juniorNames := make([]string, 0, len(members))
|
||||
for _, m := range members {
|
||||
allNames = append(allNames, m.Name)
|
||||
juniorNames = append(juniorNames, m.Name)
|
||||
}
|
||||
sort.Strings(juniorNames)
|
||||
|
||||
monthlyTotals := make(map[string]*monthSums, len(sortedMonths))
|
||||
for _, m := range sortedMonths {
|
||||
monthlyTotals[m] = &monthSums{}
|
||||
}
|
||||
|
||||
var results []MemberRow
|
||||
for _, name := range juniorNames {
|
||||
mr := result.Members[name]
|
||||
row, unpaidMonths, rawUnpaidMonths := buildJuniorMemberRow(name, mr, sortedMonths, monthLabels, currentMonth, monthlyTotals)
|
||||
row.UnpaidPeriods = joinComma(unpaidMonths)
|
||||
row.RawUnpaidPeriods = joinPlus(rawUnpaidMonths)
|
||||
row.Balance = settledBalance(mr, currentMonth)
|
||||
row.PayableAmount = max(0, -row.Balance)
|
||||
results = append(results, row)
|
||||
}
|
||||
|
||||
// Totals row.
|
||||
totalsCells := make([]TotalCell, len(sortedMonths))
|
||||
for i, m := range sortedMonths {
|
||||
t := monthlyTotals[m] // *monthSums, never nil (initialised above)
|
||||
status := "empty"
|
||||
if t.expected > 0 || t.paid > 0 {
|
||||
switch {
|
||||
case t.paid == t.expected:
|
||||
status = "ok"
|
||||
case t.paid < t.expected:
|
||||
status = "unpaid"
|
||||
default:
|
||||
status = "surplus"
|
||||
}
|
||||
}
|
||||
totalsCells[i] = TotalCell{
|
||||
Text: fmt.Sprintf("%d / %d CZK", t.paid, t.expected),
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
var credits, debts []Credit
|
||||
for _, name := range juniorNames {
|
||||
bal := settledBalance(result.Members[name], currentMonth)
|
||||
if bal > 0 {
|
||||
credits = append(credits, Credit{Name: name, Amount: bal})
|
||||
} else if bal < 0 {
|
||||
debts = append(debts, Credit{Name: name, Amount: -bal})
|
||||
}
|
||||
}
|
||||
sort.Slice(credits, func(i, j int) bool { return credits[i].Name < credits[j].Name })
|
||||
sort.Slice(debts, func(i, j int) bool { return debts[i].Name < debts[j].Name })
|
||||
|
||||
// member_data: full reconcile output for all junior members.
|
||||
memberData := make(map[string]JuniorsMemberData, len(result.Members))
|
||||
for name, mr := range result.Members {
|
||||
months := make(map[string]JuniorsMonthData, len(mr.Months))
|
||||
for m, md := range mr.Months {
|
||||
var exc *ExceptionData
|
||||
if md.Exception != nil {
|
||||
exc = &ExceptionData{Amount: md.Exception.Amount, Note: md.Exception.Note}
|
||||
}
|
||||
txEntries := make([]MemberTxEntry, len(md.Transactions))
|
||||
for i, te := range md.Transactions {
|
||||
txEntries[i] = memberTxFromDomain(te)
|
||||
}
|
||||
months[m] = JuniorsMonthData{
|
||||
Expected: juniorExpected(md),
|
||||
OriginalExpected: juniorOriginalExpected(md),
|
||||
AttendanceCount: md.AttendanceCount,
|
||||
Exception: exc,
|
||||
Paid: md.Paid,
|
||||
Transactions: txEntries,
|
||||
}
|
||||
}
|
||||
otherTxs := make([]MemberOtherEntry, len(mr.OtherTransactions))
|
||||
for i, oe := range mr.OtherTransactions {
|
||||
otherTxs[i] = memberOtherFromDomain(oe)
|
||||
}
|
||||
memberData[name] = JuniorsMemberData{
|
||||
Tier: mr.Tier,
|
||||
Months: months,
|
||||
OtherTransactions: otherTxs,
|
||||
TotalBalance: mr.TotalBalance,
|
||||
}
|
||||
}
|
||||
|
||||
unmatched := make([]RawTransaction, len(result.Unmatched))
|
||||
for i, tx := range result.Unmatched {
|
||||
unmatched[i] = rawTxFromDomain(tx)
|
||||
}
|
||||
|
||||
juniorURL := "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID +
|
||||
"/edit#gid=" + config.JuniorSheetGID
|
||||
|
||||
return JuniorsResponse{
|
||||
Months: labelsForMonths(sortedMonths, monthLabels),
|
||||
RawMonths: sortedMonths,
|
||||
Results: ensureSlice(results),
|
||||
Totals: totalsCells,
|
||||
MemberData: memberData,
|
||||
MonthLabels: monthLabels,
|
||||
RawPayments: groupRawPaymentsByPerson(txns, allNames),
|
||||
Credits: ensureSlice(credits),
|
||||
Debts: ensureSlice(debts),
|
||||
Unmatched: unmatched,
|
||||
AttendanceURL: juniorURL,
|
||||
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
||||
BankAccount: cfg.BankAccount,
|
||||
CurrentMonth: currentMonth,
|
||||
}
|
||||
}
|
||||
|
||||
func buildJuniorMemberRow(
|
||||
name string,
|
||||
mr domreconcile.MemberResult,
|
||||
sortedMonths []string,
|
||||
monthLabels map[string]string,
|
||||
currentMonth string,
|
||||
monthlyTotals map[string]*monthSums,
|
||||
) (row MemberRow, unpaidMonths, rawUnpaidMonths []string) {
|
||||
row = MemberRow{Name: name}
|
||||
for _, m := range sortedMonths {
|
||||
md, ok := mr.Months[m]
|
||||
if !ok {
|
||||
md = domreconcile.MonthData{}
|
||||
}
|
||||
paid := int(md.Paid)
|
||||
|
||||
// Update monthly totals (skip "?" months for expected).
|
||||
if t := monthlyTotals[m]; t != nil {
|
||||
if !md.IsUnknown {
|
||||
t.expected += md.Expected
|
||||
}
|
||||
t.paid += paid
|
||||
}
|
||||
|
||||
// Attendance breakdown string e.g. ":3J,2A".
|
||||
var breakdown string
|
||||
jc, ac := md.JuniorAttendance, md.AdultAttendance
|
||||
switch {
|
||||
case jc > 0 && ac > 0:
|
||||
breakdown = fmt.Sprintf(":%dJ,%dA", jc, ac)
|
||||
case jc > 0:
|
||||
breakdown = fmt.Sprintf(":%dJ", jc)
|
||||
case ac > 0:
|
||||
breakdown = fmt.Sprintf(":%dA", ac)
|
||||
}
|
||||
countStr := ""
|
||||
if md.AttendanceCount > 0 {
|
||||
countStr = fmt.Sprintf(" (%d%s)", md.AttendanceCount, breakdown)
|
||||
}
|
||||
|
||||
// Fee display string.
|
||||
var feeDisplay string
|
||||
var isOverridden bool
|
||||
if md.Exception != nil {
|
||||
overrideAmount := md.Exception.Amount
|
||||
var origStr string
|
||||
if md.IsUnknown {
|
||||
origStr = "?"
|
||||
isOverridden = true
|
||||
} else {
|
||||
origStr = strconv.Itoa(md.OriginalExpected)
|
||||
isOverridden = overrideAmount != md.OriginalExpected
|
||||
}
|
||||
if isOverridden {
|
||||
feeDisplay = fmt.Sprintf("%d (%s) CZK%s", overrideAmount, origStr, countStr)
|
||||
} else {
|
||||
feeDisplay = fmt.Sprintf("%d CZK%s", md.Expected, countStr)
|
||||
}
|
||||
} else {
|
||||
if md.IsUnknown {
|
||||
feeDisplay = "? CZK" + countStr
|
||||
} else {
|
||||
feeDisplay = fmt.Sprintf("%d CZK%s", md.Expected, countStr)
|
||||
}
|
||||
}
|
||||
|
||||
status := "empty"
|
||||
cellText := "-"
|
||||
amountToPay := 0
|
||||
|
||||
switch {
|
||||
case md.IsUnknown:
|
||||
cellText = "?" + countStr
|
||||
case md.Expected > 0:
|
||||
switch {
|
||||
case paid >= md.Expected:
|
||||
status = "ok"
|
||||
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
||||
case paid > 0:
|
||||
status = "partial"
|
||||
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
||||
amountToPay = md.Expected - paid
|
||||
if m < currentMonth {
|
||||
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
||||
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
||||
}
|
||||
default:
|
||||
status = "unpaid"
|
||||
cellText = fmt.Sprintf("0/%s", feeDisplay)
|
||||
amountToPay = md.Expected
|
||||
if m < currentMonth {
|
||||
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
||||
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
||||
}
|
||||
}
|
||||
case paid > 0:
|
||||
status = "surplus"
|
||||
cellText = fmt.Sprintf("PAID %d", paid)
|
||||
}
|
||||
|
||||
tooltip := ""
|
||||
if (!md.IsUnknown && md.Expected > 0) || paid > 0 {
|
||||
tooltip = fmt.Sprintf("Received: %d, Expected: %d", paid, md.Expected)
|
||||
}
|
||||
|
||||
row.Months = append(row.Months, MonthCell{
|
||||
Text: cellText,
|
||||
Overridden: isOverridden,
|
||||
Status: status,
|
||||
Amount: amountToPay,
|
||||
Month: monthLabels[m],
|
||||
RawMonth: m,
|
||||
Tooltip: tooltip,
|
||||
})
|
||||
}
|
||||
return row, unpaidMonths, rawUnpaidMonths
|
||||
}
|
||||
|
||||
// juniorExpected converts domain MonthData to the Expected wire type.
|
||||
// When an exception exists it always produces a concrete int; otherwise
|
||||
// the "?" sentinel is used when IsUnknown=true.
|
||||
func juniorExpected(md domreconcile.MonthData) Expected {
|
||||
if md.Exception == nil && md.IsUnknown {
|
||||
return Expected{Unknown: true}
|
||||
}
|
||||
return Expected{Value: md.Expected}
|
||||
}
|
||||
|
||||
// juniorOriginalExpected converts the original (pre-exception) expected fee.
|
||||
func juniorOriginalExpected(md domreconcile.MonthData) Expected {
|
||||
if md.IsUnknown {
|
||||
return Expected{Unknown: true}
|
||||
}
|
||||
return Expected{Value: md.OriginalExpected}
|
||||
}
|
||||
44
go/internal/web/api/build_payments.go
Normal file
44
go/internal/web/api/build_payments.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fuj-management/go/internal/config"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||
)
|
||||
|
||||
// buildPaymentsResponse constructs the PaymentsResponse wire type.
|
||||
// Mirrors scripts/views.py:build_payments_view_model.
|
||||
func buildPaymentsResponse(
|
||||
txns []domreconcile.Transaction,
|
||||
memberNames []string,
|
||||
) PaymentsResponse {
|
||||
grouped := groupRawPaymentsByPerson(txns, memberNames)
|
||||
|
||||
// Add unmatched/unknown bucket for transactions with no person set.
|
||||
const unknownKey = "Unmatched / Unknown"
|
||||
for _, tx := range txns {
|
||||
if strings.TrimSpace(tx.Person) == "" {
|
||||
grouped[unknownKey] = append(grouped[unknownKey], rawTxFromDomain(tx))
|
||||
}
|
||||
}
|
||||
// Sort the unknown bucket newest-first (others are sorted in groupRawPaymentsByPerson).
|
||||
if rows, ok := grouped[unknownKey]; ok {
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i].Date > rows[j].Date })
|
||||
grouped[unknownKey] = rows
|
||||
}
|
||||
|
||||
sortedPeople := make([]string, 0, len(grouped))
|
||||
for p := range grouped {
|
||||
sortedPeople = append(sortedPeople, p)
|
||||
}
|
||||
sort.Strings(sortedPeople)
|
||||
|
||||
return PaymentsResponse{
|
||||
GroupedPayments: grouped,
|
||||
SortedPeople: sortedPeople,
|
||||
AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit",
|
||||
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
||||
}
|
||||
}
|
||||
125
go/internal/web/api/handler.go
Normal file
125
go/internal/web/api/handler.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"fuj-management/go/internal/config"
|
||||
"fuj-management/go/internal/services/membership"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||
)
|
||||
|
||||
// Handler holds the shared dependencies for all /api/* routes.
|
||||
type Handler struct {
|
||||
BuildVersion string
|
||||
BuildCommit string
|
||||
BuildDate string
|
||||
Sources membership.Sources
|
||||
Config config.Config
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// ServeVersion handles GET /api/version.
|
||||
func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, VersionResponse{
|
||||
Tag: h.BuildVersion,
|
||||
Commit: h.BuildCommit,
|
||||
BuildDate: h.BuildDate,
|
||||
})
|
||||
}
|
||||
|
||||
// ServeAdults handles GET /api/adults.
|
||||
func (h *Handler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, true)
|
||||
if err != nil {
|
||||
h.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
|
||||
writeJSON(w, buildAdultsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")))
|
||||
}
|
||||
|
||||
// ServeJuniors handles GET /api/juniors.
|
||||
func (h *Handler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, false)
|
||||
if err != nil {
|
||||
h.writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
|
||||
writeJSON(w, buildJuniorsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")))
|
||||
}
|
||||
|
||||
// ServePayments handles GET /api/payments.
|
||||
func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
txns, err := h.Sources.LoadTransactions(ctx)
|
||||
if err != nil {
|
||||
h.writeError(w, r, fmt.Errorf("load transactions: %w", err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, buildPaymentsResponse(txns, h.allMemberNames(ctx)))
|
||||
}
|
||||
|
||||
func (h *Handler) loadAll(ctx context.Context, adults bool) (
|
||||
members []domreconcile.Member,
|
||||
sortedMonths []string,
|
||||
txns []domreconcile.Transaction,
|
||||
exceptions map[domreconcile.ExceptionKey]domreconcile.Exception,
|
||||
err error,
|
||||
) {
|
||||
if adults {
|
||||
members, sortedMonths, err = h.Sources.LoadAdults(ctx)
|
||||
} else {
|
||||
members, sortedMonths, err = h.Sources.LoadJuniors(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
err = fmt.Errorf("load members: %w", err)
|
||||
return
|
||||
}
|
||||
txns, err = h.Sources.LoadTransactions(ctx)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("load transactions: %w", err)
|
||||
return
|
||||
}
|
||||
exceptions, err = h.Sources.LoadExceptions(ctx)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("load exceptions: %w", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (h *Handler) allMemberNames(ctx context.Context) []string {
|
||||
var names []string
|
||||
if adults, _, err := h.Sources.LoadAdults(ctx); err == nil {
|
||||
for _, m := range adults {
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
}
|
||||
if juniors, _, err := h.Sources.LoadJuniors(ctx); err == nil {
|
||||
for _, m := range juniors {
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (h *Handler) writeError(w http.ResponseWriter, r *http.Request, err error) {
|
||||
if h.Logger != nil {
|
||||
h.Logger.Error("api error", "path", r.URL.Path, "err", err)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
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"`
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"fuj-management/go/internal/config"
|
||||
"fuj-management/go/internal/services/membership"
|
||||
"fuj-management/go/internal/web/api"
|
||||
"fuj-management/go/internal/web/middleware"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -15,9 +18,22 @@ type BuildInfo struct {
|
||||
}
|
||||
|
||||
// Run registers routes and starts the HTTP server on addr.
|
||||
func Run(logger *slog.Logger, addr string, build BuildInfo) error {
|
||||
func Run(logger *slog.Logger, addr string, build BuildInfo, sources membership.Sources, cfg config.Config) error {
|
||||
h := &api.Handler{
|
||||
BuildVersion: build.Version,
|
||||
BuildCommit: build.Commit,
|
||||
BuildDate: build.BuildDate,
|
||||
Sources: sources,
|
||||
Config: cfg,
|
||||
Logger: logger,
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /{$}", helloHandler(build))
|
||||
mux.HandleFunc("GET /api/version", h.ServeVersion)
|
||||
mux.HandleFunc("GET /api/adults", h.ServeAdults)
|
||||
mux.HandleFunc("GET /api/juniors", h.ServeJuniors)
|
||||
mux.HandleFunc("GET /api/payments", h.ServePayments)
|
||||
|
||||
logger.Info("starting server", "addr", addr)
|
||||
return http.ListenAndServe(addr, middleware.RequestTimer(logger, mux))
|
||||
|
||||
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