Stand up the Go project alongside the Python backend so both run
independently during migration. `make web-go` builds and serves on :8080;
`make web-py` (alias: `make web`) keeps the Python side on :5001.
- go/: new module `fuj-management/go` (Go 1.26)
- cmd/fuj: stdlib-flag dispatcher; `server` + `version` work,
fees/reconcile/sync/infer stubbed for M2/M4
- internal/config: env loader mirroring scripts/config.py
- internal/logging: slog setup, level taken from config
- internal/web: net/http ServeMux + request-timer middleware
- build/Dockerfile: golang:1.26 → alpine:3 multi-stage image
- .golangci.yml: govet, staticcheck, errcheck, gofumpt, unused
- Makefile: web→web-py alias; go-build/go-test/go-run/go-lint/web-go
- CI: parallel build-go job in .gitea/workflows/build.yaml (<tag>-go image)
- docs/plans/: M1 kickoff plan + progress tracker (M1 complete)
- .claude/settings.json: gofumpt + golangci-lint permissions
Gate: make go-build ✓ make go-lint ✓ make go-test ✓ curl :8080 ✓
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
22 KiB
Plan: Full Go rewrite of the Python/Flask backend
Context
The current Flask app (app.py + 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:
- 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.
- 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.modvia thegodirective (e.g.go 1.X) and use the matchinggolang:1.Xbuilder image. Bump on each new minor as it lands stable. - Go libraries: latest stable for every dependency in
go.mod; rungo get -u ./... && go mod tidyat the start and quarterly thereafter. - Python deps (during the parallel-run period): keep pyproject.toml on its current versions to avoid destabilizing the parity baseline; bump only after Python retires.
- Base images:
golang:latest-stablebuilder →gcr.io/distroless/static:latestruntime, 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/Xshadow 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/templatewith cleanly organized templates and JS extracted into static files served viaembed.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 <table class="table"> 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 normalize → parse_month_references →
attendance.py calculate_fee/calculate_junior_fee →
infer_payments.py parse_czk_amount →
sync_fio_to_sheets.py generate_sync_id →
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 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 <script>.
Gate: Browser smoke test of all routes on :8080 covers: name filter,
month filter, modal opens with correct months/transactions/exceptions, QR
modal renders, navigation between adults/juniors/payments works.
M7 — Parallel-running watch period. Both make web-py and make web-go
running locally (and in production via two containers on different ports).
Daily/manual cmd/parity runs catch any JSON drift. The user verifies the
Go UI matches what they expect feature-by-feature against the Python UI.
Run 1–2 weeks.
Gate: Zero non-allowlisted JSON diffs over 7 consecutive days, including
a sync-bank execution, a flush, and an attendance update. User sign-off
that the Go UI is feature-complete.
M8 — Cutover + Python retirement. Switch the bookmarked URL / docs to
the Go port. Keep Python container running but unrouted (or stopped) for
1 week as rollback. Then delete app.py, scripts/,
the Python Dockerfile, and the Python tests. Update
CLAUDE.md to reflect the Go-only state.
Gate: Two consecutive months of Go-only operation including end-of-month
settlement.
CLI port (decided: port as Go subcommands)
Single Go binary fuj with subcommands replacing the existing Makefile
targets. Each reuses the domain layer directly:
| Old | New | Backed by | Milestone |
|---|---|---|---|
make fees |
fuj fees |
domain/fees + services/attendance |
M2 |
make reconcile |
fuj reconcile |
domain/reconcile |
M2 |
make sync-2026 |
fuj sync --year=2026 |
services/banksync.SyncToSheets |
M4 |
make infer |
fuj infer [--dry-run] |
services/banksync.InferPayments |
M4 |
make web (py) |
stays as Python make web-py until M8 |
— | — |
make web-go |
fuj server |
web/handlers |
M1 |
Makefile targets get rewritten to invoke ./bin/fuj <subcommand> once each
is ported. The Python make targets for already-ported commands stay as
make X-py aliases until M8, so you can run either side for cross-checks.
JSON API contract strategy
Go-defines, Python-conforms with a 1-step bootstrap:
- Run Python locally and dump
result["members"],formatted_results,monthly_totals, etc., to JSON. This is the spec. - Hand-author Go structs with explicit
json:tags matching exact Python keys (total_balance,original_expected,attendance_count— no reliance on default lowercasing). - Generate
tests/fixtures/api-schema/*.schema.jsonfrom the Go structs usinggithub.com/invopop/jsonschema. Commit them. - Add a Python-side schema validator running in CI against the new
/api/Xresponses.
Two known-tricky shapes:
- Junior
expected: int | "?"→Same fortype Expected struct{ Value int; Unknown bool } // MarshalJSON emits 42 or "?"original_expected. - Tuple dict keys
(normalize(name), normalize(period))for exceptions — internal only, never crosses JSON. Usemap[ExceptionKey]ExceptionwithExceptionKey struct{ Name, Period string }.
Characterization test harness — two tiers
(HTML rendering parity dropped: frontends are intentionally different.)
Tier 1 — Pure-function parity (fast, every commit). Fixtures at
tests/fixtures/pure/<func>/<case>.json containing {input, output},
captured once via scripts/capture_fixtures.py. Go test reads each, calls
the ported function, asserts deep equality with cmp.Diff. Functions in
scope: normalize, parse_month_references, parse_czk_amount,
parse_czech_amount, parse_czech_date, format_date,
_build_name_variants, match_members, infer_transaction_details,
generate_sync_id, calculate_fee, calculate_junior_fee, reconcile.
Tier 2 — JSON API parity (medium, on PR + nightly). cmd/parity/main.go
hits both :5001/api/X and :8080/api/X with a fixture-seeded tmp/
cache, normalizes volatile fields (render_time, build metadata), asserts
byte-equality. Cache freezing: pre-populate tmp/*_cache.json from
scrubbed snapshots so both backends read identical data.
PII scrubbing is mandatory (CLAUDE.md: "Member data must
never be committed"). scripts/scrub_fixtures.py produces deterministic
pseudonyms preserving uniqueness and structural relationships. Only
scrubbed fixtures land in tests/fixtures/; raw tmp/*.json stays
gitignored.
Side-by-side runtime
Two services on different ports, started independently. No reverse proxy.
make web-py # Python on :5001 (existing target, perhaps renamed from `make web`)
make web-go # Go on :8080
Both read the same Google Sheets and write to the same tmp/ cache
directory. The user opens localhost:5001 or localhost:8080 directly to
A/B compare.
Cache directory coordination: both backends use tmp/. Go writes via
os.WriteFile to tmp/<key>_cache.json.tmp then os.Rename (atomic on
Linux). Python's writes are pre-existing-non-atomic; accept until Python
retires.
Sync coordination: /sync-bank is non-idempotent under concurrency.
Both backends flock on tmp/sync.lock; Go uses syscall.Flock. (In
practice the user is unlikely to trigger sync from both UIs at once, but
the lock is cheap insurance.)
Production deployment: keep the existing Python container; add a Go
container in docker-compose.yml exposed on a different port. After M8,
remove the Python service.
CI/CD
Currently zero test CI (.gitea/workflows/build.yaml
only does docker build/push). Add /.gitea/workflows/test.yml:
jobs:
python-tests: # fix M3 broken-test references first
- uv sync && pytest tests/
go-tests:
- cd go && go test -race ./...
- cd go && golangci-lint run
parity-pure: # Tier 1
- cd go && go test -tags=parity ./tests/parity/...
Branch protection: python-tests, go-tests, parity-pure block merge.
Tier-2 parity runs nightly via parity-nightly.yml (boots both servers
via docker-compose with seeded caches, replays a fixed transaction script,
fails on any non-allowlisted diff).
A new Go build/Dockerfile (multi-stage: latest-stable golang builder →
gcr.io/distroless/static:latest, both pinned by digest) mirrors the
existing Python build job and produces a single static binary image.
Risk register (top 4)
(Template auto-escape divergence dropped: irrelevant when frontends differ.)
- Sync ID hash drift — HIGH/HIGH. Python builds the SHA-256 input by
str()-ing each field then.lower()-ing the joined string;str(750.0) == "750.0",str(750) == "750". If Sheets API returns floats in Python but Go unmarshals as int,750vs750.0→ different hash → duplicate rows. Mitigation: dedicated parity test with ~50 real-row fixtures; if Go can't reproduce Python's float string format, normalize at the boundary (round to 2 decimals, format with explicit precision). - Float allocation in
reconcile()proportional phase — HIGH/MEDIUM. Python's "last month absorbs remainder" depends on dict iteration order; Go map iteration is randomized. Mitigation: always iteratesorted_monthsexplicitly in Go, never the map. Lock the distribution with a parity test on (300, 300, 150) months × 751-CZK payment. - NFKD edge cases — MEDIUM/MEDIUM. Python
unicodedataand Gogolang.org/x/textuse the same algorithm but can differ on niche compatibility decompositions ifx/textis older than CPython's tables. Mitigation: parity test with every distinct character ever observed in member names; pinx/textversion explicitly. - Czech month parser semantics — MEDIUM/MEDIUM. Wrap-around year
inference (
if start_m > end_m and m >= start_m: year = default_year - 1) plus the "month >= 10 → previous year" heuristic are easy to mis-port. Mitigation: port table and algorithm verbatim line-for-line; parity test with ~30 realmessage-field fixture strings.
Cutover plan
Simpler without a proxy in the middle:
- After M7's 7-day clean window + user sign-off, treat Go as primary.
Update bookmarks, docs,
make webto point at Go. - Keep
make web-pyavailable for 1-week rollback. Run both containers in production but only point users at the Go one. - Watch 2 weeks including a month-end settlement on Go-only.
- Decommission Python: remove from
docker-compose.yml, delete app.py and scripts/, update CLAUDE.md. Keep image taggedpython-finalin registry as a 6-month rollback option.
Retirement criteria: zero parity-diff incidents in last 30 days, zero
rollbacks, two month-end settlements completed Go-only, manual
reconciliation review against python-final signed off.
Critical files
- scripts/match_payments.py —
reconcile()is the single most load-bearing function (~200 lines of allocation logic) that must port byte-equivalently. - scripts/czech_utils.py —
normalizeandparse_month_referencesunderpin every member/month match across the system. 45 Czech month declensions, range wrap-around, year inference. - app.py — defines the 8-route HTTP surface and view-model shapes. The spec for the Go web layer's JSON API.
- scripts/sync_fio_to_sheets.py —
generate_sync_iddefines the dedup contract against existing rows in the live sheet. Any drift creates duplicates. - scripts/attendance.py — fee math + merged-month
logic + junior
"?"sentinel. - scripts/cache_utils.py — Drive
modifiedTimegating + two-TTL fallback that must be reproduced for shared-cache safety. - templates/adults.html — read for the JSON shape
the existing inline JS consumes (
member_data); the Go frontend doesn't have to mirror the template, but the JSON contract derived from this page's data injection is the parity spec.
Verification
End-to-end checks per milestone:
- M1:
make go-build && ./bin/fuj server --helpprints subcommand list.make web-goserves :8080 in parallel withmake web-pyon :5001. - M2-M3:
cd go && go test -tags=parity ./tests/parity/pure/...green. Spot-check: feed a known Czech-message string through bothparse_month_referencesimplementations, diff outputs. - M4:
go test -tags=integration ./internal/io/sheets/...round-trips against a test sheet (separate from prod). - M5:
curl localhost:5001/api/adults | jq -S . > py.json && curl localhost:8080/api/adults | jq -S . > go.json && diff py.json go.json— empty diff modulo allowlist. - M6: Browser open
localhost:8080/adults, click a member row, modal opens with all months / transactions / exceptions correctly populated. Same on/juniors. Click a Pay button → QR loads. Name filter and month range filter work. - M7: Run
cd go && ./bin/parity --base http://localhost:5001 --candidate http://localhost:8080 --routes adults,juniors,paymentsdaily for 7 days, zero non-allowlisted diffs. User confirms Go UI is feature-complete vs Python UI side-by-side. - M8:
make web-pyremoved from Makefile;make webpoints at Go; manual end-of-month settlement on Go matches the prior month's Python-produced report.
Open questions / forks the user can override at review
- Frontend JS organization in M6: default is vanilla JS in separate
files via
embed.FS. If the user wants HTMX, Alpine.js, or a small framework, raise it before M6. - CI host: Gitea Actions assumed (matches existing .gitea/workflows/build.yaml).
- Test sheet for M4 integration tests: would need provisioning. Confirm whether to use a copy of the production sheet (PII!) or a synthetic one seeded by the fixture-capture process.