Compare commits
19 Commits
be4ecef20f
...
fix/py-pay
| Author | SHA1 | Date | |
|---|---|---|---|
| 07ca1cd9e1 | |||
| 5dcac25c13 | |||
| fc47606b1c | |||
| 65694ad378 | |||
| 092dff25a5 | |||
| 56c21bcf03 | |||
| 208f762c18 | |||
| 4d035213b5 | |||
| 2b15280d03 | |||
| 723152cdad | |||
| fe0e49a134 | |||
| e5a272b682 | |||
| 8b3064ffab | |||
| 423c3e2a4b | |||
| f4c497681f | |||
| 40e4a9e45e | |||
| 68810369bd | |||
| 2b7eff14c4 | |||
| 7d48e8f607 |
45
CHANGELOG.md
45
CHANGELOG.md
@@ -1,5 +1,50 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-05-07 23:51 CEST — feat(py): M5.4 fix #2 — add vs and sync_id to payments tx projection
|
||||||
|
|
||||||
|
- `scripts/match_payments.py`: `fetch_sheet_data` now reads `VS` and `Sync ID` columns and includes `vs`/`sync_id` keys in every tx dict. Previously only 9 columns were projected, causing `make parity` to report extra `vs`/`sync_id` fields on every raw payment row emitted by the Go backend. Values flow through `group_payments_by_person` → `_unwrap_view_model_for_api` to `raw_payments` (adults/juniors) and `grouped_payments` (payments) automatically.
|
||||||
|
- `tests/test_app.py`: updated `/api/*` mock fixtures to include `vs`/`sync_id` keys for realism.
|
||||||
|
- **Cache note**: after deploying, hit `POST /flush-cache` once so the in-process cache is cleared and the next request picks up the new column lookups.
|
||||||
|
|
||||||
|
## 2026-05-07 23:37 CEST — fix(go): accept single-digit day/month in attendance date headers
|
||||||
|
|
||||||
|
- `go/internal/services/membership/sources.go`: `parseDates` now uses Go time formats `2.1.2006` and `1/2/2006` (single-digit reference forms, which accept both padded and unpadded inputs) instead of `02.01.2006` and `01/02/2006`. The Czech attendance sheet headers contain dates like `1.6.2026`, `23.3.2026`, `6.4.2026` — Go silently dropped those columns under the strict zero-padded format, while Python's `strptime("%d.%m.%Y")` accepted them. Effect was a missing `2026-06` month entirely on `/api/juniors` plus undercounted attendance for any month with single-digit columns; both surfaced as diffs in `make parity`.
|
||||||
|
- `sources_test.go::TestParseDates_SingleDigitDayMonth` added as a regression guard covering both Czech and US format flavours with and without leading zeros.
|
||||||
|
|
||||||
|
## 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:55 CEST — feat(go): M5.4 — parity diff binary + `make parity`
|
||||||
|
|
||||||
|
- `go/cmd/parity/main.go`: new standalone binary that GETs `/api/adults`, `/api/juniors`, `/api/payments` from both Python (:5001) and Go (:8080) backends, scrubs an allowlist (`render_time.total`), and prints `cmp.Diff` for any remaining differences. Exits 0 on full match, 1 on diffs, 2 on fetch/parse errors — CI-friendly for M7.2. `/api/version` is excluded by design (returns binary identity — tag/commit/build_date — which differs between independently built backends); still accessible via `make parity ARGS="-route /api/version"`.
|
||||||
|
- `go/cmd/parity/scrub_test.go`: 4 unit tests covering top-level delete, nested delete, missing path, and non-map parent.
|
||||||
|
- `go/go.mod`: `github.com/google/go-cmp` promoted to direct dependency.
|
||||||
|
- `Makefile`: `parity` target added (`.PHONY`, help, `cd go && go run ./cmd/parity`).
|
||||||
|
- `docs/plans/2026-05-07-2254-m5-4-parity-binary.md`: plan archived.
|
||||||
|
|
||||||
|
## 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
|
## 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.
|
- New `go/internal/web/api/` package: `AdultsResponse`, `JuniorsResponse`, `PaymentsResponse`, `VersionResponse` with explicit `json:` tags matching Python view-model keys.
|
||||||
|
|||||||
6
Makefile
6
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-sync-debug go-lint capture-fixtures image run sync sync-2026 test test-v docs
|
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-sync-debug go-lint capture-fixtures parity image run sync sync-2026 test test-v docs
|
||||||
|
|
||||||
export PYTHONPATH := scripts:$(PYTHONPATH)
|
export PYTHONPATH := scripts:$(PYTHONPATH)
|
||||||
VENV := .venv
|
VENV := .venv
|
||||||
@@ -29,6 +29,7 @@ help:
|
|||||||
@echo " make go-lint - Run golangci-lint on Go code"
|
@echo " make go-lint - Run golangci-lint on Go code"
|
||||||
@echo " make go-sync-debug [DAYS=N] - Dry-run Go sync with Fio debug logs and txn table (default DAYS=30)"
|
@echo " make go-sync-debug [DAYS=N] - Dry-run Go sync with Fio debug logs and txn table (default DAYS=30)"
|
||||||
@echo " make capture-fixtures - Regenerate parity fixture corpus from live Python"
|
@echo " make capture-fixtures - Regenerate parity fixture corpus from live Python"
|
||||||
|
@echo " make parity - Diff /api/* between web-py (:5001) and web-go (:8080); both must be running"
|
||||||
@echo " make image - Build Python OCI container image"
|
@echo " make image - Build Python OCI container image"
|
||||||
@echo " make run - Run the built Python Docker image locally"
|
@echo " make run - Run the built Python Docker image locally"
|
||||||
@echo " make sync - Sync Fio transactions to Google Sheets"
|
@echo " make sync - Sync Fio transactions to Google Sheets"
|
||||||
@@ -102,6 +103,9 @@ go-lint:
|
|||||||
web-go: go-build
|
web-go: go-build
|
||||||
./$(GO_BIN) server
|
./$(GO_BIN) server
|
||||||
|
|
||||||
|
parity:
|
||||||
|
cd $(GO_SRC) && go run ./cmd/parity $(ARGS)
|
||||||
|
|
||||||
image:
|
image:
|
||||||
docker build -t fuj-management:latest \
|
docker build -t fuj-management:latest \
|
||||||
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \
|
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \
|
||||||
|
|||||||
81
app.py
81
app.py
@@ -7,7 +7,7 @@ import os
|
|||||||
import io
|
import io
|
||||||
import qrcode
|
import qrcode
|
||||||
import logging
|
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
|
# Configure logging, allowing override via LOG_LEVEL environment variable
|
||||||
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
|
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
|
||||||
@@ -68,6 +68,16 @@ BUILD_META = _json.loads(_meta_path.read_text()) if _meta_path.exists() else {
|
|||||||
"tag": "dev", "commit": "local", "build_date": ""
|
"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()
|
warmup_cache()
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
@@ -144,6 +154,75 @@ def sync_bank():
|
|||||||
def version():
|
def version():
|
||||||
return BUILD_META
|
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")
|
@app.route("/adults")
|
||||||
def adults_view():
|
def adults_view():
|
||||||
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md).
|
Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md).
|
||||||
|
|
||||||
**Current milestone:** M4 — IO layer behind interfaces ✅
|
**Current milestone:** M5 — JSON-only `/api/...` routes ✅
|
||||||
**Started:** 2026-05-04
|
**Started:** 2026-05-04
|
||||||
**Last updated:** 2026-05-07
|
**Last updated:** 2026-05-07 (M5.4)
|
||||||
|
|
||||||
## How to use
|
## How to use
|
||||||
|
|
||||||
@@ -98,9 +98,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.
|
Goal: byte-equal JSON between Python and Go for every route. This is the parity contract.
|
||||||
|
|
||||||
- [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.1** Hand-author Go structs for `/api/adults`, `/api/juniors`, `/api/payments`, `/api/version` with explicit `json:` tags matching Python keys; emit JSON Schemas via `github.com/invopop/jsonschema` to `tests/fixtures/api-schema/` — `f253e3f`
|
||||||
- [ ] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs
|
- [x] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs — `7d48e8f`
|
||||||
- [ ] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation
|
- [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
|
- [x] **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.
|
**Gate:** For each route, `make parity` reports zero non-allowlisted diffs across the M3 fixture corpus.
|
||||||
|
|
||||||
@@ -154,6 +154,7 @@ Goal: Go is the one true backend.
|
|||||||
|
|
||||||
(Add entries as you go. Format: `YYYY-MM-DD — short note`.)
|
(Add entries as you go. Format: `YYYY-MM-DD — short note`.)
|
||||||
|
|
||||||
|
- 2026-05-07 — `/api/version` excluded from parity diff by design. Each binary's tag/commit/build_date is identity, not a data contract — diffing it would always flag a diff between independently built backends. Route remains reachable via `make parity ARGS="-route /api/version"` for manual inspection.
|
||||||
- 2026-05-04 — Plan approved. Versioning policy: latest stable for Go and all libs at the time M1 starts. Frontends explicitly allowed to diverge between Python and Go; only the JSON API contract is parity-locked. No reverse proxy — both backends run on different ports via `make web-py` / `make web-go`.
|
- 2026-05-04 — Plan approved. Versioning policy: latest stable for Go and all libs at the time M1 starts. Frontends explicitly allowed to diverge between Python and Go; only the JSON API contract is parity-locked. No reverse proxy — both backends run on different ports via `make web-py` / `make web-go`.
|
||||||
- 2026-05-07 — M4 complete. Chose fakes-only unit tests (no live integration tests) and CSV-via-public-URL for attendance (no Sheets API auth required for read-only). golangci-lint gofumpt extra-rules differ slightly from standalone gofumpt; used `golangci-lint run --fix --enable-only gofumpt` to auto-resolve formatting.
|
- 2026-05-07 — M4 complete. Chose fakes-only unit tests (no live integration tests) and CSV-via-public-URL for attendance (no Sheets API auth required for read-only). golangci-lint gofumpt extra-rules differ slightly from standalone gofumpt; used `golangci-lint run --fix --enable-only gofumpt` to auto-resolve formatting.
|
||||||
- 2026-05-04 — M1 complete. Dockerfile base changed from `distroless/static:nonroot` → `alpine:3` for debuggability (can tighten later). CLI dispatcher uses stdlib `flag`; module path `fuj-management/go`. golangci-lint v1 embedded gofumpt merges all imports into one group (no stdlib/local split) — accepted as the project style.
|
- 2026-05-04 — M1 complete. Dockerfile base changed from `distroless/static:nonroot` → `alpine:3` for debuggability (can tighten later). CLI dispatcher uses stdlib `flag`; module path `fuj-management/go`. golangci-lint v1 embedded gofumpt merges all imports into one group (no stdlib/local split) — accepted as the project style.
|
||||||
|
|||||||
113
docs/plans/2026-05-07-2114-go-rewrite-m5-3-python-api-shadow.md
Normal file
113
docs/plans/2026-05-07-2114-go-rewrite-m5-3-python-api-shadow.md
Normal 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.
|
||||||
152
docs/plans/2026-05-07-2254-m5-4-parity-binary.md
Normal file
152
docs/plans/2026-05-07-2254-m5-4-parity-binary.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# M5.4 — Parity diff binary (`cmd/parity`) + `make parity`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Per [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md:103](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md#L103), M5.4 is the next milestone in the Go rewrite. M5.1–M5.3 already landed:
|
||||||
|
|
||||||
|
- M5.1: hand-authored Go response structs at [go/internal/web/api/](go/internal/web/api/) + JSON Schemas in [go/tests/fixtures/api-schema/](go/tests/fixtures/api-schema/).
|
||||||
|
- M5.2: Go `/api/version|adults|juniors|payments` handlers in [go/internal/web/api/handler.go](go/internal/web/api/handler.go).
|
||||||
|
- M5.3: Python shadow endpoints in [app.py:157-224](app.py#L157-L224) using `_unwrap_view_model_for_api` for the same JSON shape.
|
||||||
|
|
||||||
|
What's missing: a tool that proves the two backends actually agree on the wire. The M5 gate says "byte-equal JSON between Python and Go for every route." Without a diffing tool, drift between the two implementations slips in silently and we can't gate further milestones (M6 frontend, M7 watch period) on parity.
|
||||||
|
|
||||||
|
This task delivers the **parity contract enforcer**: a Go binary that fetches `/api/*` from both backends, scrubs an allowlist of expected diffs, and prints `cmp.Diff` for everything else. Both backends read the same live Google Sheets — that shared state is what makes parity meaningful, no scenario fixtures needed.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
- New binary `go/cmd/parity/main.go` plus a small support package for the allowlist scrubber.
|
||||||
|
- A unit test for the scrubber.
|
||||||
|
- New `make parity` target.
|
||||||
|
- `go-cmp` promoted to a direct dependency.
|
||||||
|
|
||||||
|
Out of scope (explicitly):
|
||||||
|
- CI integration / nightly job — that's M7.2.
|
||||||
|
- Fixture-driven offline parity — pure-fn parity already runs via `make go-parity` (M3).
|
||||||
|
- Hooking `make parity` into `make go-test-all` — leave it manual since it requires two live servers.
|
||||||
|
|
||||||
|
## Approach
|
||||||
|
|
||||||
|
### 1. New binary: `go/cmd/parity/main.go`
|
||||||
|
|
||||||
|
Standalone binary (mirrors task spec — not a subcommand of `fuj`). Stdlib `flag`, no third-party CLI lib.
|
||||||
|
|
||||||
|
**Flags:**
|
||||||
|
- `-py` — Python base URL, default `http://localhost:5001`
|
||||||
|
- `-go` — Go base URL, default `http://localhost:8080`
|
||||||
|
- `-route` — optional single route to diff (e.g. `/api/adults`); empty means iterate all four
|
||||||
|
- `-timeout` — per-request timeout, default `30s` (sheet fetches are slow on cold cache)
|
||||||
|
|
||||||
|
**Routes**, hard-coded:
|
||||||
|
```go
|
||||||
|
var routes = []string{"/api/version", "/api/adults", "/api/juniors", "/api/payments"}
|
||||||
|
```
|
||||||
|
All `GET`, no query params (verified in [app.py:157-224](app.py#L157-L224) and [go/internal/web/api/handler.go:27-68](go/internal/web/api/handler.go#L27-L68)).
|
||||||
|
|
||||||
|
**Per route, the binary:**
|
||||||
|
1. `GET py+route` and `GET go+route` (sequential — keep it simple; total wall time ~1–2s on warm cache).
|
||||||
|
2. Verify both return HTTP 200; on non-200, print body and mark route as ERROR.
|
||||||
|
3. Decode each body into `map[string]any` (NOT into `api.AdultsResponse` — using the typed struct would silently drop unknown Python-side keys, defeating the diff. Map gives `cmp.Diff` clean dotted-path field names for free.).
|
||||||
|
4. Run `scrub(m, allowlist)` on both decoded maps.
|
||||||
|
5. `diff := cmp.Diff(pyMap, goMap, cmpopts.EquateEmpty())`.
|
||||||
|
6. If `diff != ""`, print `=== /api/X ===` header followed by the diff; track for exit code.
|
||||||
|
|
||||||
|
**Exit codes:**
|
||||||
|
- `0` — all routes match (or the only diffs were under the allowlist).
|
||||||
|
- `1` — at least one route had a non-allowlisted diff.
|
||||||
|
- `2` — at least one route failed to fetch / parse (HTTP error, timeout, non-JSON body).
|
||||||
|
|
||||||
|
This makes the binary CI-friendly when M7.2 lands.
|
||||||
|
|
||||||
|
**Output**: human-readable to stdout — header per route, then "OK" or the diff. Final summary line: `parity: 4/4 routes match` or `parity: 2/4 match, 1 diff, 1 error`.
|
||||||
|
|
||||||
|
### 2. Allowlist scrubber
|
||||||
|
|
||||||
|
Live in same package (`main`). Keep it tiny — no need for a separate sub-package.
|
||||||
|
|
||||||
|
```go
|
||||||
|
var defaultAllowlist = []string{"render_time.total", "build_meta"}
|
||||||
|
|
||||||
|
// scrub walks m and deletes any key whose dotted path matches an allowlist entry.
|
||||||
|
// "render_time.total" deletes m["render_time"]["total"] only (preserves render_time.breakdown).
|
||||||
|
// "build_meta" (no dot) deletes m["build_meta"] entirely.
|
||||||
|
func scrub(m map[string]any, paths []string) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
Today these fields **don't appear in the JSON** — they're Jinja template-only ([app.py:91-110](app.py#L91-L110)) and the Go side only logs render time via [go/internal/web/middleware/timer.go](go/internal/web/middleware/timer.go). So today the scrub is a no-op. The implementation is forward-compatible insurance: if someone later adds either field to a JSON response on one side only, the parity binary already tolerates it.
|
||||||
|
|
||||||
|
The implementation note in [go/internal/web/middleware/timer.go](go/internal/web/middleware/timer.go) ("the elapsed value maps to render_time.total in the M5 JSON allowlist") confirms this is the intended design.
|
||||||
|
|
||||||
|
### 3. Unit test
|
||||||
|
|
||||||
|
Add `go/cmd/parity/scrub_test.go` with a couple of cases:
|
||||||
|
- Scrubbing top-level key (`build_meta`) — key removed; siblings untouched.
|
||||||
|
- Scrubbing nested key (`render_time.total`) — only `total` removed; `breakdown` preserved.
|
||||||
|
- Path that doesn't exist — no-op, no error.
|
||||||
|
- Map without the parent key — no-op (don't panic).
|
||||||
|
|
||||||
|
Runs as part of normal `make go-test` (no `-tags` needed).
|
||||||
|
|
||||||
|
### 4. Makefile target
|
||||||
|
|
||||||
|
Add to [Makefile](Makefile):
|
||||||
|
|
||||||
|
```make
|
||||||
|
parity:
|
||||||
|
cd $(GO_SRC) && go run ./cmd/parity $(ARGS)
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `parity` to the `.PHONY` line at [Makefile:1](Makefile#L1) and a `help` entry like:
|
||||||
|
|
||||||
|
```
|
||||||
|
@echo " make parity - Diff /api/* between web-py (:5001) and web-go (:8080); both must be running"
|
||||||
|
```
|
||||||
|
|
||||||
|
Don't depend on `go-build` — `go run` compiles ad-hoc, and parity is interactive enough that the slight rebuild cost doesn't matter.
|
||||||
|
|
||||||
|
### 5. Dependency
|
||||||
|
|
||||||
|
`github.com/google/go-cmp v0.7.0` is already a transitive dep ([go/go.sum:24-25](go/go.sum#L24-L25)) but not in `go.mod`'s `require` block. After adding the import, run `go mod tidy` inside `go/` to promote it to direct. No new external deps.
|
||||||
|
|
||||||
|
## Files to add / modify
|
||||||
|
|
||||||
|
**Add:**
|
||||||
|
- [go/cmd/parity/main.go](go/cmd/parity/main.go) — flags, route loop, fetch+scrub+diff, exit codes
|
||||||
|
- [go/cmd/parity/scrub_test.go](go/cmd/parity/scrub_test.go) — unit tests for the scrubber
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- [Makefile](Makefile) — `.PHONY`, help block, `parity` target
|
||||||
|
- [go/go.mod](go/go.mod) — promote `go-cmp` to direct (via `go mod tidy`)
|
||||||
|
- [go/go.sum](go/go.sum) — likely unchanged (already pinned)
|
||||||
|
- [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) — tick M5.4, add commit SHA
|
||||||
|
- [CHANGELOG.md](CHANGELOG.md) — entry per project convention
|
||||||
|
|
||||||
|
**Per [CLAUDE.md](CLAUDE.md) plans convention:** copy this plan to `docs/plans/2026-05-07-HHMM-m5-4-parity-binary.md` (timestamp via `date "+%Y-%m-%d-%H%M"`) before opening the MR, since plan files are committed for posterity.
|
||||||
|
|
||||||
|
## Existing utilities to reuse
|
||||||
|
|
||||||
|
- `cmp.Diff` + `cmpopts.EquateEmpty()` from `github.com/google/go-cmp/cmp` (already in go.sum).
|
||||||
|
- Stdlib `net/http`, `encoding/json`, `flag` — sufficient; no need to pull a CLI framework.
|
||||||
|
- No existing scrubber to reuse — the one in [scripts/scrub_fixtures.py](scripts/scrub_fixtures.py) operates on the *capture* path and renames PII; the parity scrubber is a different concern (path-based deletion of expected diffs).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Manual smoke test (the binary is meant to be run against live servers):
|
||||||
|
|
||||||
|
1. **Sanity / build:** `cd go && go build ./cmd/parity && go test ./cmd/parity/...` — compiles + scrubber unit test passes.
|
||||||
|
2. **Both backends up:**
|
||||||
|
- Terminal A: `make web-py`
|
||||||
|
- Terminal B: `make web-go`
|
||||||
|
- Terminal C: `make parity`
|
||||||
|
Expected: `parity: 4/4 routes match`, exit 0.
|
||||||
|
3. **Negative test:** add a literal extra field to one Go handler temporarily (e.g. `"diagnostic": "test"` in `VersionResponse`), rebuild, re-run `make parity`. Expected: non-zero exit, diff shown for `/api/version`. Revert.
|
||||||
|
4. **Allowlist test:** in a unit test (or by manually constructing a payload with `render_time.total` injected), confirm the scrubber removes it before the diff stage.
|
||||||
|
5. **CHANGELOG** entry added; progress tracker ticked with the merge commit SHA.
|
||||||
|
|
||||||
|
Per [CLAUDE.md](CLAUDE.md) branching policy: this is a feature, so work happens on `feat/go-m5-4-parity-binary`, push with `-u`, open MR with `tea pr create --base main --head feat/go-m5-4-parity-binary`. User merges in Gitea.
|
||||||
|
|
||||||
|
## Notes & risks
|
||||||
|
|
||||||
|
- **Cache-warmth dependency:** Both backends cache aggressively. A cold-cache fetch on Python can take 30s+ — hence the configurable timeout. If parity is run immediately after `flush-cache`, expect the first run to be slow.
|
||||||
|
- **Order-sensitive lists:** `cmp.Diff` on `map[string]any` treats slices as ordered. If either backend ever returns members/transactions in a different order, the diff will flag it — that's a real parity bug, not a false positive, so this is correct behavior. If we hit ordering instability later, fix the source, don't add `cmpopts.SortSlices`.
|
||||||
|
- **Float formatting:** Both sides go through `encoding/json` (Go) and `jsonify` (Python `json.dumps`). Floats in totals/balances may format differently (`123` vs `123.0`). If this surfaces, the Go side already uses typed structs with `int` where appropriate — investigate at the source rather than allowlisting.
|
||||||
@@ -73,10 +73,18 @@ func serverCmd(args []string) {
|
|||||||
cfg.ServerAddr = *addr
|
cfg.ServerAddr = *addr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
logger := logging.New(cfg.LogLevel)
|
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}
|
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)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|||||||
133
go/cmd/parity/main.go
Normal file
133
go/cmd/parity/main.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/google/go-cmp/cmp/cmpopts"
|
||||||
|
)
|
||||||
|
|
||||||
|
// /api/version is intentionally excluded — it returns each binary's own build
|
||||||
|
// identity (tag/commit/build_date), which differs by design between independently
|
||||||
|
// built backends. Pass -route /api/version to inspect it manually.
|
||||||
|
var allRoutes = []string{"/api/adults", "/api/juniors", "/api/payments"}
|
||||||
|
|
||||||
|
// defaultAllowlist holds dotted key paths to strip before diffing.
|
||||||
|
// render_time.total is forward-compatible insurance: today it lives in the Jinja
|
||||||
|
// template context only (app.py inject_render_time) and is logged via
|
||||||
|
// middleware/timer.go on the Go side, so it isn't in any JSON response. If either
|
||||||
|
// side ever surfaces it under a render_time envelope, the scrubber handles it.
|
||||||
|
var defaultAllowlist = []string{"render_time.total"}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
pyURL := flag.String("py", "http://localhost:5001", "Python backend base URL")
|
||||||
|
goURL := flag.String("go", "http://localhost:8080", "Go backend base URL")
|
||||||
|
route := flag.String("route", "", "single route to diff, e.g. /api/adults (default: all)")
|
||||||
|
timeout := flag.Duration("timeout", 30*time.Second, "per-request HTTP timeout")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: *timeout}
|
||||||
|
|
||||||
|
targets := allRoutes
|
||||||
|
if *route != "" {
|
||||||
|
targets = []string{*route}
|
||||||
|
}
|
||||||
|
|
||||||
|
matched, diffs, errs := 0, 0, 0
|
||||||
|
|
||||||
|
for _, r := range targets {
|
||||||
|
pyMap, err1 := fetch(client, *pyURL+r)
|
||||||
|
goMap, err2 := fetch(client, *goURL+r)
|
||||||
|
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
fmt.Printf("=== %s ===\n", r)
|
||||||
|
if err1 != nil {
|
||||||
|
fmt.Printf("ERROR (py): %v\n", err1)
|
||||||
|
}
|
||||||
|
if err2 != nil {
|
||||||
|
fmt.Printf("ERROR (go): %v\n", err2)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
errs++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
scrub(pyMap, defaultAllowlist)
|
||||||
|
scrub(goMap, defaultAllowlist)
|
||||||
|
|
||||||
|
diff := cmp.Diff(pyMap, goMap, cmpopts.EquateEmpty())
|
||||||
|
if diff == "" {
|
||||||
|
fmt.Printf("=== %s ===\nOK\n\n", r)
|
||||||
|
matched++
|
||||||
|
} else {
|
||||||
|
fmt.Printf("=== %s ===\n%s\n", r, diff)
|
||||||
|
diffs++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total := len(targets)
|
||||||
|
fmt.Printf("parity: %d/%d routes match", matched, total)
|
||||||
|
if diffs > 0 {
|
||||||
|
fmt.Printf(", %d diff", diffs)
|
||||||
|
if diffs > 1 {
|
||||||
|
fmt.Print("s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if errs > 0 {
|
||||||
|
fmt.Printf(", %d error", errs)
|
||||||
|
if errs > 1 {
|
||||||
|
fmt.Print("s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if errs > 0 {
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
if diffs > 0 {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetch(client *http.Client, url string) (map[string]any, error) {
|
||||||
|
resp, err := client.Get(url) //nolint:noctx
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read body: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(body, &m); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode JSON: %w", err)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrub removes keys from m whose dotted paths appear in paths.
|
||||||
|
// A bare segment (no dot) deletes a top-level key.
|
||||||
|
// A two-segment path "parent.child" deletes child from m["parent"] if it is a map.
|
||||||
|
func scrub(m map[string]any, paths []string) {
|
||||||
|
for _, path := range paths {
|
||||||
|
parts := strings.SplitN(path, ".", 2)
|
||||||
|
if len(parts) == 1 {
|
||||||
|
delete(m, parts[0])
|
||||||
|
} else {
|
||||||
|
if child, ok := m[parts[0]].(map[string]any); ok {
|
||||||
|
delete(child, parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
go/cmd/parity/scrub_test.go
Normal file
57
go/cmd/parity/scrub_test.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestScrubTopLevel(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"build_meta": map[string]any{"tag": "v1"},
|
||||||
|
"other": "keep",
|
||||||
|
}
|
||||||
|
scrub(m, []string{"build_meta"})
|
||||||
|
if _, ok := m["build_meta"]; ok {
|
||||||
|
t.Error("expected build_meta to be removed")
|
||||||
|
}
|
||||||
|
if m["other"] != "keep" {
|
||||||
|
t.Error("expected other to be preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScrubNested(t *testing.T) {
|
||||||
|
m := map[string]any{
|
||||||
|
"render_time": map[string]any{
|
||||||
|
"total": "0.123",
|
||||||
|
"breakdown": "fetch:0.1s",
|
||||||
|
},
|
||||||
|
"other": "keep",
|
||||||
|
}
|
||||||
|
scrub(m, []string{"render_time.total"})
|
||||||
|
rt, ok := m["render_time"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("render_time should still be present")
|
||||||
|
}
|
||||||
|
if _, ok := rt["total"]; ok {
|
||||||
|
t.Error("expected render_time.total to be removed")
|
||||||
|
}
|
||||||
|
if rt["breakdown"] != "fetch:0.1s" {
|
||||||
|
t.Error("expected render_time.breakdown to be preserved")
|
||||||
|
}
|
||||||
|
if m["other"] != "keep" {
|
||||||
|
t.Error("expected other to be preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScrubMissingPath(t *testing.T) {
|
||||||
|
m := map[string]any{"foo": "bar"}
|
||||||
|
scrub(m, []string{"nonexistent", "render_time.total"})
|
||||||
|
if m["foo"] != "bar" {
|
||||||
|
t.Error("expected foo to be preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScrubNestedParentNotMap(t *testing.T) {
|
||||||
|
m := map[string]any{"render_time": "not-a-map"}
|
||||||
|
scrub(m, []string{"render_time.total"})
|
||||||
|
if m["render_time"] != "not-a-map" {
|
||||||
|
t.Error("expected render_time to be unchanged when it is not a map")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ module fuj-management/go
|
|||||||
go 1.26.1
|
go 1.26.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/google/go-cmp v0.7.0
|
||||||
github.com/invopop/jsonschema v0.14.0
|
github.com/invopop/jsonschema v0.14.0
|
||||||
golang.org/x/net v0.53.0
|
golang.org/x/net v0.53.0
|
||||||
golang.org/x/text v0.36.0
|
golang.org/x/text v0.36.0
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func Load() Config {
|
|||||||
return Config{
|
return Config{
|
||||||
CredentialsPath: env("CREDENTIALS_PATH", ".secret/fuj-management-bot-credentials.json"),
|
CredentialsPath: env("CREDENTIALS_PATH", ".secret/fuj-management-bot-credentials.json"),
|
||||||
BankAccount: env("BANK_ACCOUNT", "CZ8520100000002800359168"),
|
BankAccount: env("BANK_ACCOUNT", "CZ8520100000002800359168"),
|
||||||
CacheDir: env("CACHE_DIR", "tmp"),
|
CacheDir: env("CACHE_DIR", "tmp/go"),
|
||||||
CacheTTL: envDuration("CACHE_TTL_SECONDS", 300),
|
CacheTTL: envDuration("CACHE_TTL_SECONDS", 300),
|
||||||
CacheAPICheckTTL: envDuration("CACHE_API_CHECK_TTL_SECONDS", 300),
|
CacheAPICheckTTL: envDuration("CACHE_API_CHECK_TTL_SECONDS", 300),
|
||||||
DriveTimeout: envDuration("DRIVE_TIMEOUT_SECONDS", 10),
|
DriveTimeout: envDuration("DRIVE_TIMEOUT_SECONDS", 10),
|
||||||
|
|||||||
@@ -20,10 +20,13 @@ type Exception struct {
|
|||||||
Note string
|
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 {
|
type FeeData struct {
|
||||||
Expected int
|
Expected int
|
||||||
Attendance 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.
|
// Member is one row from the attendance sheet.
|
||||||
@@ -39,11 +42,15 @@ type Member struct {
|
|||||||
type Transaction struct {
|
type Transaction struct {
|
||||||
Date string
|
Date string
|
||||||
Amount float64
|
Amount float64
|
||||||
|
ManualFix string // "manual fix" column; non-empty disables re-inference
|
||||||
Person string // comma-separated canonical names (empty → use inference)
|
Person string // comma-separated canonical names (empty → use inference)
|
||||||
Purpose string // comma-separated "YYYY-MM" or "other:…" (empty → use inference)
|
Purpose string // comma-separated "YYYY-MM" or "other:…" (empty → use inference)
|
||||||
InferredAmount *float64 // nil → fall back to Amount
|
InferredAmount *float64 // nil → fall back to Amount
|
||||||
Sender string
|
Sender string
|
||||||
|
VS string // Variabilní symbol (Czech variable payment symbol)
|
||||||
Message string
|
Message string
|
||||||
|
BankID string
|
||||||
|
SyncID string
|
||||||
UserID string
|
UserID string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,8 +76,11 @@ type OtherEntry struct {
|
|||||||
// MonthData is the ledger state for one member in one month.
|
// MonthData is the ledger state for one member in one month.
|
||||||
type MonthData struct {
|
type MonthData struct {
|
||||||
Expected int
|
Expected int
|
||||||
|
IsUnknown bool // mirrors FeeData.IsUnknown; not overridden by exceptions
|
||||||
OriginalExpected int
|
OriginalExpected int
|
||||||
AttendanceCount 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
|
Exception *Exception
|
||||||
Paid float64
|
Paid float64
|
||||||
Transactions []TxEntry
|
Transactions []TxEntry
|
||||||
@@ -173,8 +183,11 @@ func Reconcile(
|
|||||||
|
|
||||||
ledger[name][m] = MonthData{
|
ledger[name][m] = MonthData{
|
||||||
Expected: expected,
|
Expected: expected,
|
||||||
|
IsUnknown: fd.IsUnknown,
|
||||||
OriginalExpected: originalExpected,
|
OriginalExpected: originalExpected,
|
||||||
AttendanceCount: attendanceCount,
|
AttendanceCount: attendanceCount,
|
||||||
|
JuniorAttendance: fd.JuniorAttendance,
|
||||||
|
AdultAttendance: fd.AdultAttendance,
|
||||||
Exception: exInfo,
|
Exception: exInfo,
|
||||||
Paid: 0,
|
Paid: 0,
|
||||||
Transactions: []TxEntry{},
|
Transactions: []TxEntry{},
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func tx(person, purpose string, amount float64) Transaction {
|
|||||||
|
|
||||||
func TestReconcileExceptionOverride(t *testing.T) {
|
func TestReconcileExceptionOverride(t *testing.T) {
|
||||||
t.Parallel()
|
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{
|
exceptions := map[ExceptionKey]Exception{
|
||||||
{Name: "alice", Period: "2026-01"}: {Amount: 400, Note: "Test 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) {
|
func TestReconcileFallbackToAttendance(t *testing.T) {
|
||||||
t.Parallel()
|
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)
|
result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear)
|
||||||
|
|
||||||
@@ -68,9 +68,9 @@ func TestReconcileGreedyExactMatch(t *testing.T) {
|
|||||||
members := []Member{{
|
members := []Member{{
|
||||||
Name: "Alice", Tier: "A",
|
Name: "Alice", Tier: "A",
|
||||||
Fees: map[string]FeeData{
|
Fees: map[string]FeeData{
|
||||||
"2026-02": {750, 3},
|
"2026-02": {Expected: 750, Attendance: 3},
|
||||||
"2026-03": {350, 3},
|
"2026-03": {Expected: 350, Attendance: 3},
|
||||||
"2026-04": {150, 2},
|
"2026-04": {Expected: 150, Attendance: 2},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
|
sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
|
||||||
@@ -93,7 +93,7 @@ func TestReconcileGreedyOverpaymentGoesToCredit(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{{
|
members := []Member{{
|
||||||
Name: "Alice", Tier: "A",
|
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"}
|
sortedMonths := []string{"2026-01", "2026-02"}
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ func TestReconcileProportionalUnderpayment(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{{
|
members := []Member{{
|
||||||
Name: "Alice", Tier: "A",
|
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"}
|
sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
|
||||||
amount := 1250.0
|
amount := 1250.0
|
||||||
@@ -146,7 +146,7 @@ func TestReconcileProportionalUnderpayment(t *testing.T) {
|
|||||||
|
|
||||||
func TestReconcileSingleMonthUnchanged(t *testing.T) {
|
func TestReconcileSingleMonthUnchanged(t *testing.T) {
|
||||||
t.Parallel()
|
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)
|
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) {
|
func TestReconcileTwoMembersMultiMonth(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{
|
members := []Member{
|
||||||
{Name: "Alice", 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": {750, 3}, "2026-02": {350, 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"}
|
sortedMonths := []string{"2026-01", "2026-02"}
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ func TestReconcileEvenSplitFallbackWhenNoExpected(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{{
|
members := []Member{{
|
||||||
Name: "Alice", Tier: "A",
|
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"}
|
sortedMonths := []string{"2026-01", "2026-02"}
|
||||||
|
|
||||||
@@ -197,7 +197,7 @@ func TestReconcileEvenSplitFallbackWhenNoExpected(t *testing.T) {
|
|||||||
|
|
||||||
func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) {
|
func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) {
|
||||||
t.Parallel()
|
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 {
|
txFn := func(person string) Transaction {
|
||||||
return Transaction{
|
return Transaction{
|
||||||
Date: "2026-04-15", Amount: 750, Person: person, Purpose: "2026-04",
|
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) {
|
func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) {
|
||||||
t.Parallel()
|
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{{
|
txs := []Transaction{{
|
||||||
Date: "2026-04-15", Amount: 750,
|
Date: "2026-04-15", Amount: 750,
|
||||||
Person: "Někdo Neznámý", Purpose: "2026-04",
|
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.
|
// [Go] Test that [?] markers are stripped from the Person field before lookup.
|
||||||
func TestReconcileQuestionMarkMarkerStripped(t *testing.T) {
|
func TestReconcileQuestionMarkMarkerStripped(t *testing.T) {
|
||||||
t.Parallel()
|
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{{
|
txs := []Transaction{{
|
||||||
Date: "2026-01-01", Amount: 750,
|
Date: "2026-01-01", Amount: 750,
|
||||||
Person: "[?] Alice", Purpose: "2026-01",
|
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.
|
// [Go] Purpose "other:shirt" puts payment in OtherTransactions, not in month ledger.
|
||||||
func TestReconcileOtherPurpose(t *testing.T) {
|
func TestReconcileOtherPurpose(t *testing.T) {
|
||||||
t.Parallel()
|
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{{
|
txs := []Transaction{{
|
||||||
Date: "2026-01-01", Amount: 300,
|
Date: "2026-01-01", Amount: 300,
|
||||||
Person: "Alice", Purpose: "other:shirt",
|
Person: "Alice", Purpose: "other:shirt",
|
||||||
@@ -297,7 +297,7 @@ func TestReconcileOtherPurpose(t *testing.T) {
|
|||||||
func TestReconcileOutOfWindowGoesToCredit(t *testing.T) {
|
func TestReconcileOutOfWindowGoesToCredit(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
// Window shows only 2026-01. Transaction references 2026-01 (in) and 2026-02 (out).
|
// 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{{
|
txs := []Transaction{{
|
||||||
Date: "2026-01-01", Amount: 1200,
|
Date: "2026-01-01", Amount: 1200,
|
||||||
Person: "Alice", Purpose: "2026-01, 2026-02",
|
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.
|
// [Go] No person/purpose → inference fallback resolves sender name and date month.
|
||||||
func TestReconcileInferenceFallback(t *testing.T) {
|
func TestReconcileInferenceFallback(t *testing.T) {
|
||||||
t.Parallel()
|
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{{
|
txs := []Transaction{{
|
||||||
Date: "2026-04-15", Amount: 750,
|
Date: "2026-04-15", Amount: 750,
|
||||||
// Person and Purpose are empty → inference path
|
// 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.
|
// [Go] Transaction with no match at all ends up in Unmatched; ledger unchanged.
|
||||||
func TestReconcileNoMatchGoesToUnmatched(t *testing.T) {
|
func TestReconcileNoMatchGoesToUnmatched(t *testing.T) {
|
||||||
t.Parallel()
|
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{{
|
txs := []Transaction{{
|
||||||
Date: "2026-01-01", Amount: 500,
|
Date: "2026-01-01", Amount: 500,
|
||||||
// empty person+purpose and sender name not matching any member
|
// 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.
|
// [Go] Empty transaction list leaves every month at paid=0 and balance=–expected.
|
||||||
func TestReconcileNoTransactionsAllUnpaid(t *testing.T) {
|
func TestReconcileNoTransactionsAllUnpaid(t *testing.T) {
|
||||||
t.Parallel()
|
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)
|
result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear)
|
||||||
|
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ func TestParseCzechDate(t *testing.T) {
|
|||||||
{"7.5.2026", "2026-05-07"}, // non-padded — real Fio transparent page format
|
{"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
|
{"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
|
{"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
|
{"7.5.26", "2026-05-07"}, // non-padded 2-digit year
|
||||||
{"07/05/26", "2026-05-07"}, // slash variant
|
{"07/05/26", "2026-05-07"}, // slash variant
|
||||||
{"", ""},
|
{"", ""},
|
||||||
{"invalid", ""},
|
{"invalid", ""},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ package banksync
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"fuj-management/go/internal/io/fio"
|
||||||
"io"
|
"io"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
|
||||||
"fuj-management/go/internal/io/fio"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func printFioTable(w io.Writer, txns []fio.Transaction, syncIDs []string, existing map[string]bool) {
|
func printFioTable(w io.Writer, txns []fio.Transaction, syncIDs []string, existing map[string]bool) {
|
||||||
|
|||||||
@@ -25,12 +25,12 @@ const (
|
|||||||
firstDateCol = 3
|
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).
|
// 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.
|
// JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py.
|
||||||
var juniorMergedMonths = map[string]string{
|
var JuniorMergedMonths = map[string]string{
|
||||||
"2025-12": "2026-01",
|
"2025-12": "2026-01",
|
||||||
"2025-09": "2025-10",
|
"2025-09": "2025-10",
|
||||||
}
|
}
|
||||||
@@ -142,7 +142,13 @@ func parseDates(header []string) []struct {
|
|||||||
}
|
}
|
||||||
var dt time.Time
|
var dt time.Time
|
||||||
var err error
|
var err error
|
||||||
for _, fmt_ := range []string{"02.01.2006", "01/02/2006"} {
|
// Use the unpadded reference forms ("2.1" and "1/2"): Go's time.Parse
|
||||||
|
// accepts both single-digit and zero-padded inputs against them, so
|
||||||
|
// "1.6.2026", "01.06.2026", "23.3.2026" all parse. Czech sheet authors
|
||||||
|
// drop the leading zero on dates ≤ 9 — Python's strptime is lenient
|
||||||
|
// the same way; the previous "02.01.2006" form silently dropped those
|
||||||
|
// columns and undercounted attendance.
|
||||||
|
for _, fmt_ := range []string{"2.1.2006", "1/2/2006"} {
|
||||||
dt, err = time.Parse(fmt_, raw)
|
dt, err = time.Parse(fmt_, raw)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
break
|
break
|
||||||
@@ -195,7 +201,7 @@ func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
|
|||||||
return nil, nil, nil
|
return nil, nil, nil
|
||||||
}
|
}
|
||||||
dates := parseDates(rows[0])
|
dates := parseDates(rows[0])
|
||||||
months := groupByMonth(dates, adultMergedMonths)
|
months := groupByMonth(dates, AdultMergedMonths)
|
||||||
sortedMonths := sortedKeys(months)
|
sortedMonths := sortedKeys(months)
|
||||||
|
|
||||||
var members []reconcile.Member
|
var members []reconcile.Member
|
||||||
@@ -243,8 +249,8 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st
|
|||||||
|
|
||||||
mainDates := parseDates(adultRows[0])
|
mainDates := parseDates(adultRows[0])
|
||||||
juniorDates := parseDates(juniorRows[0])
|
juniorDates := parseDates(juniorRows[0])
|
||||||
mainMonths := groupByMonth(mainDates, juniorMergedMonths)
|
mainMonths := groupByMonth(mainDates, JuniorMergedMonths)
|
||||||
jrMonths := groupByMonth(juniorDates, juniorMergedMonths)
|
jrMonths := groupByMonth(juniorDates, JuniorMergedMonths)
|
||||||
|
|
||||||
allMonths := make(map[string]bool)
|
allMonths := make(map[string]bool)
|
||||||
for m := range mainMonths {
|
for m := range mainMonths {
|
||||||
@@ -337,7 +343,13 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st
|
|||||||
if !exp.Unknown {
|
if !exp.Unknown {
|
||||||
fee = exp.Value
|
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})
|
members = append(members, reconcile.Member{Name: name, Tier: data.tier, Fees: feeMap})
|
||||||
}
|
}
|
||||||
@@ -365,11 +377,15 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
|||||||
}
|
}
|
||||||
idxDate := idx("date")
|
idxDate := idx("date")
|
||||||
idxAmount := idx("amount")
|
idxAmount := idx("amount")
|
||||||
|
idxManualFix := idx("manual fix")
|
||||||
idxPerson := idx("person")
|
idxPerson := idx("person")
|
||||||
idxPurpose := idx("purpose")
|
idxPurpose := idx("purpose")
|
||||||
idxInferred := idx("inferred amount")
|
idxInferred := idx("inferred amount")
|
||||||
idxSender := idx("sender")
|
idxSender := idx("sender")
|
||||||
|
idxVS := idx("vs")
|
||||||
idxMessage := idx("message")
|
idxMessage := idx("message")
|
||||||
|
idxBankID := idx("bank id")
|
||||||
|
idxSyncID := idx("sync id")
|
||||||
|
|
||||||
for _, label := range []string{"date", "amount", "person", "purpose"} {
|
for _, label := range []string{"date", "amount", "person", "purpose"} {
|
||||||
if idx(label) == -1 {
|
if idx(label) == -1 {
|
||||||
@@ -384,9 +400,19 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
|||||||
return fmt.Sprint(row[i])
|
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
|
var txns []reconcile.Transaction
|
||||||
for _, row := range rows[1:] {
|
for _, row := range rows[1:] {
|
||||||
dateStr := matching.FormatDate(getVal(row, idxDate))
|
dateStr := matching.FormatDate(getRaw(row, idxDate))
|
||||||
amountRaw := row[idxAmount]
|
amountRaw := row[idxAmount]
|
||||||
if idxAmount < 0 || idxAmount >= len(row) {
|
if idxAmount < 0 || idxAmount >= len(row) {
|
||||||
amountRaw = ""
|
amountRaw = ""
|
||||||
@@ -403,11 +429,15 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
|||||||
txns = append(txns, reconcile.Transaction{
|
txns = append(txns, reconcile.Transaction{
|
||||||
Date: dateStr,
|
Date: dateStr,
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
|
ManualFix: getVal(row, idxManualFix),
|
||||||
Person: getVal(row, idxPerson),
|
Person: getVal(row, idxPerson),
|
||||||
Purpose: getVal(row, idxPurpose),
|
Purpose: getVal(row, idxPurpose),
|
||||||
InferredAmount: inferredAmount,
|
InferredAmount: inferredAmount,
|
||||||
Sender: getVal(row, idxSender),
|
Sender: getVal(row, idxSender),
|
||||||
|
VS: getVal(row, idxVS),
|
||||||
Message: getVal(row, idxMessage),
|
Message: getVal(row, idxMessage),
|
||||||
|
BankID: getVal(row, idxBankID),
|
||||||
|
SyncID: getVal(row, idxSyncID),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return txns, nil
|
return txns, nil
|
||||||
|
|||||||
@@ -114,12 +114,15 @@ func TestLoadJuniors(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadTransactions(t *testing.T) {
|
func TestLoadTransactions(t *testing.T) {
|
||||||
// Sheets fake keyed by "<spreadsheetID>/<range>" — use the real constant.
|
// 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"
|
paymentsKey := config.PaymentsSheetID + "/A1:Z"
|
||||||
sh := &sheets.Fake{Values: map[string][][]any{
|
sh := &sheets.Fake{Values: map[string][][]any{
|
||||||
paymentsKey: {
|
paymentsKey: {
|
||||||
{"Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"},
|
{"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-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)
|
s := buildSources(t, &attendance.Fake{}, sh)
|
||||||
@@ -137,6 +140,12 @@ func TestLoadTransactions(t *testing.T) {
|
|||||||
if txns[0].Amount != 700 {
|
if txns[0].Amount != 700 {
|
||||||
t.Errorf("txn[0].Amount: want 700, got %v", txns[0].Amount)
|
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) {
|
func TestLoadExceptions(t *testing.T) {
|
||||||
@@ -165,6 +174,28 @@ func TestLoadExceptions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestParseDates_SingleDigitDayMonth covers the regression where Go's strict
|
||||||
|
// "02.01.2006" format dropped header cells written without leading zeros
|
||||||
|
// (e.g. "1.6.2026", "23.3.2026"), causing attendance undercounts and missing
|
||||||
|
// months on the /api/juniors response. Czech sheet authors drop the zero
|
||||||
|
// pad freely; Python's strptime tolerates it, so the parsers must match.
|
||||||
|
func TestParseDates_SingleDigitDayMonth(t *testing.T) {
|
||||||
|
// Czech form ("DD.MM.YYYY", with leading zeros optional) is the primary
|
||||||
|
// path. The "M/D/YYYY" fallback mirrors Python's %m/%d/%Y secondary
|
||||||
|
// strptime branch — month-first, day-second.
|
||||||
|
header := []string{"Jméno", "Tier", "", "01.06.2026", "1.6.2026", "23.3.2026", "6.4.2026", "01/02/2026", "1/2/2026"}
|
||||||
|
got := parseDates(header)
|
||||||
|
want := []string{"2026-06", "2026-06", "2026-03", "2026-04", "2026-01", "2026-01"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("parseDates: got %d entries, want %d (%v)", len(got), len(want), got)
|
||||||
|
}
|
||||||
|
for i, e := range got {
|
||||||
|
if e.month != want[i] {
|
||||||
|
t.Errorf("parseDates[%d].month = %q, want %q (raw=%q)", i, e.month, want[i], header[e.col])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TTL smoke test: second call within TTL must not call fetch again.
|
// TTL smoke test: second call within TTL must not call fetch again.
|
||||||
func TestLoadAdults_CacheHit(t *testing.T) {
|
func TestLoadAdults_CacheHit(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|||||||
263
go/internal/web/api/build_adults.go
Normal file
263
go/internal/web/api/build_adults.go
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"fuj-management/go/internal/config"
|
||||||
|
"fuj-management/go/internal/services/membership"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
type monthSums struct{ expected, paid int }
|
||||||
|
|
||||||
|
// buildAdultsResponse constructs the AdultsResponse wire type from reconcile output.
|
||||||
|
// Mirrors scripts/views.py:build_adults_view_model.
|
||||||
|
func buildAdultsResponse(
|
||||||
|
members []domreconcile.Member,
|
||||||
|
sortedMonths []string,
|
||||||
|
result domreconcile.Result,
|
||||||
|
txns []domreconcile.Transaction,
|
||||||
|
cfg config.Config,
|
||||||
|
currentMonth string,
|
||||||
|
) AdultsResponse {
|
||||||
|
monthLabels := getMonthLabels(sortedMonths, membership.AdultMergedMonths)
|
||||||
|
|
||||||
|
// Collect tier-A names, sorted.
|
||||||
|
var adultNames []string
|
||||||
|
allNames := make([]string, 0, len(members))
|
||||||
|
for _, m := range members {
|
||||||
|
allNames = append(allNames, m.Name)
|
||||||
|
if m.Tier == "A" {
|
||||||
|
adultNames = append(adultNames, m.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(adultNames)
|
||||||
|
|
||||||
|
// Per-month aggregate totals (expected and paid integers).
|
||||||
|
monthlyTotals := make(map[string]*monthSums, len(sortedMonths))
|
||||||
|
for _, m := range sortedMonths {
|
||||||
|
monthlyTotals[m] = &monthSums{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []MemberRow
|
||||||
|
for _, name := range adultNames {
|
||||||
|
mr := result.Members[name]
|
||||||
|
row, unpaidMonths, rawUnpaidMonths := buildAdultMemberRow(name, mr, sortedMonths, monthLabels, currentMonth, monthlyTotals)
|
||||||
|
row.UnpaidPeriods = joinComma(unpaidMonths)
|
||||||
|
row.RawUnpaidPeriods = joinPlus(rawUnpaidMonths)
|
||||||
|
row.Balance = settledBalance(mr, currentMonth)
|
||||||
|
row.PayableAmount = max(0, -row.Balance)
|
||||||
|
results = append(results, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Totals row.
|
||||||
|
totalsCells := make([]TotalCell, len(sortedMonths))
|
||||||
|
for i, m := range sortedMonths {
|
||||||
|
t := monthlyTotals[m] // *monthSums, never nil (initialised above)
|
||||||
|
status := "empty"
|
||||||
|
if t.expected > 0 || t.paid > 0 {
|
||||||
|
switch {
|
||||||
|
case t.paid == t.expected:
|
||||||
|
status = "ok"
|
||||||
|
case t.paid < t.expected:
|
||||||
|
status = "unpaid"
|
||||||
|
default:
|
||||||
|
status = "surplus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalsCells[i] = TotalCell{
|
||||||
|
Text: fmt.Sprintf("%d / %d CZK", t.paid, t.expected),
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Credits and debts (settled balance, past months only).
|
||||||
|
var credits, debts []Credit
|
||||||
|
for _, name := range adultNames {
|
||||||
|
bal := settledBalance(result.Members[name], currentMonth)
|
||||||
|
if bal > 0 {
|
||||||
|
credits = append(credits, Credit{Name: name, Amount: bal})
|
||||||
|
} else if bal < 0 {
|
||||||
|
debts = append(debts, Credit{Name: name, Amount: -bal})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(credits, func(i, j int) bool { return credits[i].Name < credits[j].Name })
|
||||||
|
sort.Slice(debts, func(i, j int) bool { return debts[i].Name < debts[j].Name })
|
||||||
|
|
||||||
|
// member_data: full reconcile output for all members (not just adults).
|
||||||
|
memberData := make(map[string]AdultsMemberData, len(result.Members))
|
||||||
|
for name, mr := range result.Members {
|
||||||
|
months := make(map[string]AdultsMonthData, len(mr.Months))
|
||||||
|
for m, md := range mr.Months {
|
||||||
|
var exc *ExceptionData
|
||||||
|
if md.Exception != nil {
|
||||||
|
exc = &ExceptionData{Amount: md.Exception.Amount, Note: md.Exception.Note}
|
||||||
|
}
|
||||||
|
txEntries := make([]MemberTxEntry, len(md.Transactions))
|
||||||
|
for i, te := range md.Transactions {
|
||||||
|
txEntries[i] = memberTxFromDomain(te)
|
||||||
|
}
|
||||||
|
months[m] = AdultsMonthData{
|
||||||
|
Expected: md.Expected,
|
||||||
|
OriginalExpected: md.OriginalExpected,
|
||||||
|
AttendanceCount: md.AttendanceCount,
|
||||||
|
Exception: exc,
|
||||||
|
Paid: md.Paid,
|
||||||
|
Transactions: txEntries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
otherTxs := make([]MemberOtherEntry, len(mr.OtherTransactions))
|
||||||
|
for i, oe := range mr.OtherTransactions {
|
||||||
|
otherTxs[i] = memberOtherFromDomain(oe)
|
||||||
|
}
|
||||||
|
memberData[name] = AdultsMemberData{
|
||||||
|
Tier: mr.Tier,
|
||||||
|
Months: months,
|
||||||
|
OtherTransactions: otherTxs,
|
||||||
|
TotalBalance: mr.TotalBalance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unmatched := make([]RawTransaction, len(result.Unmatched))
|
||||||
|
for i, tx := range result.Unmatched {
|
||||||
|
unmatched[i] = rawTxFromDomain(tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return AdultsResponse{
|
||||||
|
Months: labelsForMonths(sortedMonths, monthLabels),
|
||||||
|
RawMonths: sortedMonths,
|
||||||
|
Results: ensureSlice(results),
|
||||||
|
Totals: totalsCells,
|
||||||
|
MemberData: memberData,
|
||||||
|
MonthLabels: monthLabels,
|
||||||
|
RawPayments: groupRawPaymentsByPerson(txns, allNames),
|
||||||
|
Credits: ensureSlice(credits),
|
||||||
|
Debts: ensureSlice(debts),
|
||||||
|
Unmatched: unmatched,
|
||||||
|
AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit",
|
||||||
|
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
||||||
|
BankAccount: cfg.BankAccount,
|
||||||
|
CurrentMonth: currentMonth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAdultMemberRow(
|
||||||
|
name string,
|
||||||
|
mr domreconcile.MemberResult,
|
||||||
|
sortedMonths []string,
|
||||||
|
monthLabels map[string]string,
|
||||||
|
currentMonth string,
|
||||||
|
monthlyTotals map[string]*monthSums,
|
||||||
|
) (row MemberRow, unpaidMonths, rawUnpaidMonths []string) {
|
||||||
|
row = MemberRow{Name: name}
|
||||||
|
for _, m := range sortedMonths {
|
||||||
|
md, ok := mr.Months[m]
|
||||||
|
if !ok {
|
||||||
|
md = domreconcile.MonthData{}
|
||||||
|
}
|
||||||
|
paid := int(md.Paid)
|
||||||
|
expected := md.Expected
|
||||||
|
|
||||||
|
if t := monthlyTotals[m]; t != nil {
|
||||||
|
t.expected += expected
|
||||||
|
t.paid += paid
|
||||||
|
}
|
||||||
|
|
||||||
|
var feeDisplay string
|
||||||
|
var isOverridden bool
|
||||||
|
if md.Exception != nil && md.Exception.Amount != md.OriginalExpected {
|
||||||
|
isOverridden = true
|
||||||
|
if md.AttendanceCount > 0 {
|
||||||
|
feeDisplay = fmt.Sprintf("%d (%d) CZK (%d)", md.Exception.Amount, md.OriginalExpected, md.AttendanceCount)
|
||||||
|
} else {
|
||||||
|
feeDisplay = fmt.Sprintf("%d (%d) CZK", md.Exception.Amount, md.OriginalExpected)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if md.AttendanceCount > 0 {
|
||||||
|
feeDisplay = fmt.Sprintf("%d CZK (%d)", expected, md.AttendanceCount)
|
||||||
|
} else {
|
||||||
|
feeDisplay = fmt.Sprintf("%d CZK", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "empty"
|
||||||
|
cellText := "-"
|
||||||
|
amountToPay := 0
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case expected > 0:
|
||||||
|
amountToPay = max(0, expected-paid)
|
||||||
|
switch {
|
||||||
|
case paid >= expected:
|
||||||
|
status = "ok"
|
||||||
|
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
||||||
|
case paid > 0:
|
||||||
|
status = "partial"
|
||||||
|
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
||||||
|
if m < currentMonth {
|
||||||
|
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
||||||
|
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
status = "unpaid"
|
||||||
|
cellText = fmt.Sprintf("0/%s", feeDisplay)
|
||||||
|
if m < currentMonth {
|
||||||
|
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
||||||
|
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case paid > 0:
|
||||||
|
status = "surplus"
|
||||||
|
cellText = fmt.Sprintf("PAID %d", paid)
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltip := ""
|
||||||
|
if expected > 0 || paid > 0 {
|
||||||
|
tooltip = fmt.Sprintf("Received: %d, Expected: %d", paid, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
row.Months = append(row.Months, MonthCell{
|
||||||
|
Text: cellText,
|
||||||
|
Overridden: isOverridden,
|
||||||
|
Status: status,
|
||||||
|
Amount: amountToPay,
|
||||||
|
Month: monthLabels[m],
|
||||||
|
RawMonth: m,
|
||||||
|
Tooltip: tooltip,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return row, unpaidMonths, rawUnpaidMonths
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawMonthLabel converts "YYYY-MM" to "MM/YYYY" matching Python's strftime("%m/%Y").
|
||||||
|
func rawMonthLabel(m string) string {
|
||||||
|
dt, err := time.Parse("2006-01", m)
|
||||||
|
if err != nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return dt.Format("01/2006")
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinComma(parts []string) string {
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
result := parts[0]
|
||||||
|
for _, p := range parts[1:] {
|
||||||
|
result += ", " + p
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinPlus(parts []string) string {
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
result := parts[0]
|
||||||
|
for _, p := range parts[1:] {
|
||||||
|
result += "+" + p
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
184
go/internal/web/api/build_common.go
Normal file
184
go/internal/web/api/build_common.go
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fuj-management/go/internal/domain/czech"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// getMonthLabels builds display labels for sortedMonths, merging month names
|
||||||
|
// (e.g. "Dec+Jan 2026") when mergedMonths maps a source month into this target.
|
||||||
|
// Mirrors scripts/views.py:get_month_labels.
|
||||||
|
func getMonthLabels(sortedMonths []string, mergedMonths map[string]string) map[string]string {
|
||||||
|
labels := make(map[string]string, len(sortedMonths))
|
||||||
|
for _, m := range sortedMonths {
|
||||||
|
dt, err := time.Parse("2006-01", m)
|
||||||
|
if err != nil {
|
||||||
|
labels[m] = m
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var mergedIn []string
|
||||||
|
for src, dst := range mergedMonths {
|
||||||
|
if dst == m {
|
||||||
|
mergedIn = append(mergedIn, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(mergedIn)
|
||||||
|
if len(mergedIn) == 0 {
|
||||||
|
labels[m] = dt.Format("Jan 2006")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allMonths := append(mergedIn, m) //nolint:gocritic // intentional: mergedIn already owned
|
||||||
|
sort.Strings(allMonths)
|
||||||
|
years := map[int]bool{}
|
||||||
|
for _, x := range allMonths {
|
||||||
|
if d, err2 := time.Parse("2006-01", x); err2 == nil {
|
||||||
|
years[d.Year()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(allMonths))
|
||||||
|
if len(years) > 1 {
|
||||||
|
for _, x := range allMonths {
|
||||||
|
if d, err2 := time.Parse("2006-01", x); err2 == nil {
|
||||||
|
parts = append(parts, d.Format("Jan 2006"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labels[m] = strings.Join(parts, "+")
|
||||||
|
} else {
|
||||||
|
for _, x := range allMonths {
|
||||||
|
if d, err2 := time.Parse("2006-01", x); err2 == nil {
|
||||||
|
parts = append(parts, d.Format("Jan"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labels[m] = strings.Join(parts, "+") + " " + dt.Format("2006")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
// labelsForMonths returns the display labels for sortedMonths in slice order.
|
||||||
|
func labelsForMonths(sortedMonths []string, labels map[string]string) []string {
|
||||||
|
out := make([]string, len(sortedMonths))
|
||||||
|
for i, m := range sortedMonths {
|
||||||
|
out[i] = labels[m]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
|
||||||
|
|
||||||
|
// canonicalKey returns a normalized form of a person name used for deduplication.
|
||||||
|
// Mirrors scripts/match_payments.py:canonical_member_key.
|
||||||
|
func canonicalKey(name string) string {
|
||||||
|
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupRawPaymentsByPerson groups transactions by the "person" column,
|
||||||
|
// canonicalizing names against memberNames where possible.
|
||||||
|
// Mirrors scripts/views.py:group_payments_by_person (without the
|
||||||
|
// "Unmatched / Unknown" bucket that is payments-view-specific).
|
||||||
|
func groupRawPaymentsByPerson(txns []domreconcile.Transaction, memberNames []string) map[string][]RawTransaction {
|
||||||
|
canonicalByKey := make(map[string]string, len(memberNames))
|
||||||
|
for _, n := range memberNames {
|
||||||
|
k := canonicalKey(n)
|
||||||
|
if _, exists := canonicalByKey[k]; !exists {
|
||||||
|
canonicalByKey[k] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
grouped := make(map[string][]RawTransaction)
|
||||||
|
for _, tx := range txns {
|
||||||
|
person := strings.TrimSpace(tx.Person)
|
||||||
|
if person == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, p := range strings.Split(person, ",") {
|
||||||
|
p = questionMarkRe.ReplaceAllString(p, "")
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := p
|
||||||
|
if canonical, ok := canonicalByKey[canonicalKey(p)]; ok {
|
||||||
|
key = canonical
|
||||||
|
}
|
||||||
|
grouped[key] = append(grouped[key], rawTxFromDomain(tx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k := range grouped {
|
||||||
|
sort.Slice(grouped[k], func(i, j int) bool {
|
||||||
|
return grouped[k][i].Date > grouped[k][j].Date
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawTxFromDomain converts a domain Transaction to the wire RawTransaction.
|
||||||
|
func rawTxFromDomain(tx domreconcile.Transaction) RawTransaction {
|
||||||
|
inferredAmount := 0.0
|
||||||
|
if tx.InferredAmount != nil {
|
||||||
|
inferredAmount = *tx.InferredAmount
|
||||||
|
}
|
||||||
|
return RawTransaction{
|
||||||
|
Date: tx.Date,
|
||||||
|
Amount: tx.Amount,
|
||||||
|
ManualFix: tx.ManualFix,
|
||||||
|
Person: tx.Person,
|
||||||
|
Purpose: tx.Purpose,
|
||||||
|
InferredAmount: inferredAmount,
|
||||||
|
Sender: tx.Sender,
|
||||||
|
VS: tx.VS,
|
||||||
|
Message: tx.Message,
|
||||||
|
BankID: tx.BankID,
|
||||||
|
SyncID: tx.SyncID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// memberTxFromDomain converts a domain TxEntry to a wire MemberTxEntry.
|
||||||
|
func memberTxFromDomain(te domreconcile.TxEntry) MemberTxEntry {
|
||||||
|
return MemberTxEntry{
|
||||||
|
Amount: te.Amount,
|
||||||
|
Date: te.Date,
|
||||||
|
Sender: te.Sender,
|
||||||
|
Message: te.Message,
|
||||||
|
Confidence: te.Confidence,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// memberOtherFromDomain converts a domain OtherEntry to a wire MemberOtherEntry.
|
||||||
|
func memberOtherFromDomain(oe domreconcile.OtherEntry) MemberOtherEntry {
|
||||||
|
return MemberOtherEntry{
|
||||||
|
Amount: oe.Amount,
|
||||||
|
Date: oe.Date,
|
||||||
|
Sender: oe.Sender,
|
||||||
|
Message: oe.Message,
|
||||||
|
Purpose: oe.Purpose,
|
||||||
|
Confidence: oe.Confidence,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// settledBalance computes the settled balance: sum of (paid − expected) for months
|
||||||
|
// strictly before currentMonth. Months with IsUnknown=true are excluded to match
|
||||||
|
// Python's isinstance(exp, int) guard (skips "?" months).
|
||||||
|
func settledBalance(mr domreconcile.MemberResult, currentMonth string) int {
|
||||||
|
total := 0
|
||||||
|
for m, md := range mr.Months {
|
||||||
|
if m >= currentMonth || md.IsUnknown {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += int(md.Paid) - md.Expected
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureSlice returns s unchanged when non-nil, or an empty (non-nil) slice so
|
||||||
|
// json.Marshal emits [] instead of null.
|
||||||
|
func ensureSlice[T any](s []T) []T {
|
||||||
|
if s == nil {
|
||||||
|
return []T{}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
276
go/internal/web/api/build_juniors.go
Normal file
276
go/internal/web/api/build_juniors.go
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"fuj-management/go/internal/config"
|
||||||
|
"fuj-management/go/internal/services/membership"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildJuniorsResponse constructs the JuniorsResponse wire type from reconcile output.
|
||||||
|
// Mirrors scripts/views.py:build_juniors_view_model.
|
||||||
|
func buildJuniorsResponse(
|
||||||
|
members []domreconcile.Member,
|
||||||
|
sortedMonths []string,
|
||||||
|
result domreconcile.Result,
|
||||||
|
txns []domreconcile.Transaction,
|
||||||
|
cfg config.Config,
|
||||||
|
currentMonth string,
|
||||||
|
) JuniorsResponse {
|
||||||
|
monthLabels := getMonthLabels(sortedMonths, membership.JuniorMergedMonths)
|
||||||
|
|
||||||
|
allNames := make([]string, 0, len(members))
|
||||||
|
juniorNames := make([]string, 0, len(members))
|
||||||
|
for _, m := range members {
|
||||||
|
allNames = append(allNames, m.Name)
|
||||||
|
juniorNames = append(juniorNames, m.Name)
|
||||||
|
}
|
||||||
|
sort.Strings(juniorNames)
|
||||||
|
|
||||||
|
monthlyTotals := make(map[string]*monthSums, len(sortedMonths))
|
||||||
|
for _, m := range sortedMonths {
|
||||||
|
monthlyTotals[m] = &monthSums{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var results []MemberRow
|
||||||
|
for _, name := range juniorNames {
|
||||||
|
mr := result.Members[name]
|
||||||
|
row, unpaidMonths, rawUnpaidMonths := buildJuniorMemberRow(name, mr, sortedMonths, monthLabels, currentMonth, monthlyTotals)
|
||||||
|
row.UnpaidPeriods = joinComma(unpaidMonths)
|
||||||
|
row.RawUnpaidPeriods = joinPlus(rawUnpaidMonths)
|
||||||
|
row.Balance = settledBalance(mr, currentMonth)
|
||||||
|
row.PayableAmount = max(0, -row.Balance)
|
||||||
|
results = append(results, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Totals row.
|
||||||
|
totalsCells := make([]TotalCell, len(sortedMonths))
|
||||||
|
for i, m := range sortedMonths {
|
||||||
|
t := monthlyTotals[m] // *monthSums, never nil (initialised above)
|
||||||
|
status := "empty"
|
||||||
|
if t.expected > 0 || t.paid > 0 {
|
||||||
|
switch {
|
||||||
|
case t.paid == t.expected:
|
||||||
|
status = "ok"
|
||||||
|
case t.paid < t.expected:
|
||||||
|
status = "unpaid"
|
||||||
|
default:
|
||||||
|
status = "surplus"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalsCells[i] = TotalCell{
|
||||||
|
Text: fmt.Sprintf("%d / %d CZK", t.paid, t.expected),
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var credits, debts []Credit
|
||||||
|
for _, name := range juniorNames {
|
||||||
|
bal := settledBalance(result.Members[name], currentMonth)
|
||||||
|
if bal > 0 {
|
||||||
|
credits = append(credits, Credit{Name: name, Amount: bal})
|
||||||
|
} else if bal < 0 {
|
||||||
|
debts = append(debts, Credit{Name: name, Amount: -bal})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(credits, func(i, j int) bool { return credits[i].Name < credits[j].Name })
|
||||||
|
sort.Slice(debts, func(i, j int) bool { return debts[i].Name < debts[j].Name })
|
||||||
|
|
||||||
|
// member_data: full reconcile output for all junior members.
|
||||||
|
memberData := make(map[string]JuniorsMemberData, len(result.Members))
|
||||||
|
for name, mr := range result.Members {
|
||||||
|
months := make(map[string]JuniorsMonthData, len(mr.Months))
|
||||||
|
for m, md := range mr.Months {
|
||||||
|
var exc *ExceptionData
|
||||||
|
if md.Exception != nil {
|
||||||
|
exc = &ExceptionData{Amount: md.Exception.Amount, Note: md.Exception.Note}
|
||||||
|
}
|
||||||
|
txEntries := make([]MemberTxEntry, len(md.Transactions))
|
||||||
|
for i, te := range md.Transactions {
|
||||||
|
txEntries[i] = memberTxFromDomain(te)
|
||||||
|
}
|
||||||
|
months[m] = JuniorsMonthData{
|
||||||
|
Expected: juniorExpected(md),
|
||||||
|
OriginalExpected: juniorOriginalExpected(md),
|
||||||
|
AttendanceCount: md.AttendanceCount,
|
||||||
|
Exception: exc,
|
||||||
|
Paid: md.Paid,
|
||||||
|
Transactions: txEntries,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
otherTxs := make([]MemberOtherEntry, len(mr.OtherTransactions))
|
||||||
|
for i, oe := range mr.OtherTransactions {
|
||||||
|
otherTxs[i] = memberOtherFromDomain(oe)
|
||||||
|
}
|
||||||
|
memberData[name] = JuniorsMemberData{
|
||||||
|
Tier: mr.Tier,
|
||||||
|
Months: months,
|
||||||
|
OtherTransactions: otherTxs,
|
||||||
|
TotalBalance: mr.TotalBalance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unmatched := make([]RawTransaction, len(result.Unmatched))
|
||||||
|
for i, tx := range result.Unmatched {
|
||||||
|
unmatched[i] = rawTxFromDomain(tx)
|
||||||
|
}
|
||||||
|
|
||||||
|
juniorURL := "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID +
|
||||||
|
"/edit#gid=" + config.JuniorSheetGID
|
||||||
|
|
||||||
|
return JuniorsResponse{
|
||||||
|
Months: labelsForMonths(sortedMonths, monthLabels),
|
||||||
|
RawMonths: sortedMonths,
|
||||||
|
Results: ensureSlice(results),
|
||||||
|
Totals: totalsCells,
|
||||||
|
MemberData: memberData,
|
||||||
|
MonthLabels: monthLabels,
|
||||||
|
RawPayments: groupRawPaymentsByPerson(txns, allNames),
|
||||||
|
Credits: ensureSlice(credits),
|
||||||
|
Debts: ensureSlice(debts),
|
||||||
|
Unmatched: unmatched,
|
||||||
|
AttendanceURL: juniorURL,
|
||||||
|
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
||||||
|
BankAccount: cfg.BankAccount,
|
||||||
|
CurrentMonth: currentMonth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildJuniorMemberRow(
|
||||||
|
name string,
|
||||||
|
mr domreconcile.MemberResult,
|
||||||
|
sortedMonths []string,
|
||||||
|
monthLabels map[string]string,
|
||||||
|
currentMonth string,
|
||||||
|
monthlyTotals map[string]*monthSums,
|
||||||
|
) (row MemberRow, unpaidMonths, rawUnpaidMonths []string) {
|
||||||
|
row = MemberRow{Name: name}
|
||||||
|
for _, m := range sortedMonths {
|
||||||
|
md, ok := mr.Months[m]
|
||||||
|
if !ok {
|
||||||
|
md = domreconcile.MonthData{}
|
||||||
|
}
|
||||||
|
paid := int(md.Paid)
|
||||||
|
|
||||||
|
// Update monthly totals (skip "?" months for expected).
|
||||||
|
if t := monthlyTotals[m]; t != nil {
|
||||||
|
if !md.IsUnknown {
|
||||||
|
t.expected += md.Expected
|
||||||
|
}
|
||||||
|
t.paid += paid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attendance breakdown string e.g. ":3J,2A".
|
||||||
|
var breakdown string
|
||||||
|
jc, ac := md.JuniorAttendance, md.AdultAttendance
|
||||||
|
switch {
|
||||||
|
case jc > 0 && ac > 0:
|
||||||
|
breakdown = fmt.Sprintf(":%dJ,%dA", jc, ac)
|
||||||
|
case jc > 0:
|
||||||
|
breakdown = fmt.Sprintf(":%dJ", jc)
|
||||||
|
case ac > 0:
|
||||||
|
breakdown = fmt.Sprintf(":%dA", ac)
|
||||||
|
}
|
||||||
|
countStr := ""
|
||||||
|
if md.AttendanceCount > 0 {
|
||||||
|
countStr = fmt.Sprintf(" (%d%s)", md.AttendanceCount, breakdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fee display string.
|
||||||
|
var feeDisplay string
|
||||||
|
var isOverridden bool
|
||||||
|
if md.Exception != nil {
|
||||||
|
overrideAmount := md.Exception.Amount
|
||||||
|
var origStr string
|
||||||
|
if md.IsUnknown {
|
||||||
|
origStr = "?"
|
||||||
|
isOverridden = true
|
||||||
|
} else {
|
||||||
|
origStr = strconv.Itoa(md.OriginalExpected)
|
||||||
|
isOverridden = overrideAmount != md.OriginalExpected
|
||||||
|
}
|
||||||
|
if isOverridden {
|
||||||
|
feeDisplay = fmt.Sprintf("%d (%s) CZK%s", overrideAmount, origStr, countStr)
|
||||||
|
} else {
|
||||||
|
feeDisplay = fmt.Sprintf("%d CZK%s", md.Expected, countStr)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if md.IsUnknown {
|
||||||
|
feeDisplay = "? CZK" + countStr
|
||||||
|
} else {
|
||||||
|
feeDisplay = fmt.Sprintf("%d CZK%s", md.Expected, countStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "empty"
|
||||||
|
cellText := "-"
|
||||||
|
amountToPay := 0
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case md.IsUnknown:
|
||||||
|
cellText = "?" + countStr
|
||||||
|
case md.Expected > 0:
|
||||||
|
switch {
|
||||||
|
case paid >= md.Expected:
|
||||||
|
status = "ok"
|
||||||
|
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
||||||
|
case paid > 0:
|
||||||
|
status = "partial"
|
||||||
|
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
||||||
|
amountToPay = md.Expected - paid
|
||||||
|
if m < currentMonth {
|
||||||
|
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
||||||
|
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
status = "unpaid"
|
||||||
|
cellText = fmt.Sprintf("0/%s", feeDisplay)
|
||||||
|
amountToPay = md.Expected
|
||||||
|
if m < currentMonth {
|
||||||
|
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
||||||
|
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case paid > 0:
|
||||||
|
status = "surplus"
|
||||||
|
cellText = fmt.Sprintf("PAID %d", paid)
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltip := ""
|
||||||
|
if (!md.IsUnknown && md.Expected > 0) || paid > 0 {
|
||||||
|
tooltip = fmt.Sprintf("Received: %d, Expected: %d", paid, md.Expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
row.Months = append(row.Months, MonthCell{
|
||||||
|
Text: cellText,
|
||||||
|
Overridden: isOverridden,
|
||||||
|
Status: status,
|
||||||
|
Amount: amountToPay,
|
||||||
|
Month: monthLabels[m],
|
||||||
|
RawMonth: m,
|
||||||
|
Tooltip: tooltip,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return row, unpaidMonths, rawUnpaidMonths
|
||||||
|
}
|
||||||
|
|
||||||
|
// juniorExpected converts domain MonthData to the Expected wire type.
|
||||||
|
// When an exception exists it always produces a concrete int; otherwise
|
||||||
|
// the "?" sentinel is used when IsUnknown=true.
|
||||||
|
func juniorExpected(md domreconcile.MonthData) Expected {
|
||||||
|
if md.Exception == nil && md.IsUnknown {
|
||||||
|
return Expected{Unknown: true}
|
||||||
|
}
|
||||||
|
return Expected{Value: md.Expected}
|
||||||
|
}
|
||||||
|
|
||||||
|
// juniorOriginalExpected converts the original (pre-exception) expected fee.
|
||||||
|
func juniorOriginalExpected(md domreconcile.MonthData) Expected {
|
||||||
|
if md.IsUnknown {
|
||||||
|
return Expected{Unknown: true}
|
||||||
|
}
|
||||||
|
return Expected{Value: md.OriginalExpected}
|
||||||
|
}
|
||||||
44
go/internal/web/api/build_payments.go
Normal file
44
go/internal/web/api/build_payments.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fuj-management/go/internal/config"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildPaymentsResponse constructs the PaymentsResponse wire type.
|
||||||
|
// Mirrors scripts/views.py:build_payments_view_model.
|
||||||
|
func buildPaymentsResponse(
|
||||||
|
txns []domreconcile.Transaction,
|
||||||
|
memberNames []string,
|
||||||
|
) PaymentsResponse {
|
||||||
|
grouped := groupRawPaymentsByPerson(txns, memberNames)
|
||||||
|
|
||||||
|
// Add unmatched/unknown bucket for transactions with no person set.
|
||||||
|
const unknownKey = "Unmatched / Unknown"
|
||||||
|
for _, tx := range txns {
|
||||||
|
if strings.TrimSpace(tx.Person) == "" {
|
||||||
|
grouped[unknownKey] = append(grouped[unknownKey], rawTxFromDomain(tx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort the unknown bucket newest-first (others are sorted in groupRawPaymentsByPerson).
|
||||||
|
if rows, ok := grouped[unknownKey]; ok {
|
||||||
|
sort.Slice(rows, func(i, j int) bool { return rows[i].Date > rows[j].Date })
|
||||||
|
grouped[unknownKey] = rows
|
||||||
|
}
|
||||||
|
|
||||||
|
sortedPeople := make([]string, 0, len(grouped))
|
||||||
|
for p := range grouped {
|
||||||
|
sortedPeople = append(sortedPeople, p)
|
||||||
|
}
|
||||||
|
sort.Strings(sortedPeople)
|
||||||
|
|
||||||
|
return PaymentsResponse{
|
||||||
|
GroupedPayments: grouped,
|
||||||
|
SortedPeople: sortedPeople,
|
||||||
|
AttendanceURL: "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID + "/edit",
|
||||||
|
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
||||||
|
}
|
||||||
|
}
|
||||||
125
go/internal/web/api/handler.go
Normal file
125
go/internal/web/api/handler.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"fuj-management/go/internal/config"
|
||||||
|
"fuj-management/go/internal/services/membership"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
domreconcile "fuj-management/go/internal/domain/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler holds the shared dependencies for all /api/* routes.
|
||||||
|
type Handler struct {
|
||||||
|
BuildVersion string
|
||||||
|
BuildCommit string
|
||||||
|
BuildDate string
|
||||||
|
Sources membership.Sources
|
||||||
|
Config config.Config
|
||||||
|
Logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeVersion handles GET /api/version.
|
||||||
|
func (h *Handler) ServeVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, VersionResponse{
|
||||||
|
Tag: h.BuildVersion,
|
||||||
|
Commit: h.BuildCommit,
|
||||||
|
BuildDate: h.BuildDate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeAdults handles GET /api/adults.
|
||||||
|
func (h *Handler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
|
||||||
|
writeJSON(w, buildAdultsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeJuniors handles GET /api/juniors.
|
||||||
|
func (h *Handler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
members, sortedMonths, txns, exceptions, err := h.loadAll(ctx, false)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
|
||||||
|
writeJSON(w, buildJuniorsResponse(members, sortedMonths, result, txns, h.Config, time.Now().Format("2006-01")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServePayments handles GET /api/payments.
|
||||||
|
func (h *Handler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
txns, err := h.Sources.LoadTransactions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
h.writeError(w, r, fmt.Errorf("load transactions: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, buildPaymentsResponse(txns, h.allMemberNames(ctx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) loadAll(ctx context.Context, adults bool) (
|
||||||
|
members []domreconcile.Member,
|
||||||
|
sortedMonths []string,
|
||||||
|
txns []domreconcile.Transaction,
|
||||||
|
exceptions map[domreconcile.ExceptionKey]domreconcile.Exception,
|
||||||
|
err error,
|
||||||
|
) {
|
||||||
|
if adults {
|
||||||
|
members, sortedMonths, err = h.Sources.LoadAdults(ctx)
|
||||||
|
} else {
|
||||||
|
members, sortedMonths, err = h.Sources.LoadJuniors(ctx)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("load members: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
txns, err = h.Sources.LoadTransactions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("load transactions: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exceptions, err = h.Sources.LoadExceptions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("load exceptions: %w", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) allMemberNames(ctx context.Context) []string {
|
||||||
|
var names []string
|
||||||
|
if adults, _, err := h.Sources.LoadAdults(ctx); err == nil {
|
||||||
|
for _, m := range adults {
|
||||||
|
names = append(names, m.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if juniors, _, err := h.Sources.LoadJuniors(ctx); err == nil {
|
||||||
|
for _, m := range juniors {
|
||||||
|
names = append(names, m.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) writeError(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
if h.Logger != nil {
|
||||||
|
h.Logger.Error("api error", "path", r.URL.Path, "err", err)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"fuj-management/go/internal/config"
|
||||||
|
"fuj-management/go/internal/services/membership"
|
||||||
|
"fuj-management/go/internal/web/api"
|
||||||
"fuj-management/go/internal/web/middleware"
|
"fuj-management/go/internal/web/middleware"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -15,9 +18,22 @@ type BuildInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run registers routes and starts the HTTP server on addr.
|
// 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 := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /{$}", helloHandler(build))
|
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)
|
logger.Info("starting server", "addr", addr)
|
||||||
return http.ListenAndServe(addr, middleware.RequestTimer(logger, mux))
|
return http.ListenAndServe(addr, middleware.RequestTimer(logger, mux))
|
||||||
|
|||||||
@@ -236,6 +236,8 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
|
|||||||
idx_sender = get_col_index("Sender")
|
idx_sender = get_col_index("Sender")
|
||||||
idx_message = get_col_index("Message")
|
idx_message = get_col_index("Message")
|
||||||
idx_bank_id = get_col_index("Bank ID")
|
idx_bank_id = get_col_index("Bank ID")
|
||||||
|
idx_vs = get_col_index("VS")
|
||||||
|
idx_sync_id = get_col_index("Sync ID")
|
||||||
|
|
||||||
required = {"Date": idx_date, "Amount": idx_amount, "Person": idx_person, "Purpose": idx_purpose}
|
required = {"Date": idx_date, "Amount": idx_amount, "Person": idx_person, "Purpose": idx_purpose}
|
||||||
missing = [name for name, idx in required.items() if idx == -1]
|
missing = [name for name, idx in required.items() if idx == -1]
|
||||||
@@ -247,6 +249,12 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
|
|||||||
def get_val(idx):
|
def get_val(idx):
|
||||||
return row[idx] if idx != -1 and idx < len(row) else ""
|
return row[idx] if idx != -1 and idx < len(row) else ""
|
||||||
|
|
||||||
|
def get_str(idx):
|
||||||
|
v = get_val(idx)
|
||||||
|
if isinstance(v, float) and v.is_integer():
|
||||||
|
return str(int(v))
|
||||||
|
return str(v)
|
||||||
|
|
||||||
tx = {
|
tx = {
|
||||||
"date": format_date(get_val(idx_date)),
|
"date": format_date(get_val(idx_date)),
|
||||||
"amount": get_val(idx_amount),
|
"amount": get_val(idx_amount),
|
||||||
@@ -255,8 +263,10 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
|
|||||||
"purpose": get_val(idx_purpose),
|
"purpose": get_val(idx_purpose),
|
||||||
"inferred_amount": get_val(idx_inferred_amount),
|
"inferred_amount": get_val(idx_inferred_amount),
|
||||||
"sender": get_val(idx_sender),
|
"sender": get_val(idx_sender),
|
||||||
|
"vs": get_str(idx_vs),
|
||||||
"message": get_val(idx_message),
|
"message": get_val(idx_message),
|
||||||
"bank_id": get_val(idx_bank_id),
|
"bank_id": get_val(idx_bank_id),
|
||||||
|
"sync_id": get_val(idx_sync_id),
|
||||||
}
|
}
|
||||||
transactions.append(tx)
|
transactions.append(tx)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import unittest
|
import unittest
|
||||||
|
import json
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from app import app
|
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):
|
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."""
|
"""Test helper: call fetch_func directly, bypassing the cache layer."""
|
||||||
@@ -97,5 +107,83 @@ class TestWebApp(unittest.TestCase):
|
|||||||
self.assertIn(b'500/500 CZK', response.data)
|
self.assertIn(b'500/500 CZK', response.data)
|
||||||
self.assertIn(b'?', 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,
|
||||||
|
'vs': '', 'sync_id': 'abc123',
|
||||||
|
}]
|
||||||
|
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,
|
||||||
|
'vs': '', 'sync_id': 'def456',
|
||||||
|
}]
|
||||||
|
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',
|
||||||
|
'vs': '', 'sync_id': 'ghi789',
|
||||||
|
}]
|
||||||
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user