Compare commits

...

16 Commits

Author SHA1 Message Date
723152cdad fix(go): pass raw value to FormatDate so numeric serial-day dates format
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
The transaction-row parser in services/membership/sources.go used a
helper (`getVal`) that did `fmt.Sprint(row[i])` before passing to
`matching.FormatDate`.  The Sheets API returns date-formatted cells
as `float64` (Sheets serial-day numbers); pre-stringifying defeated
`FormatDate`'s `case float64:` dispatch, so values like 46147 leaked
through unchanged as the string "46147" instead of being converted
to "2026-05-05".

Surfaced by `make parity` (M5.4) — every `transactions[].date` on
/api/adults and /api/juniors differed between Python and Go.  Python
side passes the raw value through directly (`isinstance(val, (int,
float))` in scripts/match_payments.py format_date), so it was always
correct.

Added a `getRaw` helper that returns row[i] without stringifying;
only the date column needs it.  Extended TestLoadTransactions with
a numeric-serial-day row to lock in the regression.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 23:17:45 +02:00
e5a272b682 Merge pull request 'fix(go): default CacheDir to tmp/go to avoid Python collision' (#20) from fix/cache-collision into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
Reviewed-on: #20
2026-05-07 21:07:18 +00:00
8b3064ffab fix(go): default CacheDir to tmp/go to avoid Python collision
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Previously both backends defaulted to `CacheDir=tmp` and used the
same cache keys (`attendance_regular`, `attendance_juniors`,
`payments_transactions`, `exceptions_dict`) but stored different
shapes: Python caches post-processed view-model tuples
(e.g. `(members, sorted_months)`), Go caches raw sheet rows.
Whichever backend wrote last poisoned the cache for the other,
producing `ValueError: too many values to unpack (expected 2,
got 68)` on Python's /adults after the Go side populated the
file with 68 raw CSV rows.

This breaks the M5.4 `make parity` workflow that requires both
backends running side-by-side.

Fix: change Go's default to `tmp/go` so the two cache trees
never overlap.  `CACHE_DIR` env var override still works.
`os.MkdirAll` already handles creating the new subdirectory on
first write.

Recovery for users with poisoned `tmp/`: hit /flush-cache on
the Python side once after pulling, then restart the Go server.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 23:06:34 +02:00
423c3e2a4b Merge pull request 'feat(py): M5.3 — Python /api/* shadow endpoints' (#18) from feat/go-m5-3-python-api-shadow into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Reviewed-on: #18
2026-05-07 20:42:54 +00:00
f4c497681f chore: CHANGELOG and progress tracker for M5.3
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:37:52 +02:00
40e4a9e45e feat(py): M5.3 — add Python /api/* shadow endpoints
Four new JSON routes mirror the Go /api/* handlers so the M5.4 parity
tool can diff them: /api/version, /api/adults, /api/juniors,
/api/payments. A small _unwrap_view_model_for_api() helper in app.py
expands the three pre-serialised JSON strings in the view-model dicts
and renames month_labels_json → month_labels and
raw_payments_json → raw_payments to match the Go wire contract.

Tests in test_app.py assert top-level key sets match the Go API schema
and that member_data, month_labels, raw_payments are objects not strings.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 22:37:14 +02:00
68810369bd Merge pull request 'feat(go): M5.2 — HTTP handlers for /api/adults, /api/juniors, /api/payments, /api/version' (#17) from feat/go-m5-2-api-handlers into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Reviewed-on: #17
2026-05-07 19:08:01 +00:00
2b7eff14c4 chore: CHANGELOG and progress tracker for M5.2
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 21:02:54 +02:00
7d48e8f607 feat(go): M5.2 — HTTP handlers for /api/adults, /api/juniors, /api/payments, /api/version
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
- Add web/api/handler.go: Handler struct wiring Sources+Config into ServeAdults,
  ServeJuniors, ServePayments, ServeVersion
- Add web/api/build_common.go: getMonthLabels, groupRawPaymentsByPerson, settledBalance,
  domain-to-wire converters, ensureSlice generic helper
- Add web/api/build_adults.go: buildAdultsResponse + buildAdultMemberRow mirroring
  scripts/views.py:build_adults_view_model
- Add web/api/build_juniors.go: buildJuniorsResponse + buildJuniorMemberRow mirroring
  scripts/views.py:build_juniors_view_model, including "?" sentinel and :NJ,MA breakdown
- Add web/api/build_payments.go: buildPaymentsResponse with Unmatched/Unknown bucket
- Extend reconcile.FeeData/MonthData with IsUnknown, JuniorAttendance, AdultAttendance
- Extend reconcile.Transaction with ManualFix, VS, BankID, SyncID for raw_payments wire field
- Export membership.AdultMergedMonths and JuniorMergedMonths
- Update sources.go to propagate new FeeData fields and parse extra transaction columns
- Wire sources+cfg into web.Run; register /api/* routes via Go 1.22 method+path patterns
- Fix pre-existing gofumpt formatting in fio_test.go and fio_table.go

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 20:13:38 +02:00
be4ecef20f Merge pull request 'feat(go): M5.1 — hand-author /api/* wire types + JSON Schemas' (#16) from feat/go-m5-1-api-structs into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
Reviewed-on: #16
2026-05-07 17:50:55 +00:00
da5b82fcdb chore: CHANGELOG and progress tracker for M5.1
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:38:00 +02:00
f253e3fcb1 feat(go): M5.1 — hand-author /api/* wire types + JSON Schemas
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Add internal/web/api package with Go structs for every /api/X route:
AdultsResponse, JuniorsResponse, PaymentsResponse, VersionResponse.
All fields carry explicit json: tags matching the Python view-model keys.

Key design choices:
- member_data / month_labels / raw_payments are nested objects (not
  the pre-serialised JSON strings used in Jinja templates)
- Expected{Value int; Unknown bool} with custom MarshalJSON emits int
  or the string "?" for junior single-attendance months
- RawTransaction covers the full 11-column payments sheet row

schemagen_test.go reflects all four response types via
github.com/invopop/jsonschema and golden-compares against committed
schemas in tests/fixtures/api-schema/. The JSONSchema() method on
Expected lives in the test file so the prod binary has no jsonschema
dependency.

Closes M5.1 in docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 17:36:46 +02:00
59223c0da4 chore: CHANGELOG for Python view-model extraction
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:27:17 +02:00
32a16ff50d Merge pull request 'refactor(app): extract view-model builders into scripts/views.py' (#15) from feat/m5-python-views-extraction into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 5s
Reviewed-on: #15
2026-05-07 13:26:42 +00:00
2eec51bb34 fix(app): restore missing import re needed by qr_code route
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
Accidentally removed when moving group_payments_by_person to views.py;
re.match in qr_code caused a 500 on every QR request.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:24:40 +02:00
b562ce3201 refactor(app): extract view-model builders into scripts/views.py
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Pull 350+ lines of inline per-row computation out of adults_view,
juniors_view, and payments into three pure builder functions with no
Flask globals or IO dependencies. Route handlers now contain only
cache/IO calls and a single render_template. No behaviour change —
all 27 tests pass.

Also moves get_month_labels, group_payments_by_person, and
adapt_junior_members out of app.py. Prep for /api/* shadow endpoints
(M5 Go parity).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:22:12 +02:00
34 changed files with 3476 additions and 428 deletions

View File

@@ -1,5 +1,45 @@
# Changelog
## 2026-05-07 23:17 CEST — fix(go): pass raw value to FormatDate so numeric serial-day dates format
- `go/internal/services/membership/sources.go`: transaction-row parser now passes `row[idxDate]` directly to `matching.FormatDate` (via a new `getRaw` helper) instead of stringifying first via `getVal`. The Sheets API returns numeric serial-day values as `float64` for date-formatted cells; pre-stringifying them defeated `FormatDate`'s `case float64:` dispatch, causing all numeric dates to leak through as `"46147"` style strings instead of `"2026-05-05"`.
- Surfaced by `make parity` (M5.4): every `transactions[].date` field on `/api/adults` and `/api/juniors` differed between Python and Go.
- `sources_test.go::TestLoadTransactions` extended with a numeric-serial-day row covering the regression.
## 2026-05-07 23:05 CEST — fix(go): default CacheDir to `tmp/go` to avoid Python collision
- `go/internal/config/config.go`: `CacheDir` default changed from `tmp` to `tmp/go`. Override via `CACHE_DIR` env var still works.
- Why: both backends used `tmp/<key>_cache.json` with the same keys (`attendance_regular`, `attendance_juniors`, `payments_transactions`, `exceptions_dict`) but different shapes — Python caches post-processed view-model tuples, Go caches raw rows. Whichever wrote last poisoned the cache; running both in parallel produced `ValueError: too many values to unpack (expected 2, got 68)` on Python's `/adults` after the Go server populated `attendance_regular_cache.json` with raw CSV rows.
- After upgrading: stop the Go server, hit `/flush-cache` on the Python side once (rewrites `tmp/*.json` with correct shapes), then restart `make web-go` — it will use `tmp/go/` going forward. Required for the M5.4 `make parity` workflow which assumes both backends run side-by-side.
## 2026-05-07 22:37 CEST — feat(py): M5.3 — Python /api/* shadow endpoints
- `app.py`: four new JSON routes (`/api/version`, `/api/adults`, `/api/juniors`, `/api/payments`) mirroring the Go `/api/*` handlers; `_unwrap_view_model_for_api()` helper expands pre-serialised JSON strings and renames `month_labels_json``month_labels`, `raw_payments_json``raw_payments` to match Go wire contract.
- `tests/test_app.py`: four new smoke tests asserting top-level key sets and that unwrapped fields are objects (not strings).
## 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`.

505
app.py
View File

@@ -7,7 +7,7 @@ import os
import io
import qrcode
import logging
from flask import Flask, render_template, g, send_file, request
from flask import Flask, render_template, g, send_file, request, jsonify
# Configure logging, allowing override via LOG_LEVEL environment variable
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
@@ -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__)
@@ -100,6 +68,16 @@ BUILD_META = _json.loads(_meta_path.read_text()) if _meta_path.exists() else {
"tag": "dev", "commit": "local", "build_date": ""
}
def _unwrap_view_model_for_api(vm: dict) -> dict:
"""Expand pre-stringified JSON fields and rename to match Go API contract."""
out = dict(vm)
out["member_data"] = _json.loads(out.pop("member_data"))
out["month_labels"] = _json.loads(out.pop("month_labels_json"))
out["raw_payments"] = _json.loads(out.pop("raw_payments_json"))
return out
warmup_cache()
@app.before_request
@@ -176,386 +154,151 @@ def sync_bank():
def version():
return BUILD_META
@app.route("/api/version")
def api_version():
return jsonify(BUILD_META)
@app.route("/api/adults")
def api_adults():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
if not members_data:
return jsonify({"error": "no data"}), 503
members, sorted_months = members_data
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
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},
)
result = reconcile(members, sorted_months, transactions, exceptions)
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,
)
return jsonify(_unwrap_view_model_for_api(vm))
@app.route("/api/juniors")
def api_juniors():
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"
junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
if not junior_members_data:
return jsonify({"error": "no data"}), 503
junior_members, sorted_months = junior_members_data
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
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},
)
adapted_members = adapt_junior_members(junior_members)
result = reconcile(adapted_members, sorted_months, transactions, exceptions)
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,
)
return jsonify(_unwrap_view_model_for_api(vm))
@app.route("/api/payments")
def api_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"
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
adults_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
juniors_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
member_names = []
if adults_data:
member_names.extend(name for name, _, _ in adults_data[0])
if juniors_data:
member_names.extend(name for name, _, _ in juniors_data[0])
vm = build_payments_view_model(
transactions, member_names,
attendance_url=attendance_url, payments_url=payments_url,
)
return jsonify(vm)
@app.route("/adults")
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 +310,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():

View File

@@ -97,9 +97,9 @@ 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
- [ ] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation
- [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`
- [x] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation — `40e4a9e`
- [ ] **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
**Gate:** For each route, `make parity` reports zero non-allowlisted diffs across the M3 fixture corpus.

View 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 150200 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:4177](app.py#L41-L77) into `scripts/views.py`. Update the
import in `app.py`.
- Implement `build_adults_view_model` by extracting
[app.py:200344](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:370550](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:570586](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: ~2530 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.1M5.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.

View File

@@ -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.

View File

@@ -0,0 +1,113 @@
# M5.3 — Python `/api/X` shadow endpoints
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) (M5.3 row)
- [2026-05-07-1431-m5-json-api-parity.md](2026-05-07-1431-m5-json-api-parity.md) (Python view-model extraction prep)
- [2026-05-07-1650-go-rewrite-m5-1-api-structs-schemas.md](2026-05-07-1650-go-rewrite-m5-1-api-structs-schemas.md) (Go wire types)
## Context
M5.1 (Go wire types + JSON Schemas) and M5.2 (Go HTTP handlers for `/api/adults` `/api/juniors` `/api/payments` `/api/version`) have merged. M5.3 mirrors the same four endpoints on the Python Flask side so M5.4's `cmd/parity` tool can hit both backends and diff the JSON. After M5.3, every byte the Go side emits has a Python counterpart to compare against.
The Python view-model builders ([scripts/views.py](scripts/views.py)) already produce dicts very close to the wire shape — except three template-only fields (`member_data`, `month_labels_json`, `raw_payments_json`) are pre-`json.dumps`'d for inline `<script>` blocks. M5.1's plan called this out explicitly: M5.3's `/api/X` is `jsonify(unwrap_json_strings(view_model_dict))` — a 4-line shim, not real transformation logic.
## Approach
Add four shadow routes to [app.py](app.py) and one private unwrap helper. Builders and templates are untouched.
### Decisions
1. **Unwrap shim lives in `app.py`**, not `scripts/views.py`. It's 4 lines, only the API routes use it, and it's parity-only scaffolding that M8 will delete. Keeping `views.py` free of HTTP-layer concerns means cleaner deletion later.
2. **No data-loading helper extraction.** Each shadow route duplicates ~8 lines of cache loads from its sibling HTML route. A helper would have to thread `attendance_url` / `payments_url` / `bank_account` and the adults-vs-juniors-vs-payments branching back out — net negative for code that M8 will erase wholesale.
3. **Drop `record_step` calls in API routes.** `record_step` only feeds `inject_render_time` (a Jinja `context_processor`); JSON responses don't go through templates, so timing breakdown has no consumer.
4. **`/api/version` is a one-liner.** `BUILD_META` already has the keys (`tag`, `commit`, `build_date`) Go emits. Just `jsonify(BUILD_META)`. Existing `/version` route stays as-is — the new endpoint sits alongside.
5. **Tests assert key sets and unwrap, not values.** Hard-code `EXPECTED_ADULTS_KEYS` etc. as module constants in [tests/test_app.py](tests/test_app.py). Catches drift in unit tests rather than waiting for M5.4 parity diffs. Type-check the unwrapped fields (`isinstance(member_data, dict)` etc.) to prove the shim ran.
### The shim
```python
def _unwrap_view_model_for_api(vm: dict) -> dict:
out = dict(vm)
out["member_data"] = _json.loads(out.pop("member_data"))
out["month_labels"] = _json.loads(out.pop("month_labels_json"))
out["raw_payments"] = _json.loads(out.pop("raw_payments_json"))
return out
```
Note the **rename**: `month_labels_json``month_labels`, `raw_payments_json``raw_payments` (matches Go contract per [adults.go](go/internal/web/api/adults.go) JSON tags).
### Route skeleton (`/api/adults`)
```python
@app.route("/api/adults")
def api_adults():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
if not members_data:
return jsonify({"error": "no data"}), 503
members, sorted_months = members_data
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
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},
)
result = reconcile(members, sorted_months, transactions, exceptions)
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,
)
return jsonify(_unwrap_view_model_for_api(vm))
```
`/api/juniors` mirrors with `adapt_junior_members` + `JUNIOR_SHEET_GID` + `build_juniors_view_model`. `/api/payments` skips the unwrap (its builder has no JSON-string fields): `return jsonify(vm)`. `/api/version`: `return jsonify(BUILD_META)`.
## Files to modify
- [app.py](app.py)
- L10: add `jsonify` to `from flask import ...`.
- Add `_unwrap_view_model_for_api` near [BUILD_META](app.py#L67) (it already imports `json as _json`).
- Add four routes: `/api/version`, `/api/adults`, `/api/juniors`, `/api/payments`. Place them after [`/version`](app.py#L143) for grouping.
- [tests/test_app.py](tests/test_app.py)
- Add `EXPECTED_ADULTS_KEYS`, `EXPECTED_JUNIORS_KEYS`, `EXPECTED_PAYMENTS_KEYS`, `EXPECTED_VERSION_KEYS` module constants (sourced from [adults.go](go/internal/web/api/adults.go) / [juniors.go](go/internal/web/api/juniors.go) / [payments.go](go/internal/web/api/payments.go) / [version.go](go/internal/web/api/version.go) JSON tags).
- Four new test functions: `test_api_adults`, `test_api_juniors`, `test_api_payments`, `test_api_version`. Reuse the existing `_bypass_cache` patcher and the `fetch_sheet_data` / `fetch_exceptions` / `get_members_with_fees` / `get_junior_members_with_fees` mocks already in the file.
- Each adults/juniors test asserts: `200`, `response.is_json`, `set(json.keys()) == EXPECTED_*_KEYS`, `isinstance(json["member_data"], dict)`, `isinstance(json["month_labels"], dict)`, `isinstance(json["raw_payments"], dict)`.
- [CHANGELOG.md](CHANGELOG.md): post-merge entry per CLAUDE.md format.
- [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:102](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L102): tick M5.3 with merge SHA.
## Reusable existing code
- [`build_adults_view_model`](scripts/views.py#L64), [`build_juniors_view_model`](scripts/views.py#L240), [`build_payments_view_model`](scripts/views.py#L432) — call as-is, no changes.
- [`reconcile`](scripts/match_payments.py) and [`adapt_junior_members`](scripts/views.py#L48) — same.
- [`get_cached_data`](app.py#L36), [`fetch_sheet_data`](scripts/match_payments.py), [`fetch_exceptions`](scripts/match_payments.py), [`get_members_with_fees`](scripts/attendance.py), [`get_junior_members_with_fees`](scripts/attendance.py) — same call sites as the HTML routes.
- [`BUILD_META`](app.py#L67) — already shaped to match Go's `VersionResponse`.
## Verification
1. `make test` — all existing tests still pass; four new `test_api_*` tests pass.
2. `python -c "import app"` — catches the missing `jsonify` import.
3. `make web-py`, then:
- `curl -s localhost:5001/api/version | jq .``{tag, commit, build_date}`.
- `curl -s localhost:5001/api/adults | jq 'keys'` → 14 keys, no `_json` suffix anywhere.
- `curl -s localhost:5001/api/adults | jq '.member_data | type, .month_labels | type, .raw_payments | type'` → all `"object"` (proves unwrap).
- Same checks on `/api/juniors` and `/api/payments`.
4. Visit `/adults`, `/juniors`, `/payments` in browser — HTML still renders identically (regression check on builders).
5. **Optional pre-M5.4 peek:** `make web-go` on :8080 + `make web-py` on :5001, then `diff <(curl -s :5001/api/adults | jq -S .) <(curl -s :8080/api/adults | jq -S .)`. Expect non-zero diff (raw transaction key shape, see below) — that is fine; M5.4 surfaces and resolves these.
## Out of scope (M5.4 will surface and resolve)
These are known parity friction points; **don't fix in M5.3** — the whole point of M5.4 is to enumerate them:
- **`raw_payments[name][i]` row shape**: Python emits raw Google Sheets row dicts with column-header keys (e.g. `"VS"`, `"Sync ID"`); Go's `RawTransaction` uses snake_case (`vs`, `sync_id`). Keys and types will diverge.
- **`unmatched[]`**: same divergence (same raw row dicts).
- **`null` vs missing keys**: Python omits keys never set; Go zero-value structs may emit `null`/`""` depending on `omitempty`.
- **`Decimal` / float precision** in `grouped_payments` amounts.
- **Field insertion order**: `jsonify` preserves insertion order (Python 3.7+); Go marshals struct fields in declaration order. Likely fine, parity tool will tell.
## Branch + MR
Per CLAUDE.md: branch `feat/go-m5-3-python-api-shadow`, push with `-u`, open MR via `tea pr create --title ... --description ... --base main --head feat/go-m5-3-python-api-shadow`. Do not merge from CLI.

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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=

View File

@@ -50,7 +50,7 @@ func Load() Config {
return Config{
CredentialsPath: env("CREDENTIALS_PATH", ".secret/fuj-management-bot-credentials.json"),
BankAccount: env("BANK_ACCOUNT", "CZ8520100000002800359168"),
CacheDir: env("CACHE_DIR", "tmp"),
CacheDir: env("CACHE_DIR", "tmp/go"),
CacheTTL: envDuration("CACHE_TTL_SECONDS", 300),
CacheAPICheckTTL: envDuration("CACHE_API_CHECK_TTL_SECONDS", 300),
DriveTimeout: envDuration("DRIVE_TIMEOUT_SECONDS", 10),

View File

@@ -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{},

View File

@@ -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)

View File

@@ -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", ""},
}

View File

@@ -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) {

View File

@@ -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 {
@@ -384,9 +394,19 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
return fmt.Sprint(row[i])
}
// getRaw returns row[i] without stringifying — needed for FormatDate to
// dispatch on the underlying numeric type (Sheets returns serial-day
// numbers as float64). Stringifying first defeats that dispatch.
getRaw := func(row []any, i int) any {
if i < 0 || i >= len(row) {
return nil
}
return row[i]
}
var txns []reconcile.Transaction
for _, row := range rows[1:] {
dateStr := matching.FormatDate(getVal(row, idxDate))
dateStr := matching.FormatDate(getRaw(row, idxDate))
amountRaw := row[idxAmount]
if idxAmount < 0 || idxAmount >= len(row) {
amountRaw = ""
@@ -403,11 +423,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

View File

@@ -114,12 +114,15 @@ func TestLoadJuniors(t *testing.T) {
func TestLoadTransactions(t *testing.T) {
// Sheets fake keyed by "<spreadsheetID>/<range>" — use the real constant.
// Row 1 uses a pre-formatted date string; row 2 uses the numeric Sheets
// serial-day form (float64) — the API returns either depending on cell
// formatting, and FormatDate must handle both.
paymentsKey := config.PaymentsSheetID + "/A1:Z"
sh := &sheets.Fake{Values: map[string][][]any{
paymentsKey: {
{"Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"},
{"2026-04-01", 700.0, "", "Alice", "2026-04", "", "Alice Bank", "", "fee", "", "abc"},
{"2026-05-01", 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"},
{46147.0, 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"}, // 46147 serial-day = 2026-05-05
},
}}
s := buildSources(t, &attendance.Fake{}, sh)
@@ -137,6 +140,12 @@ func TestLoadTransactions(t *testing.T) {
if txns[0].Amount != 700 {
t.Errorf("txn[0].Amount: want 700, got %v", txns[0].Amount)
}
if txns[0].Date != "2026-04-01" {
t.Errorf("txn[0].Date: want 2026-04-01, got %q", txns[0].Date)
}
if txns[1].Date != "2026-05-05" {
t.Errorf("txn[1].Date (numeric serial-day): want 2026-05-05, got %q", txns[1].Date)
}
}
func TestLoadExceptions(t *testing.T) {

View 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"`
}

View 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
}

View 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
}

View 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}
}

View 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",
}
}

View 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)
}

View 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"`
}

View 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"`
}

View 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)
}
})
}
}

View 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"`
}

View 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"`
}

View File

@@ -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))

View 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"
]
}
}
}

View 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"
]
}
}
}

View 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"
]
}
}
}

View 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
View 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,
)

View File

@@ -1,7 +1,17 @@
import unittest
import json
from unittest.mock import patch
from app import app
EXPECTED_ADULTS_KEYS = {
"months", "raw_months", "results", "totals", "member_data", "month_labels",
"raw_payments", "credits", "debts", "unmatched", "attendance_url",
"payments_url", "bank_account", "current_month",
}
EXPECTED_JUNIORS_KEYS = EXPECTED_ADULTS_KEYS
EXPECTED_PAYMENTS_KEYS = {"grouped_payments", "sorted_people", "attendance_url", "payments_url"}
EXPECTED_VERSION_KEYS = {"tag", "commit", "build_date"}
def _bypass_cache(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
"""Test helper: call fetch_func directly, bypassing the cache layer."""
@@ -97,5 +107,80 @@ class TestWebApp(unittest.TestCase):
self.assertIn(b'500/500 CZK', response.data)
self.assertIn(b'?', response.data)
def test_api_version(self):
"""Test /api/version returns BUILD_META keys as JSON."""
response = self.client.get('/api/version')
self.assertEqual(response.status_code, 200)
self.assertTrue(response.is_json)
data = json.loads(response.data)
self.assertEqual(set(data.keys()), EXPECTED_VERSION_KEYS)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.fetch_sheet_data')
@patch('app.fetch_exceptions', return_value={})
@patch('app.get_members_with_fees')
def test_api_adults(self, mock_get_members, mock_exceptions, mock_fetch_sheet, mock_cache):
"""Test /api/adults returns JSON with correct top-level keys and unwrapped fields."""
mock_get_members.return_value = (
[('Test Member', 'A', {'2026-01': (750, 4)})],
['2026-01']
)
mock_fetch_sheet.return_value = [{
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
'purpose': '2026-01', 'message': 'test payment',
'sender': 'External Bank User', 'inferred_amount': 750,
}]
response = self.client.get('/api/adults')
self.assertEqual(response.status_code, 200)
self.assertTrue(response.is_json)
data = json.loads(response.data)
self.assertEqual(set(data.keys()), EXPECTED_ADULTS_KEYS)
self.assertIsInstance(data['member_data'], dict)
self.assertIsInstance(data['month_labels'], dict)
self.assertIsInstance(data['raw_payments'], dict)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.fetch_sheet_data')
@patch('app.fetch_exceptions', return_value={})
@patch('app.get_junior_members_with_fees')
def test_api_juniors(self, mock_get_junior_members, mock_exceptions, mock_fetch_sheet, mock_cache):
"""Test /api/juniors returns JSON with correct top-level keys and unwrapped fields."""
mock_get_junior_members.return_value = (
[
('Junior One', 'J', {'2026-01': (500, 3, 0, 3)}),
('Junior Two', 'X', {'2026-01': ('?', 1, 0, 1)}),
],
['2026-01']
)
mock_fetch_sheet.return_value = [{
'date': '2026-01-15', 'amount': 500, 'person': 'Junior One',
'purpose': '2026-01', 'message': '', 'sender': 'Parent', 'inferred_amount': 500,
}]
response = self.client.get('/api/juniors')
self.assertEqual(response.status_code, 200)
self.assertTrue(response.is_json)
data = json.loads(response.data)
self.assertEqual(set(data.keys()), EXPECTED_JUNIORS_KEYS)
self.assertIsInstance(data['member_data'], dict)
self.assertIsInstance(data['month_labels'], dict)
self.assertIsInstance(data['raw_payments'], dict)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.fetch_sheet_data')
def test_api_payments(self, mock_fetch_sheet, mock_cache):
"""Test /api/payments returns JSON with correct top-level keys."""
mock_fetch_sheet.return_value = [{
'date': '2026-01-01', 'amount': 750, 'person': 'Test Member',
'purpose': '2026-01', 'message': 'test', 'sender': 'Someone',
}]
response = self.client.get('/api/payments')
self.assertEqual(response.status_code, 200)
self.assertTrue(response.is_json)
data = json.loads(response.data)
self.assertEqual(set(data.keys()), EXPECTED_PAYMENTS_KEYS)
self.assertIsInstance(data['grouped_payments'], dict)
self.assertIsInstance(data['sorted_people'], list)
if __name__ == '__main__':
unittest.main()