# 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 `