diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..43d8950 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(git add:*)", + "Bash(go version *)", + "Bash(go mod *)", + "Bash(golangci-lint run *)", + "Bash(golangci-lint --version)", + "Bash(gofumpt *)", + "Bash(./bin/fuj help *)", + "Bash(./bin/fuj version *)", + "Bash(make go-test *)", + "Bash(make go-lint *)" + ] + } +} diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 646776f..9b996ce 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -37,3 +37,29 @@ jobs: --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ -t $IMAGE . docker push $IMAGE + + build-go: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - name: Login to Gitea registry + run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login -u ${{ github.actor }} --password-stdin gitea.home.hrajfrisbee.cz + + - name: Build and push Go image + run: | + TAG=${{ github.ref_name }} + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG=${{ inputs.tag }} + fi + IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG-go + docker build -f go/build/Dockerfile \ + --build-arg GIT_TAG=$TAG \ + --build-arg GIT_COMMIT=${{ github.sha }} \ + --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \ + -t $IMAGE go/ + docker push $IMAGE diff --git a/.gitignore b/.gitignore index a3dc557..cf45bd6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ # local tmp folder tmp/ + +# go build output +bin/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e78a939..a2f5324 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## 2026-05-04 12:02 CEST — Go rewrite M1: skeleton + tooling + +- Created `go/` tree with module `fuj-management/go` (Go 1.26). +- `cmd/fuj`: stdlib-flag subcommand dispatcher; `server` and `version` implemented, stubs for M2/M4 commands. +- `internal/config`: env loader mirroring `scripts/config.py` (same env var names and defaults). +- `internal/logging`: slog setup accepting log level from config. +- `internal/web`: `net/http` ServeMux on `:8080`; `middleware/timer.go` logs method/path/status/ms. +- `go/build/Dockerfile`: multi-stage (`golang:1.26` → `alpine:3`) producing a static binary image. +- Makefile: `web` → `web-py` alias; added `web-go`, `go-build`, `go-test`, `go-run`, `go-lint`. +- `.gitea/workflows/build.yaml`: parallel `build-go` job pushing `-go` image. +- Gate: `make go-build`, `make go-lint`, `make go-test`, `curl :8080` all pass. + ## 2026-05-03 20:37 CEST — Fix Balance column to correctly reflect past-month debt - Balance (and Pay-All) are now computed as `sum(paid − expected)` over past months only, iterating directly over the ledger entries from `reconcile()`. diff --git a/CLAUDE.md b/CLAUDE.md index d197811..bda0b50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,3 +108,13 @@ Maintain a running changelog in `CHANGELOG.md` at the repo root. After every sig ``` Get the timestamp with `date "+%Y-%m-%d %H:%M %Z"`. Skip trivial edits (typos, formatting, comment tweaks); only log changes a future reader would care about. + +## Plans + +When Claude Code's plan mode is used, save the plan file inside the repo at +`docs/plans/YYYY-MM-DD-HHMM-.md` instead of the default `~/.claude/plans/` +location. Get the timestamp with `date "+%Y-%m-%d-%H%M"` (matches the changelog +convention). The `` should be a short kebab-case summary of the plan's topic. + +Create the `docs/plans/` directory on first use. Plan files are committed to the +repo so other contributors can review historical decisions. diff --git a/Makefile b/Makefile index 48938ac..21718d5 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,13 @@ -.PHONY: help fees match web web-debug 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-run go-lint image run sync sync-2026 test test-v docs export PYTHONPATH := scripts:$(PYTHONPATH) VENV := .venv PYTHON := $(VENV)/bin/python3 CREDENTIALS := .secret/fuj-management-bot-credentials.json +GO_SRC := go +GO_BIN := bin/fuj + $(PYTHON): .venv/.last_sync .venv/.last_sync: pyproject.toml @@ -13,20 +16,25 @@ $(PYTHON): .venv/.last_sync help: @echo "Available targets:" - @echo " make fees - Calculate monthly fees from the attendance sheet" - @echo " make match - Match Fio bank payments against expected attendance fees" - @echo " make web - Start a dynamic web dashboard locally" - @echo " make web-debug - Start a dynamic web dashboard locally in debug mode" - @echo " make image - Build an OCI container image" - @echo " make run - Run the built Docker image locally" + @echo " make fees - Calculate monthly fees from the attendance sheet" + @echo " make match - Match Fio bank payments against expected attendance fees" + @echo " make web - Start Python dashboard (alias for web-py, until M8)" + @echo " make web-py - Start Python dashboard on :5001" + @echo " make web-go - Build and start Go dashboard on :8080" + @echo " make web-debug - Start Python dashboard in debug mode" + @echo " make go-build - Build Go binary to bin/fuj" + @echo " make go-test - Run Go tests" + @echo " make go-lint - Run golangci-lint on Go code" + @echo " make image - Build Python OCI container image" + @echo " make run - Run the built Python Docker image locally" @echo " make sync - Sync Fio transactions to Google Sheets" @echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)" @echo " make sync-2026 - Sync Fio transactions for the whole year of 2026" @echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet" @echo " make reconcile - Show balance report using Google Sheets data" @echo " make venv - Sync virtual environment with pyproject.toml" - @echo " make test - Run web application infrastructure tests" - @echo " make test-v - Run tests with verbose output" + @echo " make test - Run Python web application infrastructure tests" + @echo " make test-v - Run Python tests with verbose output" @echo " make docs - Serve documentation in a browser" venv: @@ -38,12 +46,33 @@ fees: $(PYTHON) match: $(PYTHON) $(PYTHON) scripts/match_payments.py -web: $(PYTHON) +web: web-py + +web-py: $(PYTHON) $(PYTHON) app.py web-debug: $(PYTHON) FLASK_DEBUG=1 $(PYTHON) app.py +go-build: + cd $(GO_SRC) && go build -trimpath \ + -ldflags "-X main.version=$$(git describe --tags --always 2>/dev/null || echo dev) \ + -X main.commit=$$(git rev-parse --short HEAD) \ + -X main.buildDate=$$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -o ../$(GO_BIN) ./cmd/fuj + +go-test: + cd $(GO_SRC) && go test -race ./... + +go-run: go-build + ./$(GO_BIN) $(ARGS) + +go-lint: + cd $(GO_SRC) && golangci-lint run ./... + +web-go: go-build + ./$(GO_BIN) server + image: docker build -t fuj-management:latest \ --build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \ diff --git a/docs/plans/2026-05-03-2325-document-plan-location-convention.md b/docs/plans/2026-05-03-2325-document-plan-location-convention.md new file mode 100644 index 0000000..a2fcb76 --- /dev/null +++ b/docs/plans/2026-05-03-2325-document-plan-location-convention.md @@ -0,0 +1,52 @@ +# Plan: Document plan-file location convention in `CLAUDE.md` + +## Context + +The user wants all plan files (created during Claude Code's plan mode) to live +inside the project at `docs/plans/`, with a creation timestamp in the filename. +This keeps planning artifacts version-controlled alongside the code, makes it +easy to see when each plan was drafted, and — critically — needs to be +discoverable by other contributors who use Claude Code on this repo. So the +convention belongs in `CLAUDE.md`, not in private agent memory. + +## Approach + +1. **Add a new section to `CLAUDE.md`** (placed near the existing "Changelog" + section, since both are about persisted artifacts that Claude maintains): + + ```markdown + ## Plans + + When Claude Code's plan mode is used, save the plan file inside the repo at + `docs/plans/YYYY-MM-DD-HHMM-.md` instead of the default + `~/.claude/plans/` location. Get the timestamp with + `date "+%Y-%m-%d-%H%M"` (matches the changelog convention). The `` + should be a short kebab-case summary of the plan's topic. + + Create the `docs/plans/` directory on first use. Plan files are committed + to the repo so other contributors can review historical decisions. + ``` + +2. **Create the `docs/plans/` directory** with a `.gitkeep` (or just let it + appear when the first plan is moved in) so the path exists. + +3. **Move this current plan** into the new location once plan mode exits: + `docs/plans/2026-05-03-1200-document-plan-location-convention.md` + (timestamp will be re-generated with the actual `date` output). + +4. **No memory entry needed** — the rule lives in `CLAUDE.md` and is loaded + automatically into every Claude Code session in this repo. + +## Files touched + +- [CLAUDE.md](CLAUDE.md) — add the new "## Plans" section. +- New directory: [docs/plans/](docs/plans/) — created on first use. +- Move this plan file from `~/.claude/plans/...` into `docs/plans/` with the + proper timestamped filename. + +## Verification + +- `grep -A 5 "## Plans" CLAUDE.md` shows the new section. +- `ls docs/plans/` lists this plan file with a `YYYY-MM-DD-HHMM-` prefix. +- Next time plan mode is entered in this repo, the new plan is written to + `docs/plans/` with a fresh timestamp (verify by re-entering plan mode). diff --git a/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md b/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md new file mode 100644 index 0000000..54934e9 --- /dev/null +++ b/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md @@ -0,0 +1,158 @@ +# Go Rewrite — Progress Tracker + +Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md). + +**Current milestone:** M2 — Pure-domain helpers +**Started:** 2026-05-04 +**Last updated:** 2026-05-04 + +## How to use + +- Tick a checkbox when the task's PR/commit lands. Append the SHA in the same + line: `[x] **M1.1** ... — `abc1234``. +- One task = one focused commit or PR. If a task balloons, split it and add + sub-tasks below the parent. +- Note decisions, surprises, or blockers under "Notes & decisions" at the + bottom — that's where future-you (or a contributor) will look first. +- Don't reorder milestones. Within a milestone, tasks can be done in any + order unless explicitly noted. + +--- + +## M1 — Skeleton + tooling + +Goal: `make web-go` serves a hello page on :8080 in parallel with `make web-py` on :5001. Lint clean. + +- [x] **M1.1** Create `go/` tree skeleton + `go.mod` initialized to latest stable Go +- [x] **M1.2** Add `cmd/fuj/main.go` with subcommand dispatcher — stdlib `flag` + `os.Args[1]` switch +- [x] **M1.3** Wire `fuj server` subcommand: `net/http` ServeMux on `:8080`, plaintext hello page +- [x] **M1.4** Add Makefile targets: `go-build`, `go-test`, `go-run`, `go-lint` +- [x] **M1.5** Rename existing `make web` → `make web-py`; added `make web-go`; kept `make web` as alias +- [x] **M1.6** Add `go/.golangci.yml` (govet, staticcheck, errcheck, gofumpt, unused) + `make go-lint` clean +- [x] **M1.7** Write `go/build/Dockerfile` (multi-stage `golang:1.26` → `alpine:3`); parallel `build-go` job in Gitea CI +- [x] **M1.8** Add `internal/config` package mirroring `scripts/config.py` (same env var names + defaults) +- [x] **M1.9** Add `internal/logging` (slog, level from config) + `middleware/timer.go` (method/path/status/ms) +- [x] **M1.10** Gate passed: `make go-build`, `make go-lint`, `make go-test`, `curl :8080` all green; CHANGELOG entry added + +**Gate:** ✅ `make go-build` succeeds, `curl localhost:8080` returns hello page, `make go-lint` clean. + +--- + +## M2 — Pure-domain helpers (port leaf-first) + +Goal: every pure function from the Python backend exists in Go with a parity test against captured fixtures (M3 produces fixtures in parallel — order is M2.1 → M3.1/M3.2 → M3.3+ alongside M2.2+). + +Each task: port the function, write Go unit tests for fresh cases, hook into the Tier-1 parity runner. + +- [ ] **M2.1** `domain/czech.Normalize` — port [czech_utils.py](scripts/czech_utils.py) `normalize` (NFKD + combining-mark strip + lowercase) +- [ ] **M2.2** `domain/czech.ParseMonthReferences` — port `parse_month_references` (45 month declensions, range wrap, year inference) +- [ ] **M2.3** `domain/fees.CalculateFee` — port [attendance.py](scripts/attendance.py) `calculate_fee` (constants table) +- [ ] **M2.4** `domain/fees.CalculateJuniorFee` — port `calculate_junior_fee` with `Expected{Value int; Unknown bool}` for the `"?"` sentinel +- [ ] **M2.5** `domain/money.ParseCZK` — port [infer_payments.py](scripts/infer_payments.py) `parse_czk_amount` (Czech locale: comma decimal, dot/space thousand separators) +- [ ] **M2.6** `domain/synch.GenerateSyncID` — port [sync_fio_to_sheets.py](scripts/sync_fio_to_sheets.py) `generate_sync_id` (SHA-256, byte-stable hash; verify float string format against real sheet rows) +- [ ] **M2.7** `domain/matching.BuildNameVariants` + `MatchMembers` — port `_build_name_variants` and `match_members` from [match_payments.py](scripts/match_payments.py) (auto vs review confidence, common-surname filter) +- [ ] **M2.8** `domain/matching.InferTransactionDetails` — port `infer_transaction_details` (composes name + month parsing) +- [ ] **M2.9** `domain/matching.FormatDate` — port `format_date` (handles Google Sheets serial-day numbers since 1899-12-30) +- [ ] **M2.10** `domain/reconcile.Reconcile` — port `reconcile` (three-phase allocation: greedy / proportional with float-remainder absorption / even-split fallback). The single most load-bearing function; budget extra time. +- [ ] **M2.11** `fuj fees` subcommand wired up via `domain/fees` + (M4-stub) attendance loader — fail gracefully on missing IO until M4 lands +- [ ] **M2.12** `fuj reconcile` subcommand similarly stubbed + +**Gate:** `cd go && go test -tags=parity ./tests/parity/pure/...` green for every fixture in `tests/fixtures/pure/`. + +--- + +## M3 — Fixture capture + characterization framework + +Goal: deterministic, PII-free fixture corpus that drives parity tests. Runs in parallel with M2 (M3.1/M3.2 unblocks M2.1). + +- [ ] **M3.1** `scripts/capture_fixtures.py` — pure-function output dumper. Reads inputs from stdin / argv, prints `{"input":..., "output":...}` JSON +- [ ] **M3.2** `scripts/scrub_fixtures.py` — replaces names with `Member_<8hex>` (deterministic per name); scrambles sender/account/VS/bank_id with stable bijection; preserves dates, amounts, exception keys +- [ ] **M3.3** Capture pure-fn fixtures for M2.1–M2.9 (run helper + scrubber, commit to `tests/fixtures/pure//.json`) +- [ ] **M3.4** Capture ~10 reconcile fixtures spanning every code path: greedy, proportional (float remainder), even-split, out-of-window credit, exception override, `other:` purpose, junior `"?"`, multi-person comma-split, multi-month range, unmatched. Commit to `tests/fixtures/reconcile/` +- [ ] **M3.5** Hook fixtures into Tier-1 test runner with `-tags=parity` build constraint +- [ ] **M3.6** Document fixture-refresh workflow in `tests/fixtures/README.md` (what to do when sheet schema changes) + +**Gate:** `tests/fixtures/` populated; M2 parity tests green; raw `tmp/*.json` confirmed gitignored. + +--- + +## M4 — IO layer behind interfaces + +Goal: every external IO (Sheets, Drive, Fio, file cache) accessed through a narrow Go interface with both a real and a fake implementation. + +- [ ] **M4.1** Design IO interfaces (`SheetsClient`, `DriveClient`, `FioClient`, `FileCache`) + in-memory fakes seeded from M3 fixtures +- [ ] **M4.2** `internal/io/sheets` — Google client (read + append + batchUpdate); integration test against a separate test sheet (NOT prod) +- [ ] **M4.3** `internal/io/drive` — Drive `modifiedTime` client + integration test +- [ ] **M4.4** `internal/io/fio` — API JSON impl (token-based); parses by hardcoded `column0..column22` indices matching [fio_utils.py](scripts/fio_utils.py) +- [ ] **M4.5** `internal/io/fio` — transparent-page HTML scraper using `golang.org/x/net/html` token visitor; targets the **second** `` +- [ ] **M4.6** `internal/io/cache` — FileCache with `modifiedTime` gating + two TTL knobs + atomic writes (`os.Rename`) +- [ ] **M4.7** `services/banksync.SyncToSheets` + `fuj sync` subcommand +- [ ] **M4.8** `services/banksync.InferPayments` + `fuj infer [--dry-run]` subcommand + +**Gate:** `go test -tags=integration ./internal/io/...` round-trips against test sheet; default-tag tests run on fakes. + +--- + +## M5 — JSON-only `/api/...` routes + +Goal: byte-equal JSON between Python and Go for every route. This is the parity contract. + +- [ ] **M5.1** Hand-author Go structs for `/api/adults`, `/api/juniors`, `/api/payments`, `/api/version` with explicit `json:` tags matching Python keys; emit JSON Schemas via `github.com/invopop/jsonschema` to `tests/fixtures/api-schema/` +- [ ] **M5.2** Implement Go handlers for `/api/*` routes composing `services/*` results into the JSON structs +- [ ] **M5.3** Add Python `/api/X` shadow endpoints in [app.py](app.py): `jsonify(view_model_dict)` — no transformation +- [ ] **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. + +--- + +## M6 — Go-native HTML frontend + +Goal: feature-equivalent UX on the Go side, designed cleanly. Not a Jinja port. + +- [ ] **M6.1** Template skeleton: base layout, nav (Adults/Juniors/Payments/Sync/Flush), terminal-green-on-black theme; `embed.FS` for `templates/` + `static/` +- [ ] **M6.2** `/adults` page: table, name filter input, month range filter, totals row, credits/debts/unmatched sections, Pay buttons that link to `/qr` +- [ ] **M6.3** `/juniors` page: same structure + per-month J/A attendance breakdown + `"?"` sentinel rendering +- [ ] **M6.4** `/payments` page: grouped-by-person ledger view +- [ ] **M6.5** Modal JS module (`static/js/member-detail.js`): fetches `/api/adults` (or juniors), renders status/exceptions/transactions on row click; keyboard nav (Esc, ↑/↓) +- [ ] **M6.6** `/qr`, `/sync-bank`, `/flush-cache`, `/version` pages +- [ ] **M6.7** Wire `embed.FS` into handlers; verify single-binary deployment includes all assets + +**Gate:** Browser smoke on :8080: all pages render, name+month filters work, modal opens with correct data, QR loads, sync/flush work end-to-end. + +--- + +## M7 — Parallel-running watch period + +Goal: prove parity over real time before flipping the default. + +- [ ] **M7.1** Add Go service to `docker-compose.yml` on different port (alongside Python container) +- [ ] **M7.2** Set up `parity-nightly.yml` Gitea workflow: boot both, replay fixed transaction script, fail on diff +- [ ] **M7.3** Run `make parity` daily for 7–14 days, log any diffs; investigate and fix root cause (don't just allowlist) +- [ ] **M7.4** Manual feature parity check: walk through every UI feature on both sides, sign off in Notes section + +**Gate:** Zero non-allowlisted JSON diffs over 7 consecutive days, including a sync-bank execution + flush + attendance update; user sign-off on UI feature parity. + +--- + +## M8 — Cutover + Python retirement + +Goal: Go is the one true backend. + +- [ ] **M8.1** Update bookmarks, README, CLAUDE.md to point at Go (`make web` aliases to `make web-go`) +- [ ] **M8.2** Run Go-only for 2 weeks including a month-end settlement; keep Python container available but unrouted +- [ ] **M8.3** Manual reconciliation review: produce a balance report on `python-final` and on Go for the same period; sign off they match +- [ ] **M8.4** Tag final Python image as `python-final` in registry; remove Python service from `docker-compose.yml` +- [ ] **M8.5** Delete [app.py](app.py), [scripts/](scripts/), Python `Dockerfile`, [tests/](tests/), `pyproject.toml`, `uv.lock` +- [ ] **M8.6** Update [CLAUDE.md](CLAUDE.md) to reflect Go-only state (commands, architecture, key modules); CHANGELOG entry + +**Gate:** Two consecutive months of Go-only operation with end-of-month settlement complete; zero rollbacks. + +--- + +## Notes & decisions + +(Add entries as you go. Format: `YYYY-MM-DD — short note`.) + +- 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 — 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. diff --git a/docs/plans/2026-05-03-2349-go-backend-rewrite.md b/docs/plans/2026-05-03-2349-go-backend-rewrite.md new file mode 100644 index 0000000..0d370bc --- /dev/null +++ b/docs/plans/2026-05-03-2349-go-backend-rewrite.md @@ -0,0 +1,424 @@ +# Plan: Full Go rewrite of the Python/Flask backend + +## Context + +The current Flask app ([app.py](app.py) + [scripts/](scripts/), ~2400 LOC of +Python) handles attendance-based fee calculation, Fio bank sync, payment +reconciliation, and a server-rendered dashboard. The user wants a full +rewrite in Go with two goals: + +1. **Quality Go code** as the primary outcome — idiomatic stdlib-first + design, strong typing, proper layering. The Python codebase grew + organically and mixes domain logic, IO, and HTTP concerns. +2. **Feature-parity certainty** — no behavioural drift between the Python + and Go versions on anything that touches money. Reconciliation is real + money; silent divergence is unacceptable. + +**Switchable runtime**: both backends run on different TCP ports, started +independently via Makefile targets (`make web-py` on :5001, `make web-go` on +:8080). The user opens whichever they want in a browser. No reverse proxy, +no traffic-splitting, no shared frontend constraint — just two services +that read the same Google Sheets and the same `tmp/` cache. + +**Frontends are allowed to diverge.** The Go web layer is designed cleanly +in its own right rather than as a byte-compatible Jinja port. Both backends +expose a JSON API (`/api/...`) with an identical contract — that's what +parity testing locks down. Rendered HTML and inline JS can be different. + +## Versioning policy + +- **Go**: latest stable release at project start. Pin in `go.mod` via the + `go` directive (e.g. `go 1.X`) and use the matching `golang:1.X` builder + image. Bump on each new minor as it lands stable. +- **Go libraries**: latest stable for every dependency in `go.mod`; run + `go get -u ./... && go mod tidy` at the start and quarterly thereafter. +- **Python deps** (during the parallel-run period): keep + [pyproject.toml](pyproject.toml) on its current versions to avoid + destabilizing the parity baseline; bump only after Python retires. +- **Base images**: `golang:latest-stable` builder → `gcr.io/distroless/static:latest` + runtime, both pinned by digest in CI for reproducibility. +- **CI runners**: latest stable Linux image on Gitea Actions. + +The plan does not hardcode specific version numbers below — implementation +picks current-stable at the time M1 starts. + +## Approach summary + +- **Three-layer Go architecture**: pure domain (no IO) → IO clients (behind + interfaces, easily faked) → HTTP/services (composition). +- **Capture-then-port**: dump current Python outputs as JSON fixtures, port + Go function-by-function, assert byte-equality with `cmp.Diff`. +- **JSON contract is the spec, not the templates.** Each Python route gets + an `/api/X` shadow that returns the dict already passed to the template. + Go defines typed structs matching that shape; both sides validate against + generated JSON Schema. +- **Money is integer CZK**: existing fees are integer CZK (750/200/500); + keep it that way to avoid float drift in reconcile allocation. Where + Sheets returns floats, parse and round at the boundary. +- **Frontend rewrite, not port**: Go uses `html/template` with cleanly + organized templates and JS extracted into static files served via + `embed.FS`. Same UX (filterable table, member-detail modal, QR launcher) + but designed natively, no Jinja-port baggage. + +## Go project layout + +`go/` lives at the repo root alongside `scripts/` and `templates/` so both +backends share the same git history during migration. + +``` +go/ + cmd/ + fuj/main.go # single binary, subcommands: server | fees | sync | infer | reconcile + parity/main.go # diff tool: hits both backends' /api/X, prints JSON diff + internal/ + domain/ # pure, no IO, no net/* + czech/ # normalize, parse_month_references + fees/ # calculate_fee, calculate_junior_fee, "?" sentinel type + money/ # parse_czk_amount, format helpers + reconcile/ # reconcile() + Ledger, MemberResult types + matching/ # _build_name_variants, match_members, infer_transaction_details + synch/ # generate_sync_id (pure hash) + io/ # IO behind interfaces, all impls have an in-memory fake + sheets/ # SheetsClient + Google impl + fake + drive/ # DriveClient for modifiedTime + fio/ # FioClient: API JSON impl + transparent-page HTML scraper + cache/ # FileCache with modifiedTime gating + two TTL knobs + services/ # composition layer; pure + IO, no HTTP + attendance/ # GetMembersWithFees, GetJuniorMembersWithFees + payments/ # FetchTransactions, FetchExceptions, BuildView + banksync/ # SyncToSheets, InferPayments (write ops) + web/ + handlers/ # one file per route family + view/ # HTML view-model structs (per route) + api/ # JSON view-model structs (the parity-locked contract) + templates/ # *.tmpl, embed.FS — designed natively, not a Jinja port + static/ # js/*.js, css/*.css served via embed.FS + middleware/ # request timer, recovery, slog + config/ # mirrors scripts/config.py (env loading) + qr/ # SPD string builder + PNG via go-qrcode + tests/ + fixtures/ # JSON fixtures captured from Python (PII-scrubbed) + parity/ # Go-side characterization tests (replay fixtures) + build/Dockerfile # multi-stage: latest-stable golang builder → distroless static + go.mod +``` + +## Library choices + +All on latest stable as per the versioning policy above. + +| Concern | Pick | Rationale | +|---|---|---| +| HTTP routing | `net/http` ServeMux | 8 static routes; no need for chi/gin given modern stdlib pattern matching | +| Templates | `html/template` | Auto-escaping; native Go feel | +| Static assets | `embed.FS` | Single binary, no loose files | +| Sheets/Drive | `google.golang.org/api/{sheets/v4,drive/v3}` + `option` | Official client; service-account auth via `option.WithCredentialsFile` | +| OAuth | `golang.org/x/oauth2/google` (token only; drop installed-app flow + pickle) | Production already uses service accounts | +| QR PNG | `github.com/skip2/go-qrcode` | Mature, byte-stable PNG output | +| NFKD | `golang.org/x/text/unicode/norm` + `unicode.IsMark` | Direct equivalent of `unicodedata.normalize("NFKD", ...)` | +| HTML scrape | `golang.org/x/net/html` token visitor | Counts `
` to target the second one | +| CSV | `encoding/csv` (stdlib) | Match for Python `csv.reader` | +| Logging | `log/slog` (stdlib) | Honors `LOG_LEVEL` env | +| Diff/testing | `testing` + `github.com/google/go-cmp/cmp` | Readable `cmp.Diff` for parity assertions | +| Lint | `golangci-lint` (govet, staticcheck, errcheck, gofumpt, unused) | Standard quality gate | + +## Migration sequencing — eight milestones with hard gates + +**M1 — Skeleton + tooling.** Create `go/` tree, `go.mod` (latest stable +Go), Makefile targets (`go-build`, `go-test`, `go-run`, `web-go`), +`golangci-lint` config. `cmd/fuj server` prints a hello + version and +listens on :8080. +*Gate:* `make go-build` succeeds; `make web-go` serves a "hello" page on +:8080 in parallel with `make web` on :5001; lint clean. + +**M2 — Pure-domain helpers, port leaf-first.** Order: +[czech_utils.py](scripts/czech_utils.py) `normalize` → `parse_month_references` → +[attendance.py](scripts/attendance.py) `calculate_fee`/`calculate_junior_fee` → +[infer_payments.py](scripts/infer_payments.py) `parse_czk_amount` → +[sync_fio_to_sheets.py](scripts/sync_fio_to_sheets.py) `generate_sync_id` → +[match_payments.py](scripts/match_payments.py) helpers (`_build_name_variants`, +`match_members`, `infer_transaction_details`, `format_date`) → `reconcile`. +Each gets a Go unit test plus a parity test driven by JSON fixtures from M3. +Also: `fuj fees` and `fuj reconcile` subcommands wired up (pure-domain CLIs). +*Gate:* All ported helpers pass parity tests. + +**M3 — Fixture capture + characterization framework.** Build +`scripts/capture_fixtures.py` (Python helper that prints function results as +JSON to stdout — user pipes to disk) and `scripts/scrub_fixtures.py` +(replaces member names with deterministic pseudonyms `Member_<8hex>`, +scrambles sender/account/VS/bank_id while preserving structural +relationships, dates, amounts, exception keys). Capture ~10 reconcile +fixtures spanning every code path: greedy, proportional with float +remainder, even-split fallback, out-of-window credit, exception override, +`other:` purpose, junior `"?"`, comma-separated multi-person, multi-month +range, unmatched. +*Gate:* `tests/fixtures/` populated and committed; M2 parity tests green. + +**M4 — IO layer behind interfaces.** Implement Sheets/Drive/Fio clients +matching Python return shapes. Drop the OAuth+pickle path entirely (service +account only). All clients have in-memory fakes for tests. Wire `fuj sync` +and `fuj infer` subcommands. +*Gate:* `go test -tags=integration ./internal/io/...` round-trips against a +test sheet (separate from prod); default-tag tests use fakes. + +**M5 — JSON-only `/api/...` routes.** Add 8 Go route handlers that return +JSON. Add symmetric `/api/X` shadow endpoints in [app.py](app.py) that +`jsonify` the existing view-model dict (no transformation). +*Gate:* For each route, `cmd/parity` asserts +`cmp.Diff(python.json, go.json) == ""` modulo allowlist +(`render_time.total`, `build_meta`). + +**M6 — Go-native HTML frontend.** Design Go templates cleanly (not a Jinja +port). Extract JS from inline into `internal/web/static/js/*.js` served via +`embed.FS`. Vanilla JS, no framework — same UX as Python (sortable table, +member-detail modal, name filter, month range filter, QR launcher) but +organized as proper modules. Templates render the JSON API response into +HTML; frontend JS fetches additional data from `/api/X` for the modal +rather than embedding `member_data` in `