Compare commits

...

48 Commits

Author SHA1 Message Date
67d2f11d7c feat(go): fixture capture + characterization framework (M3)
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Closes M3.1–M3.6.  Parity safety net proving Go output matches Python
for every ported pure-domain function (M2.1–M2.9) and reconcile (M2.10).

Capture pipeline:
- scripts/capture_fixtures.py: calls each Python function with seeded
  inputs, emits JSON fixtures to stdout (never writes files directly).
- scripts/scrub_fixtures.py: deterministic PII scrubber — SHA-256
  pseudonyms for member names, digit-preserving hashes for VS/account/
  bank_id, name-sweep in message text.  Idempotent; no salt.
- scripts/_fixture_seeds.py: handcrafted seeds for all 11 functions;
  synthetic names throughout (no real roster members).
- scripts/capture_all_fixtures.sh: convenience wrapper for full corpus
  regeneration outside of make.

Fixture corpus (98 files, all PII-free):
- go/tests/fixtures/pure/<func>/<case>.json — 10 function directories.
- go/tests/fixtures/reconcile/<NN>_<case>.json — 10 branch-coverage
  cases: greedy, overpayment credit, proportional remainder, even-split,
  out-of-window, exception override, other: purpose, junior ?, multi-
  person+month fan-out, unmatched.

Go parity tests (//go:build parity):
- go/tests/parity/parityio.go: generic LoadDir/RunAll helpers + typed
  In/Out struct pairs for all 10 pure functions; Envelope decoder for
  int/float/none disambiguation.
- 10 pure-function test packages + bespoke reconcile test with per-cell
  float tolerance (math.Abs <= 0.01 for `paid` values).

Makefile: go-parity, go-test-all, capture-fixtures targets.
go/tests/fixtures/README.md: refresh workflow + PII audit guide.

Gate: make go-test green, make go-parity green (11/11 packages),
      make go-lint clean (parity tag), make go-build clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 23:26:24 +02:00
28f0e468f7 Merge pull request 'feat(go): wire fuj fees + fuj reconcile (M2.11-12)' (#11) from feat/m2-11-12-fees-reconcile-cli into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
Reviewed-on: #11
2026-05-06 15:54:33 +00:00
8386af8078 chore: tick M2.11 + M2.12 in progress tracker + CHANGELOG entry
All checks were successful
Deploy to K8s / deploy (push) Successful in 12s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 17:50:41 +02:00
56aa2303a8 feat(go/M2.11-12): wire fuj fees + fuj reconcile subcommands
Add internal/services/membership package: AttendanceLoader,
TransactionLoader, ExceptionLoader interfaces + NewStubSources stub
(returns ErrIOPending until M4 lands real Sheets loaders).

FeesReport and ReconcileReport orchestrate domain/fees + domain/reconcile
and write fixed-width text reports matching Python calculate_fees.py and
match_payments.py print_report output. 13 unit tests cover all formatter
branches and orchestration wiring via fake loaders.

cmd/fuj/main.go: fees and reconcile subcommands now dispatch; sync/infer
retain the [M4] placeholder.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 17:50:31 +02:00
ea8622a541 Merge pull request 'feat(go/M2.10): port domain/reconcile.Reconcile' (#10) from feat/m2-10-reconcile-domain into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Reviewed-on: #10
2026-05-06 14:55:17 +00:00
71278e6f7a chore: tick M2.10 in progress tracker + CHANGELOG entry
All checks were successful
Deploy to K8s / deploy (push) Successful in 16s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:53:39 +02:00
34ce0be5a0 feat(go/M2.10): port domain/reconcile.Reconcile
Three-phase payment allocation (greedy / proportional / even-split)
ported verbatim from scripts/match_payments.py reconcile().
Includes 12 unit tests covering all Python test cases plus Go-only
extras: [?] stripping, other: purpose, out-of-window credit, inference
fallback, and no-match/empty-transaction guards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 16:52:22 +02:00
c5a8a4e7b1 fix: include juniors in payment-inference roster
Some checks failed
Deploy to K8s / deploy (push) Successful in 10s
Build and Push / build (push) Successful in 6s
Build and Push / build-go (push) Failing after 12m23s
infer_payments was building member_names from get_members_with_fees()
(adults sheet only). Junior-only members were invisible to the matcher,
so a payment message containing an exact junior name would produce a
fuzzy review match against a different adult sharing the same first name.

Fix: union the adult and junior rosters (deduped via canonical_member_key)
so all members are candidates. The existing exact-name short-circuit in
match_members then handles precedence correctly.

Two regression tests added for the Jáchym Kubík / Jáchym Hrušák case.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 16:38:21 +02:00
3e597242eb Merge pull request 'feat(go): port matching helpers (M2.7-2.9)' (#9) from feat/m2-7-2-9-matching-package into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
Reviewed-on: #9
2026-05-06 13:58:26 +00:00
7232697e9c chore: tick M2.7-2.9 in progress tracker + CHANGELOG entry
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 13:19:58 +02:00
e596f0000e feat(go/M2.7-2.9): port domain/matching package
New go/internal/domain/matching package porting three helpers from
scripts/match_payments.py:

- BuildNameVariants: normalized ASCII variants from a member name (nickname
  in parens, last/first split, len<3 filtered); variants[0] is always the
  full base name — MatchMembers relies on this invariant.
- MatchMembers: auto/review confidence matching with an exact-name
  short-circuit pass that prevents nickname substrings (tov) from firing
  inside longer surnames (ottova); common-surname filter for review tier.
- FormatDate: nil/empty/""/serial int/float64 (since 1899-12-30, fractional
  days supported)/YYYY-MM-DD passthrough/garbage → never errors.
- InferTransactionDetails: composes BuildNameVariants+MatchMembers+
  ParseMonthReferences; falls back to sender-only member match and
  date-derived month when text carries no signal.

21 table-driven tests; all expected values verified against live Python
on 2026-05-06. go-build, go-test, go-lint all clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 13:19:42 +02:00
c2bffed1b8 Merge pull request 'feat(go/M2.6): port domain/synch.GenerateSyncID' (#8) from feat/m2-6-synch-generate-sync-id into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Reviewed-on: #8
2026-05-06 11:01:43 +00:00
54a783ea00 feat(go/M2.6): port domain/synch.GenerateSyncID
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
SHA-256 dedup hash from sync_fio_to_sheets.py generate_sync_id.
Key subtlety: Python str(float) emits "500.0" for whole-valued floats
and switches to scientific notation at |f|>=1e16 or |f|<1e-4 —
replicated via formatAmount using 'f'/'e' format selection.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 12:43:41 +02:00
84a5d177e9 Merge pull request 'feat(go/M2.5): port domain/money.ParseCZK' (#7) from feat/m2-5-money-parse-czk into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
Reviewed-on: #7
2026-05-06 07:39:42 +00:00
1a63bfd313 chore: tick M2.5 in progress tracker + CHANGELOG entry
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 09:39:01 +02:00
d24d20553a feat(go/M2.5): port domain/money.ParseCZK
Port scripts/infer_payments.py parse_czk_amount to Go as
internal/domain/money.ParseCZK. Preserves the Czech-locale heuristic
(comma = decimal sep; 2+ dots = thousand seps; single dot = decimal)
and returns (float64, error) so callers can opt into Python's
silent-zero contract via v, _ := money.ParseCZK(s).
All expected values verified against live Python on 2026-05-06.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 09:38:28 +02:00
fa853780db chore: tick M2.3 + M2.4 in progress tracker + CHANGELOG entry
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 09:25:45 +02:00
0fc3b6dd9a Merge pull request 'feat(go/M2.3+M2.4): port domain/fees.CalculateFee and CalculateJuniorFee' (#6) from feat/m2-3-m2-4-domain-fees into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Reviewed-on: #6
2026-05-06 07:23:02 +00:00
57ec817044 feat(go/M2.3+M2.4): port domain/fees.CalculateFee and CalculateJuniorFee
All checks were successful
Deploy to K8s / deploy (push) Successful in 6s
Ports calculate_fee and calculate_junior_fee from scripts/attendance.py
into a new go/internal/domain/fees package. Introduces the Expected type
(Value int, Unknown bool) for the junior "?" sentinel, keeping the Go
API strictly typed instead of mirroring Python's str|int return.

All 20 table-driven tests pass with -race; golangci-lint clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 00:38:09 +02:00
6cf83a01e3 docs(claude): correct stale adult fee defaults
ADULT_FEE_DEFAULT is 700 CZK, not 750. The 750 appears in
ADULT_FEE_MONTHLY_RATE for most current months but is not the fallback.
Rephrase the member-tiers bullet to point at the dict rather than a
number that drifts each season; update the fee-calc bullet to match
the junior line's style (default 700 vs default 500).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 00:29:19 +02:00
98f401c149 chore: tick M2.2 in progress tracker + CHANGELOG entry
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 00:10:44 +02:00
0a8017fffa Merge pull request 'feat(go/M2.2): port czech.ParseMonthReferences' (#5) from feat/m2-2-parse-month-references into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Reviewed-on: #5
2026-05-05 22:07:15 +00:00
6d971b61d4 feat(go/M2.2): port czech.ParseMonthReferences
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Three-pass regex parser matching python/czech_utils.py parse_month_references:
1. Numeric slash notation — "11+12/2025", "01/26"; 2-digit year → +2000
2. Dot notation — "12.2025" (4-digit year only)
3. Czech month names — range walk (listopad-leden wrap logic) then
   standalone with m≥10 → defaultYear-1 heuristic; longest-match
   alternation (sorted desc by name length) handles cervenec vs cerven

35 table-driven tests, all expected outputs verified against live Python
on 2026-05-05 before locking. Plan at
docs/plans/2026-05-05-2337-go-rewrite-m2-2-parse-month-references.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 00:05:40 +02:00
3460f57c62 chore: tick M2.1 in progress tracker + CHANGELOG entry
All checks were successful
Deploy to K8s / deploy (push) Successful in 9s
go/internal/domain/czech.Normalize merged as 20ade6d (PR #4).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 23:34:00 +02:00
6ca35e2112 docs: Encourage tea CLI for opening MRs
Replaces the "do not use tea/gh/Gitea API" rule with explicit guidance to
run `tea pr create` and print the resulting PR URL. tea is already
authenticated on this machine. Merging stays a manual user action in
Gitea — neither tea nor git CLI may merge or delete branches.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 23:33:31 +02:00
20ade6de3e Merge pull request 'feat(go/M2.1): port czech.Normalize' (#4) from feat/m2-1-czech-normalize into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Reviewed-on: #4
2026-05-05 21:26:55 +00:00
d9a61b338c feat(go/M2.1): port czech.Normalize — NFKD + Mn strip + lowercase
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
Adds internal/domain/czech.Normalize, the first pure-domain function in
the Go rewrite (M2 milestone). Matches Python czech_utils.normalize byte-
for-byte: NFKD decompose via golang.org/x/text/unicode/norm, drop Mn-
category combining marks (unicode.Mn, not IsMark, to match Python's
unicodedata.combining() semantics), then strings.ToLower.

Includes 13-case table-driven test; all inputs spot-checked against the
Python implementation before locking. Adds golang.org/x/text v0.36.0 as
first external dependency.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 22:23:40 +02:00
91ac3b37cf docs: Add branch-per-feature + Gitea MR workflow to CLAUDE.md
Feature work now goes on feat/<slug> branches; Claude pushes and prints
the Gitea compare URL for the user to open the MR. Exceptions documented
for small fixes and typo tweaks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 21:52:25 +02:00
394da2e6b8 fix: Tolerate diacritic/case/whitespace mismatches in Person column matching
Some checks failed
Deploy to K8s / deploy (push) Successful in 11s
Build and Push / build (push) Successful in 6s
Build and Push / build-go (push) Failing after 6s
- Add canonical_member_key() in match_payments.py to normalize names via
  NFKD + lowercase + whitespace-collapse before ledger lookup; resolves
  payments attributed to e.g. "Maria Maco" to canonical "Mária Maco".
  Emits logger.info when a non-canonical cell is rescued so sheet typos
  are visible in logs without losing the payment allocation.
- Extend group_payments_by_person() in app.py to accept member_names and
  re-key raw-payment groups under the canonical attendance-sheet name so
  the modal's Raw Payments debug section also finds the row correctly.
- Add raw payments collapsible section to member detail modal in adults.html
  and juniors.html for debugging payment attribution issues.
- Remove 4 obsolete tests targeting routes /fees, /fees-juniors, /reconcile,
  /reconcile-juniors that no longer exist; add test_match_payments.py
  covering canonical key equivalence and reconcile() tolerance end-to-end.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 17:22:54 +02:00
81b36878b3 fix: Payment inference returns only exact-name matches when present
Some checks failed
Deploy to K8s / deploy (push) Successful in 11s
Build and Push / build (push) Successful in 7s
Build and Push / build-go (push) Failing after 5s
match_members() now short-circuits on whole-word full-name hits and
uses word-boundary regex everywhere else, so a nickname that is a
substring of another member's surname (e.g. "tov" inside "ottova")
no longer produces false positives. Adds tests/test_match_members.py.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 23:08:59 +02:00
97f568f49f feat: Lower adult monthly fee to 700 CZK from April 2026
Pin Sep 2025 – Feb 2026 at 750 CZK in ADULT_FEE_MONTHLY_RATE so
historical billing is unchanged; new default 700 CZK applies to
2026-04 onward. March 2026 stays at 350.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 23:08:54 +02:00
cf0f176d3f feat: Go rewrite M1 — skeleton, tooling, and hello server
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>
2026-05-04 12:05:46 +02:00
5a41cdae83 fix: Balance now sums past-month (paid - expected) directly, ignoring current/future months
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
Build and Push / build (push) Successful in 6s
The previous calculation derived balance from total_balance (which includes
current/future-month activity and out-of-window credits) plus a one-sided
debt-only adjustment. Current-month surplus leaked through, making the balance
appear less negative than actual past-month debt (e.g. Mauric Daniel -1250 vs
correct -1750). Pay-All is now max(0, -balance) so the two values share a
single source and cannot disagree.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 20:57:13 +02:00
dfdf2aacb8 fix: Distribute multi-month payments by per-month expected fee
All checks were successful
Build and Push / build (push) Successful in 33s
Deploy to K8s / deploy (push) Successful in 12s
reconcile() previously split a multi-month payment evenly across months,
which falsely flagged months as underpaid when their expected fees
differed (e.g. 1250 CZK for 02+03+04 2026 with rates 750/350/150 was
shown as 416/month with two months red).

The allocation now runs per matched member: greedy when the share covers
the total expected (each month gets its expected fee, surplus -> credit),
proportional by expected fee otherwise. Out-of-window months keep the
previous even-split-to-credit behavior. 6 new test cases.

Also adds CHANGELOG.md and a changelog convention in CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-03 19:38:10 +02:00
ced238385e feat: Exclude current month from Pay buttons and balance
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Build and Push / build (push) Successful in 32s
Hide Pay/Pay All buttons for months still in progress, exclude
current month debt from balance column, and show in-progress
month debt in a muted red color.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 13:51:37 +02:00
77743019b0 feat: Hide future months from month range filter and table columns
Compare against current YYYY-MM to exclude future months from the
from/to selectors, default selection, and table column display.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:29:48 +02:00
f712198319 feat: Add month range filter to adults and juniors dashboards
Add from/to combobox selectors and Apply/All buttons to filter
which month columns are displayed. Defaults to last 5 months on load.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 13:26:04 +02:00
1ac5df7be5 chore: Remove archived pages (fees, reconcile) from web UI
Deleted /fees, /fees-juniors, /reconcile, /reconcile-juniors routes and
their templates. Payment Ledger (/payments) is retained. Nav updated
across all remaining templates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 12:48:15 +02:00
109ef983f0 docs: Add operation manual and junior March 2026 fee override
Adds docs/operation-manual.md describing how to add per-month fee
overrides for adults and juniors. Also adds the March 2026 junior
fee override (250 CZK) to JUNIOR_MONTHLY_RATE to match the existing
adult override.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 12:37:06 +02:00
083a51023c feat: Add Flush Cache tool page to web UI
Some checks failed
Deploy to K8s / deploy (push) Failing after 7s
Build and Push / build (push) Successful in 6s
Adds a /flush-cache web page with a button to clear all cached Google
Sheets data and reset refresh timers. Link added to Tools nav across
all templates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 17:43:03 +01:00
54762cd421 feat: Change QR payment message separator from "/" to ":"
Some checks failed
Deploy to K8s / deploy (push) Failing after 6s
Build and Push / build (push) Successful in 6s
Format is now "Name: MM/YYYY+MM/YYYY" instead of "Name / MM/YYYY+MM/YYYY"
for clearer readability when multiple months are included.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 17:35:46 +01:00
b2aaca5df9 feat: Add /sync-bank endpoint to trigger bank sync and inference from web UI
Some checks failed
Deploy to K8s / deploy (push) Failing after 6s
Build and Push / build (push) Successful in 6s
Adds a new GET /sync-bank route that runs sync_to_sheets (2026) + infer_payments + flush_cache,
capturing all output and displaying it on a styled results page. Adds "Tools: [Sync Bank Data]"
nav link to all templates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 17:24:41 +01:00
883bc4489e feat: Add per-month rate override for adult fees
Some checks failed
Deploy to K8s / deploy (push) Failing after 5s
Build and Push / build (push) Successful in 6s
Mirror the junior fee override mechanism (JUNIOR_MONTHLY_RATE) for adults
via ADULT_FEE_MONTHLY_RATE. Set 2026-03 override to 350 CZK. Rename
FEE_FULL/FEE_SINGLE to ADULT_FEE_DEFAULT/ADULT_FEE_SINGLE for consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 16:54:00 +01:00
3ad4a21f5b feat: Pass build metadata args in Gitea CI pipeline
Some checks failed
Deploy to K8s / deploy (push) Failing after 6s
Build and Push / build (push) Successful in 5s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 16:34:34 +01:00
3c1604c7af feat: Bake build metadata (git tag, commit, date) into OCI image and display in web UI
Some checks failed
Deploy to K8s / deploy (push) Failing after 10s
Build and Push / build (push) Successful in 6s
Store git tag, commit hash, and build date as OCI-standard labels and a
build_meta.json file inside the Docker image. The Flask app reads this at
startup and displays version info in all page footers. Adds /version JSON
endpoint for programmatic access. Falls back to dev@local outside Docker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 16:30:20 +01:00
8b3223f865 feat: Add POST /flush-cache endpoint to clear all cached data and reset timers
Some checks failed
Deploy to K8s / deploy (push) Failing after 7s
Build and Push / build (push) Successful in 6s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 10:45:47 +01:00
276e18a9c8 feat: Show attendance breakdown for single-visit junior fees
All checks were successful
Build and Push / build (push) Successful in 8s
When a junior attended only once in a month (fee = "?"), the dashboard
cells now display the attendance details (e.g., "? (1:1J)") instead of
a bare "?". Applied to both /juniors and /reconcile-juniors routes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 10:41:45 +01:00
61f2126c1b feat: Change default redirect to Adults dashboard
All checks were successful
Build and Push / build (push) Successful in 8s
Deploy to K8s / deploy (push) Successful in 12s
Co-authored-by: Antigravity <antigravity@google.com>
2026-03-11 13:13:05 +01:00
200 changed files with 12659 additions and 2881 deletions

16
.claude/settings.json Normal file
View File

@@ -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 *)"
]
}
}

View File

@@ -31,5 +31,35 @@ jobs:
TAG=${{ inputs.tag }}
fi
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG
docker build -f build/Dockerfile -t $IMAGE .
docker build -f 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 .
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

3
.gitignore vendored
View File

@@ -4,3 +4,6 @@
# local tmp folder
tmp/
# go build output
bin/

108
CHANGELOG.md Normal file
View File

@@ -0,0 +1,108 @@
# Changelog
## 2026-05-06 23:25 CEST — feat(go/M3): fixture capture + parity test framework
- `scripts/capture_fixtures.py`: dispatcher CLI that calls each ported function with seeded inputs and emits captured output as JSON fixtures.
- `scripts/scrub_fixtures.py`: deterministic PII scrubber (SHA-256 pseudonyms, digit-preserving account/VS hashes, name-sweep in free text).
- `scripts/_fixture_seeds.py`: handcrafted seed registry for all 10 pure functions + 10 reconcile branch-coverage cases.
- 98 fixture files committed under `go/tests/fixtures/pure/<func>/` and `go/tests/fixtures/reconcile/`; all PII-free.
- `go/tests/parity/parityio.go`: shared loader with generic `LoadDir`/`RunAll` helpers and typed `In`/`Out` structs for all 10 functions.
- 11 parity test packages under `//go:build parity`: 10 pure-function tests + bespoke reconcile test with per-cell float tolerance.
- Makefile: `go-parity`, `go-test-all`, `capture-fixtures` targets.
- `go/tests/fixtures/README.md`: refresh workflow, PII audit guide, adding-a-fixture steps.
## 2026-05-06 17:49 CEST — feat(go/M2.11-12): wire fuj fees + fuj reconcile subcommands
- New `go/internal/services/membership` package: `AttendanceLoader`, `TransactionLoader`, `ExceptionLoader` interfaces, a stub (`NewStubSources`) that returns `ErrIOPending`, and `FeesReport` / `ReconcileReport` orchestration functions backed by real `domain/fees` + `domain/reconcile` logic.
- Text formatters `printFeesTable` / `printReconcileReport` port the output of `calculate_fees.py` and `match_payments.py print_report` verbatim.
- `cmd/fuj/main.go`: `fuj fees` and `fuj reconcile` subcommands now dispatch properly; `fuj sync` / `fuj infer` retain the [M4] placeholder.
- Both subcommands exit 1 with a clean `"io layer not yet wired up; lands in milestone M4"` message until real Sheets loaders are injected in M4.
- 13 unit tests covering stubs, all formatter branches (OK/partial/UNPAID/dash cells, credits, debts, unmatched, review annotation), and orchestration wiring via fake loaders.
## 2026-05-06 16:38 CEST — fix: include juniors in payment-inference roster
- `scripts/infer_payments.py`: union adults + junior rosters so junior-only members are visible to the matcher.
- Root cause: `get_members_with_fees()` reads only the adults sheet; junior-only kids like Jáchym Kubík were absent from `member_names`, causing the exact-match short-circuit to never fire and a different adult sharing the first name to win via fuzzy review.
- Two regression tests added to `tests/test_match_members.py`.
## 2026-05-06 16:05 CEST — feat(go/M2.10): port domain/reconcile.Reconcile
- New `go/internal/domain/reconcile` package porting the three-phase payment allocation from `scripts/match_payments.py reconcile()`.
- 12 unit tests covering all Python test cases plus Go-only extras (diacritics tolerance, `[?]` stripping, `other:` purpose, out-of-window credit, inference fallback, unmatched, no-transaction guard).
## 2026-05-06 13:18 CEST — feat(go/M2.7-2.9): port domain/matching package
- New `go/internal/domain/matching` package porting three helpers from `scripts/match_payments.py`.
- `BuildNameVariants` — extracts normalized ASCII search variants from a member name, including nickname (from parens) and separate first/last; filters variants shorter than 3 chars; `variants[0]` is always the full normalized base name.
- `MatchMembers` — finds members in free text with `"auto"` or `"review"` confidence; exact-name short-circuit prevents nickname substrings (e.g. `tov`) from matching inside surnames (e.g. `ottova`).
- `FormatDate` — normalizes Google Sheets date values: handles nil, empty, int/float64 serial-days since 1899-12-30 (supports fractional serials), pre-formatted `YYYY-MM-DD` strings, and garbage input — never errors.
- `InferTransactionDetails` — composes name + month matching over sender/message/user_id; falls back to sender-only member match and date-derived month when text gives no signal.
- 21 table-driven tests; all expected values verified against live Python on 2026-05-06.
## 2026-05-06 12:43 CEST — feat(go/M2.6): port domain/synch.GenerateSyncID
- New `go/internal/domain/synch` package with `GenerateSyncID(Transaction) string` ported from `scripts/sync_fio_to_sheets.py` `generate_sync_id`.
- Byte-stable SHA-256 hash over `date|amount|currency|sender|vs|message|bank_id` (lowercased, UTF-8); `Currency: ""` defaults to `"CZK"` matching the Python missing-key fallback.
- Key subtlety: Python's `str(float)` emits `"500.0"` for whole-valued floats and switches to scientific notation at `|f| >= 1e16` or `|f| < 1e-4` — replicated in `formatAmount` using `'f'`/`'e'` format selection.
- 6 table-driven hash tests + 9 `formatAmount` tests; all expected values verified against live Python on 2026-05-06.
## 2026-05-06 09:38 CEST — feat(go/M2.5): port domain/money.ParseCZK
- New `go/internal/domain/money` package with `ParseCZK(string) (float64, error)` ported from `scripts/infer_payments.py` `parse_czk_amount`.
- Preserves the Czech-locale heuristic: comma → decimal sep; 2+ dots → thousand seps; single dot → decimal (so `"1.500"``1.5`).
- Returns `(0, ErrInvalidAmount)` on parse failure; callers wanting Python's silent-zero contract use `v, _ := ParseCZK(s)`.
- 15 table-driven tests plus a silent-zero contract test; all expected values verified against live Python on 2026-05-06.
## 2026-05-06 09:24 CEST — feat(go/M2.3+M2.4): port domain/fees.CalculateFee and CalculateJuniorFee
- New `go/internal/domain/fees` package with adult and junior fee calculators ported from `scripts/attendance.py`.
- `CalculateFee(count, monthKey) int``0→0`, `1→200`, `2+→AdultFeeMonthlyRate[month]` (fallback 700 CZK).
- `CalculateJuniorFee(count, monthKey) Expected``0→{0}`, `1→{Unknown:true}` (the `"?"` sentinel, now strictly typed), `2+→JuniorFeeMonthlyRate[month]` (fallback 500 CZK).
- 20 table-driven tests, all verified against live Python; `-race` clean; `golangci-lint` clean.
## 2026-05-06 00:07 CEST — feat(go/M2.2): port czech.ParseMonthReferences
- `internal/domain/czech.ParseMonthReferences`: three-pass regex (numeric slash, dot, Czech month names) with range wrap-around and `m≥10 → previousYear` heuristic, byte-equivalent to Python.
- 35 table-driven tests; all expected outputs verified against live Python before locking (addresses risk #4 from the rewrite plan).
## 2026-05-05 23:33 CEST — feat(go/M2.1): port czech.Normalize
- First M2 pure-domain task: `internal/domain/czech.Normalize` (NFKD + Mn-strip + lowercase), byte-equivalent to Python `czech_utils.normalize`.
- Adds `golang.org/x/text v0.36.0` as first external Go dependency.
- 13-case table-driven test, all spot-checked against Python before locking.
## 2026-05-04 23:08 CEST — fix: payment inference exact-match short-circuit
- `match_members()` now short-circuits on whole-word full-name hits; nickname/partial checks only run when no full name is present.
- Replaced bare `in` substring checks with `_word_in()` word-boundary regex throughout, closing the class of bugs where a short nickname (e.g. `tov`) matches inside another member's surname (`ottova`).
- Added `tests/test_match_members.py` (6 cases). Affects `scripts/match_payments.py`.
## 2026-05-04 23:08 CEST — feat: lower adult monthly fee to 700 CZK from April 2026
- `ADULT_FEE_DEFAULT` reduced from 750 → 700 CZK.
- `ADULT_FEE_MONTHLY_RATE` now pins Sep 2025 Feb 2026 at 750 to preserve historical billing; Mar 2026 stays 350; AprMay 2026 at 700. Affects `scripts/attendance.py`.
## 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 `<tag>-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()`.
- Previously the balance used `total_balance` (which includes current/future-month activity and out-of-window credits) plus a one-sided current-month debt adjustment. Current-month *surplus* leaked through, making the balance appear less negative than the actual past-month debt.
- Pay-All is now `max(0, balance)` so the two values are derived from a single source and can never disagree.
- Affected: `adults_view()` and `juniors_view()` in `app.py`.
## 2026-05-03 19:26 CEST — Fee-aware allocation for multi-month payments
- `reconcile()` no longer splits a multi-month payment evenly. Allocation is now per-member with two phases: greedy (if amount ≥ total expected, each month gets exactly its expected fee and overflow → credit) and proportional (otherwise distribute by each month's expected). Fixes the case where e.g. 1250 CZK covering 3 months with mixed fees (750/350/150) marked two months red.
- Out-of-window months keep the previous even-split-to-credit behavior. Fallback to even split when all matched months have `expected = 0` (prepayment before attendance is recorded).
- Display layer only — no changes to how payments are stored in Google Sheets; `Inferred Amount` still holds the full bank amount.
- Files: [scripts/match_payments.py](scripts/match_payments.py), [tests/test_reconcile_exceptions.py](tests/test_reconcile_exceptions.py) (6 new test cases).

144
CLAUDE.md
View File

@@ -16,22 +16,144 @@ Flask-based financial management system for FUJ (Frisbee Ultimate Jablonec). Han
This project uses `uv` for dependency management.
```bash
uv venv # Create virtual environment
uv sync # Install dependencies from pyproject.toml
uv venv && uv sync
source .venv/bin/activate
```
Alternatively, use the Makefile:
- `make sync` - Sync bank transactions to Google Sheets
- `make infer` - Automatically infer Person/Purpose/Amount in the sheet
- `make reconcile` - Generate balance report from Google Sheets data
- `make fees` - Calculate expected fees from attendance
- `make match` - (Legacy) Match bank data directly
- `make web` - Start dashboard
- `make image` - Build Docker image
Set `PYTHONPATH=scripts:.` when running scripts directly (the Makefile does this automatically).
Requires `.secret/fuj-management-bot-credentials.json` for Google Sheets API access (configurable via `CREDENTIALS_PATH` env var).
## Commands
```bash
make web # Start dashboard at http://localhost:5001
make web-debug # Same with FLASK_DEBUG=1
make test # Run all tests (unittest discover)
make test-v # Tests with verbose output
make fees # Print fee report from attendance sheet
make sync-2026 # Sync Fio bank transactions for 2026 to Google Sheets
make infer # Auto-fill Person/Purpose/Amount columns in payments sheet
make reconcile # Print balance report from Google Sheets data
make image # Build Docker image
```
Run a single test:
```bash
PYTHONPATH=scripts:. python -m unittest tests.test_app.TestWebApp.test_adults_route
```
## Architecture
### Data flow
```
Google Sheets (attendance) ──► attendance.py ──► reconcile() ──► Flask routes ──► templates/
Google Sheets (payments) ──► match_payments.py ──┘
Fio Bank API ──► sync_fio_to_sheets.py ──► Google Sheets (payments)
```
### Key modules
- `app.py` — Flask app; routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/qr`, `/flush-cache`
- `scripts/attendance.py` — Fetches attendance CSV from Google Sheets, computes per-member per-month fees. Contains fee rate constants (`ADULT_FEE_DEFAULT`, `JUNIOR_FEE_DEFAULT`) and `ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` dicts.
- `scripts/match_payments.py``reconcile()` matches transactions to members/months. `fetch_sheet_data()` reads the payments sheet. `fetch_exceptions()` reads the `exceptions` tab.
- `scripts/cache_utils.py` — Invalidation via Google Drive API `modifiedTime`; falls back to 5-minute TTL buckets when Drive API is unavailable. Cache files live in `tmp/`.
- `scripts/sync_fio_to_sheets.py` — Pulls Fio bank transactions and appends them to the payments Google Sheet.
- `scripts/infer_payments.py` — Fills in Person/Purpose/Inferred Amount columns using name-matching heuristics.
- `scripts/config.py` — All external IDs, paths, and tunable TTLs. Override via env vars (`CREDENTIALS_PATH`, `BANK_ACCOUNT`, `CACHE_TTL_SECONDS`).
### Member tiers
Tiers are set in column B of the attendance sheet:
- `A` — Adult, pays fees (per-month rate from `ADULT_FEE_MONTHLY_RATE`, fallback 700 CZK for 2+ sessions; 200 CZK for exactly 1)
- `J` — Junior attending adult practices; their attendance is merged with the junior sheet
- `X` — Excluded from junior fee calculation (coaches, etc.)
### Fee calculation
- Adults: 0 sessions → 0, 1 session → 200 CZK, 2+ sessions → monthly rate (default 700 CZK)
- Juniors: 0 → 0, 1 → `"?"` (manual review required), 2+ → monthly rate (default 500 CZK)
- Per-member per-month overrides live in the `exceptions` tab of the payments sheet (columns: Name, Period YYYY-MM, Amount, Note). Exceptions are keyed by `(normalize(name), normalize(period))`.
### Merged months
`ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` in `attendance.py` map a source month to a target month (e.g., `"2025-12": "2026-01"` merges December into January billing). The target month accumulates attendance from both months.
### Caching
`get_cached_data()` in `app.py` checks the Drive API `modifiedTime` before each request and serves a JSON file from `tmp/` when the sheet hasn't changed. Cache is warmed up at startup (`warmup_cache()`). Flush via `/flush-cache` (POST) or `flush_cache()`.
### Payments sheet columns
`Date | Amount | manual fix | Person | Purpose | Inferred Amount | Sender | VS | Message | Bank ID | Sync ID`
`Person` and `Purpose` are written by `infer_payments.py` and can be manually corrected. `manual fix` column presence disables re-inference for that row. Multiple people or months are comma-separated in Person/Purpose.
### QR codes
`/qr?account=…&amount=…&message=…` generates a Czech QR Platba PNG (SPD format).
## Branching & merge requests
The remote is Gitea (`gitea.home.hrajfrisbee.cz/kacerr/fuj-management`).
For **features**, do not commit to `main` directly. Use a branch + merge
request flow:
1. **Create a branch off `main`** before starting work:
- `feat/<slug>` for features (e.g. `feat/qr-code-overlay`)
- `fix/<slug>` for bug-fix branches the user explicitly asks for
- `<slug>` is short kebab-case
2. **Commit on the branch** following the existing commit conventions
(Co-Authored-By trailer, etc.).
3. **Push the branch** to `origin` with `-u` so it tracks.
4. **Open the MR with `tea`** rather than printing a compare URL:
```bash
tea pr create \
--title "<short title>" \
--description "<body>" \
--base main \
--head <branch>
```
`tea` is already authenticated against the Gitea instance; just run it.
Print the resulting PR URL for the user. If `tea` is unavailable for
some reason, fall back to printing the compare URL
(`https://gitea.home.hrajfrisbee.cz/kacerr/fuj-management/compare/main...<branch>`)
and let the user open the MR manually.
5. **Do not merge or delete the branch** from the CLI — neither via `tea`,
`gh`, nor `git push --delete`. The user does that in Gitea.
**Exceptions — when committing straight to `main` is fine:**
- Small bug fixes / hotfixes the user describes as such.
- Typo / comment / formatting tweaks.
- Edits the user explicitly says to push to `main`.
When uncertain whether something is a feature or a small fix, ask before
committing.
## Git Commits
When making git commits, always append yourself as co-author trailer to the end of the commit message to indicate AI assistance
## Changelog
Maintain a running changelog in `CHANGELOG.md` at the repo root. After every significant change, fix, or update — once the user confirms it works — append a new entry **at the top** of the file in this format:
```markdown
## YYYY-MM-DD HH:MM TZ — short title
- One-line summary of what changed and why.
- Key files touched (optional, only if useful for traceability).
```
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-<slug>.md` instead of the default `~/.claude/plans/`
location. Get the timestamp with `date "+%Y-%m-%d-%H%M"` (matches the changelog
convention). The `<slug>` 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.

View File

@@ -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-test-all go-parity go-run go-lint capture-fixtures 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,28 @@ $(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 unit tests"
@echo " make go-parity - Run Go parity tests (requires -tags=parity fixture corpus)"
@echo " make go-test-all - Run both unit and parity tests"
@echo " make go-lint - Run golangci-lint on Go code"
@echo " make capture-fixtures - Regenerate parity fixture corpus from live Python"
@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,14 +49,60 @@ 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-parity:
cd $(GO_SRC) && go test -tags=parity ./tests/parity/...
go-test-all: go-test go-parity
capture-fixtures: $(PYTHON)
@echo "Capturing and scrubbing fixtures for all registered functions..."
@for func in normalize parse_month_references calculate_fee calculate_junior_fee \
parse_czk_amount generate_sync_id build_name_variants match_members \
infer_transaction_details format_date reconcile; do \
dir="go/tests/fixtures/$$([[ $$func == reconcile ]] && echo reconcile || echo pure/$$func)"; \
mkdir -p "$$dir"; \
PYTHONPATH=scripts:. $(PYTHON) scripts/capture_fixtures.py --func $$func --all \
| while IFS= read -r line; do \
case_id=$$(echo "$$line" | $(PYTHON) -c "import sys,json; print(json.load(sys.stdin)['case'])"); \
echo "$$line" | $(PYTHON) scripts/scrub_fixtures.py > "$$dir/$${case_id}.json"; \
done; \
echo " $$func done"; \
done
@echo "capture-fixtures complete."
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 -f build/Dockerfile .
docker build -t fuj-management:latest \
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \
--build-arg GIT_COMMIT=$$(git rev-parse --short HEAD) \
--build-arg BUILD_DATE=$$(date -u +%Y-%m-%dT%H:%M:%SZ) \
-f build/Dockerfile .
run:
docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest

564
app.py
View File

@@ -22,8 +22,10 @@ from config import (
BANK_ACCOUNT, CREDENTIALS_PATH,
)
from attendance import get_members_with_fees, get_junior_members_with_fees, ADULT_MERGED_MONTHS, JUNIOR_MERGED_MONTHS
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize
from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize, canonical_member_key
from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED, flush_cache
from sync_fio_to_sheets import sync_to_sheets
from infer_payments import infer_payments
def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
mod_time = get_sheet_modified_time(cache_key)
@@ -55,6 +57,25 @@ def get_month_labels(sorted_months, merged_months):
labels[m] = dt.strftime("%b %Y")
return labels
def group_payments_by_person(transactions, member_names=None):
canonical_by_key = (
{canonical_member_key(n): n for n in member_names} if member_names else {}
)
grouped = {}
for tx in transactions:
person = str(tx.get("person", "")).strip()
if not person:
continue
for p in person.split(","):
p = re.sub(r"\[\?\]\s*", "", p).strip()
if not p:
continue
key = canonical_by_key.get(canonical_member_key(p), p)
grouped.setdefault(key, []).append(tx)
for rows in grouped.values():
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
return grouped
def warmup_cache():
"""Pre-fetch all cached data so first request is fast."""
logger = logging.getLogger(__name__)
@@ -72,6 +93,13 @@ def warmup_cache():
logger.info("Cache warmup complete.")
app = Flask(__name__)
import json as _json
_meta_path = Path(__file__).parent / "build_meta.json"
BUILD_META = _json.loads(_meta_path.read_text()) if _meta_path.exists() else {
"tag": "dev", "commit": "local", "build_date": ""
}
warmup_cache()
@app.before_request
@@ -101,157 +129,52 @@ def inject_render_time():
"total": f"{total:.3f}",
"breakdown": " | ".join(breakdown)
}
return dict(get_render_time=get_render_time)
return dict(get_render_time=get_render_time, build_meta=BUILD_META)
@app.route("/")
def index():
# Redirect root to /fees for convenience while there are no other apps
return '<meta http-equiv="refresh" content="0; url=/fees" />'
# Redirect root to /adults for convenience while there are no other apps
return '<meta http-equiv="refresh" content="0; url=/adults" />'
@app.route("/fees")
def fees():
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"
@app.route("/flush-cache", methods=["GET", "POST"])
def flush_cache_endpoint():
if request.method == "GET":
return render_template("flush-cache.html")
deleted = flush_cache()
return render_template("flush-cache.html", flushed=True, deleted=deleted)
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
record_step("fetch_members")
if not members_data:
return "No data."
members, sorted_months = members_data
@app.route("/sync-bank")
def sync_bank():
import contextlib
output = io.StringIO()
success = True
try:
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
# sync_to_sheets: equivalent of make sync-2026
output.write("=== Syncing Fio transactions (2026) ===\n")
sync_to_sheets(
spreadsheet_id=PAYMENTS_SHEET_ID,
credentials_path=CREDENTIALS_PATH,
date_from_str="2026-01-01",
date_to_str="2026-12-31",
sort_by_date=True,
)
output.write("\n=== Inferring payment details ===\n")
infer_payments(PAYMENTS_SHEET_ID, CREDENTIALS_PATH)
output.write("\n=== Flushing cache ===\n")
deleted = flush_cache()
output.write(f"Deleted {deleted} cache files.\n")
output.write("\n=== Done ===\n")
except Exception as e:
import traceback
output.write(f"\n!!! Error: {e}\n")
output.write(traceback.format_exc())
success = False
return render_template("sync.html", output=output.getvalue(), success=success)
# Filter to adults only for display
results = [(name, fees) for name, tier, fees in members if tier == "A"]
# Format month labels
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
monthly_totals = {m: 0 for m in sorted_months}
# Get exceptions for formatting
credentials_path = 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},
)
record_step("fetch_exceptions")
formatted_results = []
for name, month_fees in results:
row = {"name": name, "months": []}
norm_name = normalize(name)
for m in sorted_months:
fee, count = month_fees.get(m, (0, 0))
# Check for exception
norm_period = normalize(m)
ex_data = exceptions.get((norm_name, norm_period))
override_amount = ex_data["amount"] if ex_data else None
if override_amount is not None and override_amount != fee:
cell = f"{override_amount} ({fee}) CZK ({count})" if count > 0 else f"{override_amount} ({fee}) CZK"
is_overridden = True
else:
if isinstance(fee, int):
monthly_totals[m] += fee
cell = f"{fee} CZK ({count})" if count > 0 else "-"
is_overridden = False
row["months"].append({"cell": cell, "overridden": is_overridden})
formatted_results.append(row)
record_step("process_data")
return render_template(
"fees.html",
months=[month_labels[m] for m in sorted_months],
results=formatted_results,
totals=[f"{monthly_totals[m]} CZK" for m in sorted_months],
attendance_url=attendance_url,
payments_url=payments_url
)
@app.route("/fees-juniors")
def fees_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"
members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
record_step("fetch_junior_members")
if not members_data:
return "No data."
members, sorted_months = members_data
# Sort members by name
results = sorted([(name, fees) for name, tier, fees in members], key=lambda x: x[0])
# Format month labels
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
monthly_totals = {m: 0 for m in sorted_months}
# Get exceptions for formatting (reusing payments sheet)
credentials_path = 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},
)
record_step("fetch_exceptions")
formatted_results = []
for name, month_fees in results:
row = {"name": name, "months": []}
norm_name = normalize(name)
for m in sorted_months:
fee_data = month_fees.get(m, (0, 0, 0, 0))
if len(fee_data) == 4:
fee, total_count, adult_count, junior_count = fee_data
else:
fee, total_count = fee_data
adult_count, junior_count = 0, 0
# Check for exception
norm_period = normalize(m)
ex_data = exceptions.get((norm_name, norm_period))
override_amount = ex_data["amount"] if ex_data else None
if ex_data is None and isinstance(fee, int):
monthly_totals[m] += fee
# Formulate the count string display
if adult_count > 0 and junior_count > 0:
count_str = f"{total_count} ({adult_count}A+{junior_count}J)"
elif adult_count > 0:
count_str = f"{total_count} (A)"
elif junior_count > 0:
count_str = f"{total_count} (J)"
else:
count_str = f"{total_count}"
if override_amount is not None and override_amount != fee:
cell = f"{override_amount} ({fee}) CZK / {count_str}" if total_count > 0 else f"{override_amount} ({fee}) CZK"
is_overridden = True
else:
if fee == "?":
cell = f"? / {count_str}" if total_count > 0 else "-"
else:
cell = f"{fee} CZK / {count_str}" if total_count > 0 else "-"
is_overridden = False
row["months"].append({"cell": cell, "overridden": is_overridden})
formatted_results.append(row)
record_step("process_data")
return render_template(
"fees-juniors.html",
months=[month_labels[m] for m in sorted_months],
results=formatted_results,
totals=[f"{t} CZK" if isinstance(t, int) else t for t in monthly_totals.values()],
attendance_url=attendance_url,
payments_url=payments_url
)
@app.route("/version")
def version():
return BUILD_META
@app.route("/adults")
def adults_view():
@@ -279,13 +202,15 @@ def adults_view():
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
current_month = datetime.now().strftime("%Y-%m")
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
formatted_results = []
for name in adult_names:
data = result["members"][name]
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""}
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
unpaid_months = []
raw_unpaid_months = []
for m in sorted_months:
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
expected = mdata.get("expected", 0)
@@ -318,11 +243,15 @@ def adults_view():
elif paid > 0:
status = "partial"
cell_text = f"{paid}/{fee_display}"
unpaid_months.append(month_labels[m])
if m < current_month:
unpaid_months.append(month_labels[m])
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
else:
status = "unpaid"
cell_text = f"0/{fee_display}"
unpaid_months.append(month_labels[m])
if m < current_month:
unpaid_months.append(month_labels[m])
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
elif paid > 0:
status = "surplus"
cell_text = f"PAID {paid}"
@@ -341,11 +270,24 @@ def adults_view():
"status": status,
"amount": amount_to_pay,
"month": month_labels[m],
"raw_month": m,
"tooltip": tooltip
})
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
row["balance"] = data["total_balance"]
# Balance = sum of (paid - expected) for past months only; current/future months ignored.
settled_balance = 0
for m, mdata in data["months"].items():
if m >= current_month:
continue
exp = mdata.get("expected", 0)
if isinstance(exp, int):
settled_balance += int(mdata.get("paid", 0)) - exp
payable_amount = max(0, -settled_balance)
row["unpaid_periods"] = ", ".join(unpaid_months)
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
row["balance"] = settled_balance
row["payable_amount"] = payable_amount
formatted_results.append(row)
formatted_totals = []
@@ -365,11 +307,23 @@ def adults_view():
"status": status
})
credits = sorted([{"name": n, "amount": a["total_balance"]} for n, a in result["members"].items() if a["total_balance"] > 0 and n in adult_names], key=lambda x: x["name"])
debts = sorted([{"name": n, "amount": abs(a["total_balance"])} for n, a in result["members"].items() if a["total_balance"] < 0 and n in adult_names], key=lambda x: x["name"])
def settled_balance(name):
data = result["members"][name]
total = 0
for m, mdata in data["months"].items():
if m >= current_month:
continue
exp = mdata.get("expected", 0)
if isinstance(exp, int):
total += int(mdata.get("paid", 0)) - exp
return total
credits = sorted([{"name": n, "amount": settled_balance(n)} for n in adult_names if settled_balance(n) > 0], key=lambda x: x["name"])
debts = sorted([{"name": n, "amount": abs(settled_balance(n))} for n in adult_names if settled_balance(n) < 0], key=lambda x: x["name"])
unmatched = result["unmatched"]
import json
raw_payments_by_person = group_payments_by_person(transactions, [name for name, _, _ in members])
record_step("process_data")
return render_template(
@@ -380,111 +334,14 @@ def adults_view():
totals=formatted_totals,
member_data=json.dumps(result["members"]),
month_labels_json=json.dumps(month_labels),
raw_payments_json=json.dumps(raw_payments_by_person),
credits=credits,
debts=debts,
unmatched=unmatched,
attendance_url=attendance_url,
payments_url=payments_url,
bank_account=BANK_ACCOUNT
)
@app.route("/reconcile")
def reconcile_view():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit"
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
# Use hardcoded credentials path for now, consistent with other scripts
credentials_path = CREDENTIALS_PATH
members_data = get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
record_step("fetch_members")
if not members_data:
return "No data."
members, sorted_months = members_data
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
record_step("fetch_payments")
exceptions = get_cached_data(
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
PAYMENTS_SHEET_ID, credentials_path,
serialize=lambda d: [[list(k), v] for k, v in d.items()],
deserialize=lambda c: {tuple(k): v for k, v in c},
)
record_step("fetch_exceptions")
result = reconcile(members, sorted_months, transactions, exceptions)
record_step("reconcile")
# Format month labels
month_labels = get_month_labels(sorted_months, ADULT_MERGED_MONTHS)
# Filter to adults for the main table
adult_names = sorted([name for name, tier, _ in members if tier == "A"])
formatted_results = []
for name in adult_names:
data = result["members"][name]
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""}
unpaid_months = []
for m in sorted_months:
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0})
expected = mdata["expected"]
paid = int(mdata["paid"])
status = "empty"
cell_text = "-"
amount_to_pay = 0
if expected > 0:
if paid >= expected:
status = "ok"
cell_text = "OK"
elif paid > 0:
status = "partial"
cell_text = f"{paid}/{expected}"
amount_to_pay = expected - paid
unpaid_months.append(month_labels[m])
else:
status = "unpaid"
cell_text = f"UNPAID {expected}"
amount_to_pay = expected
unpaid_months.append(month_labels[m])
elif paid > 0:
status = "surplus"
cell_text = f"PAID {paid}"
row["months"].append({
"text": cell_text,
"status": status,
"amount": amount_to_pay,
"month": month_labels[m]
})
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
row["balance"] = data["total_balance"] # Updated to use total_balance
formatted_results.append(row)
# Format credits and debts
credits = sorted([{"name": n, "amount": a["total_balance"]} for n, a in result["members"].items() if a["total_balance"] > 0 and n in adult_names], key=lambda x: x["name"])
debts = sorted([{"name": n, "amount": abs(a["total_balance"])} for n, a in result["members"].items() if a["total_balance"] < 0 and n in adult_names], key=lambda x: x["name"])
# Format unmatched
unmatched = result["unmatched"]
import json
record_step("process_data")
return render_template(
"reconcile.html",
months=[month_labels[m] for m in sorted_months],
raw_months=sorted_months,
results=formatted_results,
member_data=json.dumps(result["members"]),
month_labels_json=json.dumps(month_labels),
credits=credits,
debts=debts,
unmatched=unmatched,
attendance_url=attendance_url,
payments_url=payments_url,
bank_account=BANK_ACCOUNT
bank_account=BANK_ACCOUNT,
current_month=current_month
)
@app.route("/juniors")
@@ -531,13 +388,15 @@ def juniors_view():
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
junior_names = sorted([name for name, tier, _ in adapted_members])
junior_members_dict = {name: fees_dict for name, _, fees_dict in junior_members}
current_month = datetime.now().strftime("%Y-%m")
monthly_totals = {m: {"expected": 0, "paid": 0} for m in sorted_months}
formatted_results = []
for name in junior_names:
data = result["members"][name]
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""}
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": "", "raw_unpaid_periods": ""}
unpaid_months = []
raw_unpaid_months = []
for m in sorted_months:
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
expected = mdata.get("expected", 0)
@@ -582,7 +441,7 @@ def juniors_view():
if expected == "?" or (isinstance(expected, int) and expected > 0):
if expected == "?":
status = "empty"
cell_text = "?"
cell_text = f"?{count_str}"
elif paid >= expected:
status = "ok"
cell_text = f"{paid}/{fee_display}"
@@ -590,12 +449,16 @@ def juniors_view():
status = "partial"
cell_text = f"{paid}/{fee_display}"
amount_to_pay = expected - paid
unpaid_months.append(month_labels[m])
if m < current_month:
unpaid_months.append(month_labels[m])
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
else:
status = "unpaid"
cell_text = f"0/{fee_display}"
amount_to_pay = expected
unpaid_months.append(month_labels[m])
if m < current_month:
unpaid_months.append(month_labels[m])
raw_unpaid_months.append(datetime.strptime(m, "%Y-%m").strftime("%m/%Y"))
elif paid > 0:
status = "surplus"
cell_text = f"PAID {paid}"
@@ -611,11 +474,24 @@ def juniors_view():
"status": status,
"amount": amount_to_pay,
"month": month_labels[m],
"raw_month": m,
"tooltip": tooltip
})
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
row["balance"] = data["total_balance"]
# Balance = sum of (paid - expected) for past months only; current/future months ignored.
settled_balance = 0
for m, mdata in data["months"].items():
if m >= current_month:
continue
exp = mdata.get("expected", 0)
if isinstance(exp, int):
settled_balance += int(mdata.get("paid", 0)) - exp
payable_amount = max(0, -settled_balance)
row["unpaid_periods"] = ", ".join(unpaid_months)
row["raw_unpaid_periods"] = "+".join(raw_unpaid_months)
row["balance"] = settled_balance
row["payable_amount"] = payable_amount
formatted_results.append(row)
formatted_totals = []
@@ -636,9 +512,22 @@ def juniors_view():
})
# Format credits and debts
credits = sorted([{"name": n, "amount": a["total_balance"]} for n, a in result["members"].items() if a["total_balance"] > 0], key=lambda x: x["name"])
debts = sorted([{"name": n, "amount": abs(a["total_balance"])} for n, a in result["members"].items() if a["total_balance"] < 0], key=lambda x: x["name"])
def junior_settled_balance(name):
data = result["members"][name]
total = 0
for m, mdata in data["months"].items():
if m >= current_month:
continue
exp = mdata.get("expected", 0)
if isinstance(exp, int):
total += int(mdata.get("paid", 0)) - exp
return total
junior_all_names = [name for name, _, _ in adapted_members]
credits = sorted([{"name": n, "amount": junior_settled_balance(n)} for n in junior_all_names if junior_settled_balance(n) > 0], key=lambda x: x["name"])
debts = sorted([{"name": n, "amount": abs(junior_settled_balance(n))} for n in junior_all_names if junior_settled_balance(n) < 0], key=lambda x: x["name"])
unmatched = result["unmatched"]
raw_payments_by_person = group_payments_by_person(transactions, [name for name, _, _ in adapted_members])
import json
record_step("process_data")
@@ -651,126 +540,14 @@ def juniors_view():
totals=formatted_totals,
member_data=json.dumps(result["members"]),
month_labels_json=json.dumps(month_labels),
raw_payments_json=json.dumps(raw_payments_by_person),
credits=credits,
debts=debts,
unmatched=unmatched,
attendance_url=attendance_url,
payments_url=payments_url,
bank_account=BANK_ACCOUNT
)
@app.route("/reconcile-juniors")
def reconcile_juniors_view():
attendance_url = f"https://docs.google.com/spreadsheets/d/{ATTENDANCE_SHEET_ID}/edit#gid={JUNIOR_SHEET_GID}"
payments_url = f"https://docs.google.com/spreadsheets/d/{PAYMENTS_SHEET_ID}/edit"
credentials_path = CREDENTIALS_PATH
junior_members_data = get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
record_step("fetch_junior_members")
if not junior_members_data:
return "No data."
junior_members, sorted_months = junior_members_data
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
record_step("fetch_payments")
exceptions = get_cached_data(
"exceptions_dict", PAYMENTS_SHEET_ID, fetch_exceptions,
PAYMENTS_SHEET_ID, credentials_path,
serialize=lambda d: [[list(k), v] for k, v in d.items()],
deserialize=lambda c: {tuple(k): v for k, v in c},
)
record_step("fetch_exceptions")
# Adapt junior tuple format (name, tier, {month: (fee, total_count, adult_count, junior_count)})
# to what match_payments expects: (name, tier, {month: (expected_fee, attendance_count)})
adapted_members = []
for name, tier, fees_dict in junior_members:
adapted_fees = {}
for m, fee_data in fees_dict.items():
if len(fee_data) == 4:
fee, total_count, _, _ = fee_data
adapted_fees[m] = (fee, total_count)
else:
fee, count = fee_data
adapted_fees[m] = (fee, count)
adapted_members.append((name, tier, adapted_fees))
result = reconcile(adapted_members, sorted_months, transactions, exceptions)
record_step("reconcile")
# Format month labels
month_labels = get_month_labels(sorted_months, JUNIOR_MERGED_MONTHS)
# Filter to juniors for the main table
junior_names = sorted([name for name, tier, _ in adapted_members])
formatted_results = []
for name in junior_names:
data = result["members"][name]
row = {"name": name, "months": [], "balance": data["total_balance"], "unpaid_periods": ""}
unpaid_months = []
for m in sorted_months:
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "paid": 0})
expected = mdata["expected"]
paid = int(mdata["paid"])
status = "empty"
cell_text = "-"
amount_to_pay = 0
if expected == "?" or (isinstance(expected, int) and expected > 0):
if expected == "?":
status = "empty"
cell_text = "?"
elif paid >= expected:
status = "ok"
cell_text = "OK"
elif paid > 0:
status = "partial"
cell_text = f"{paid}/{expected}"
amount_to_pay = expected - paid
unpaid_months.append(month_labels[m])
else:
status = "unpaid"
cell_text = f"UNPAID {expected}"
amount_to_pay = expected
unpaid_months.append(month_labels[m])
elif paid > 0:
status = "surplus"
cell_text = f"PAID {paid}"
row["months"].append({
"text": cell_text,
"status": status,
"amount": amount_to_pay,
"month": month_labels[m]
})
row["unpaid_periods"] = ", ".join(unpaid_months) if unpaid_months else ("Older debt" if data["total_balance"] < 0 else "")
row["balance"] = data["total_balance"]
formatted_results.append(row)
# Format credits and debts
credits = sorted([{"name": n, "amount": a["total_balance"]} for n, a in result["members"].items() if a["total_balance"] > 0], key=lambda x: x["name"])
debts = sorted([{"name": n, "amount": abs(a["total_balance"])} for n, a in result["members"].items() if a["total_balance"] < 0], key=lambda x: x["name"])
import json
record_step("process_data")
return render_template(
"reconcile-juniors.html",
months=[month_labels[m] for m in sorted_months],
raw_months=sorted_months,
results=formatted_results,
member_data=json.dumps(result["members"]),
month_labels_json=json.dumps(month_labels),
credits=credits,
debts=debts,
unmatched=[],
attendance_url=attendance_url,
payments_url=payments_url,
bank_account=BANK_ACCOUNT
bank_account=BANK_ACCOUNT,
current_month=current_month
)
@app.route("/payments")
@@ -782,27 +559,22 @@ def payments():
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
record_step("fetch_payments")
# Group transactions by person
grouped = {}
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])
grouped = group_payments_by_person(transactions, member_names)
# payments page also groups unmatched rows under a fallback key
for tx in transactions:
person = str(tx.get("person", "")).strip()
if not person:
person = "Unmatched / Unknown"
# Handle multiple people (comma separated)
people = [p.strip() for p in person.split(",") if p.strip()]
for p in people:
# Strip markers
clean_p = re.sub(r"\[\?\]\s*", "", p)
if clean_p not in grouped:
grouped[clean_p] = []
grouped[clean_p].append(tx)
# Sort people and their transactions
if not str(tx.get("person", "")).strip():
grouped.setdefault("Unmatched / Unknown", []).append(tx)
for rows in grouped.values():
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
sorted_people = sorted(grouped.keys())
for p in sorted_people:
# Sort by date descending
grouped[p].sort(key=lambda x: str(x.get("date", "")), reverse=True)
record_step("process_data")
return render_template(

View File

@@ -24,6 +24,17 @@ COPY templates/ ./templates/
COPY build/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ARG GIT_TAG=unknown
ARG GIT_COMMIT=unknown
ARG BUILD_DATE=unknown
LABEL org.opencontainers.image.version="${GIT_TAG}" \
org.opencontainers.image.revision="${GIT_COMMIT}" \
org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.title="fuj-management"
RUN echo "{\"tag\": \"${GIT_TAG}\", \"commit\": \"${GIT_COMMIT}\", \"build_date\": \"${BUILD_DATE}\"}" > /app/build_meta.json
EXPOSE 5001
HEALTHCHECK --interval=60s --timeout=5s --start-period=5s \

52
docs/operation-manual.md Normal file
View File

@@ -0,0 +1,52 @@
# Operation Manual
## Adding a Monthly Fee Override
Use this when the club decides to charge a different flat fee for a specific month — for example, a reduced fee during a short or holiday month.
There are two independent dictionaries in [scripts/attendance.py](../scripts/attendance.py), one for adults and one for juniors. Edit whichever tiers need an override.
### Adults
Add an entry to `ADULT_FEE_MONTHLY_RATE` (line ~15):
```python
ADULT_FEE_MONTHLY_RATE = {
"2026-03": 350 # reduced fee for March 2026
}
```
The key is `YYYY-MM`, the value is the fee in CZK. This replaces `ADULT_FEE_DEFAULT` (750 CZK) for members who attended 2+ practices that month. The single-practice fee (`ADULT_FEE_SINGLE`, 200 CZK) is unaffected.
### Juniors
Add an entry to `JUNIOR_MONTHLY_RATE` (line ~20):
```python
JUNIOR_MONTHLY_RATE = {
"2025-09": 250, # reduced fee for September 2025
"2026-03": 250 # reduced fee for March 2026
}
```
The key is `YYYY-MM`, the value is the fee in CZK. This replaces `JUNIOR_FEE_DEFAULT` (500 CZK) for members who attended 2+ practices that month.
### Example: March 2026
Both tiers reduced to 350 CZK (adults) and 250 CZK (juniors):
```python
ADULT_FEE_MONTHLY_RATE = {
"2026-03": 350
}
JUNIOR_MONTHLY_RATE = {
"2026-03": 250
}
```
### Notes
- Overrides apply to all members of the given tier — use the **exceptions sheet** in Google Sheets for per-member overrides instead.
- After changing these values, restart the web dashboard (`make web`) for the change to take effect.
- The override only affects the calculated/expected fee. It does not modify any already-recorded payments in the bank sheet.

View File

@@ -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-<slug>.md` instead of the default
`~/.claude/plans/` location. Get the timestamp with
`date "+%Y-%m-%d-%H%M"` (matches the changelog convention). The `<slug>`
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).

View File

@@ -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:** M3 — Fixture capture + characterization framework ✅
**Started:** 2026-05-04
**Last updated:** 2026-05-06
## 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.
- [x] **M2.1** `domain/czech.Normalize` — port [czech_utils.py](scripts/czech_utils.py) `normalize` (NFKD + combining-mark strip + lowercase) — `20ade6d`
- [x] **M2.2** `domain/czech.ParseMonthReferences` — port `parse_month_references` (45 month declensions, range wrap, year inference) — `0a8017f`
- [x] **M2.3** `domain/fees.CalculateFee` — port [attendance.py](scripts/attendance.py) `calculate_fee` (constants table) — `0fc3b6d`
- [x] **M2.4** `domain/fees.CalculateJuniorFee` — port `calculate_junior_fee` with `Expected{Value int; Unknown bool}` for the `"?"` sentinel — `0fc3b6d`
- [x] **M2.5** `domain/money.ParseCZK` — port [infer_payments.py](scripts/infer_payments.py) `parse_czk_amount` (Czech locale: comma decimal, dot/space thousand separators) — `d24d205`
- [x] **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)
- [x] **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) — `e596f00`
- [x] **M2.8** `domain/matching.InferTransactionDetails` — port `infer_transaction_details` (composes name + month parsing) — `e596f00`
- [x] **M2.9** `domain/matching.FormatDate` — port `format_date` (handles Google Sheets serial-day numbers since 1899-12-30) — `e596f00`
- [x] **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. — `c53bf5a`
- [x] **M2.11** `fuj fees` subcommand wired up via `domain/fees` + (M4-stub) attendance loader — fail gracefully on missing IO until M4 lands — `56aa230`
- [x] **M2.12** `fuj reconcile` subcommand similarly stubbed — `56aa230`
**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).
- [x] **M3.1** `scripts/capture_fixtures.py` — pure-function output dumper. Reads inputs from stdin / argv, prints `{"input":..., "output":...}` JSON
- [x] **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
- [x] **M3.3** Capture pure-fn fixtures for M2.1M2.9 (run helper + scrubber, commit to `tests/fixtures/pure/<func>/<case>.json`)
- [x] **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/`
- [x] **M3.5** Hook fixtures into Tier-1 test runner with `-tags=parity` build constraint
- [x] **M3.6** Document fixture-refresh workflow in `tests/fixtures/README.md` (what to do when sheet schema changes)
**Gate:** ✅ `tests/fixtures/` populated (98 files); `make go-parity` green; `make go-lint` (parity tag) clean; 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** `<table class="table">`
- [ ] **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 714 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.

View File

@@ -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 `<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](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 `<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 12 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](app.py), [scripts/](scripts/),
the Python `Dockerfile`, and the Python tests. Update
[CLAUDE.md](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:
1. Run Python locally and dump `result["members"]`, `formatted_results`,
`monthly_totals`, etc., to JSON. This is the spec.
2. Hand-author Go structs with explicit `json:` tags matching exact Python
keys (`total_balance`, `original_expected`, `attendance_count` — no
reliance on default lowercasing).
3. Generate `tests/fixtures/api-schema/*.schema.json` from the Go structs
using `github.com/invopop/jsonschema`. Commit them.
4. Add a Python-side schema validator running in CI against the new
`/api/X` responses.
**Two known-tricky shapes:**
- Junior `expected: int | "?"`
```go
type Expected struct{ Value int; Unknown bool }
// MarshalJSON emits 42 or "?"
```
Same for `original_expected`.
- Tuple dict keys `(normalize(name), normalize(period))` for exceptions —
internal only, never crosses JSON. Use
`map[ExceptionKey]Exception` with `ExceptionKey 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](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](.gitea/workflows/build.yaml)
only does `docker build`/`push`). Add `/.gitea/workflows/test.yml`:
```yaml
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.)
1. **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, `750` vs `750.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).
2. **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 iterate
`sorted_months` explicitly in Go, never the map. Lock the distribution
with a parity test on (300, 300, 150) months × 751-CZK payment.
3. **NFKD edge cases** — MEDIUM/MEDIUM. Python `unicodedata` and Go
`golang.org/x/text` use the same algorithm but can differ on niche
compatibility decompositions if `x/text` is older than CPython's tables.
*Mitigation:* parity test with every distinct character ever observed in
member names; pin `x/text` version explicitly.
4. **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 real `message`-field fixture strings.
## Cutover plan
Simpler without a proxy in the middle:
1. After M7's 7-day clean window + user sign-off, treat Go as primary.
Update bookmarks, docs, `make web` to point at Go.
2. Keep `make web-py` available for 1-week rollback. Run both containers
in production but only point users at the Go one.
3. Watch 2 weeks including a month-end settlement on Go-only.
4. Decommission Python: remove from `docker-compose.yml`, delete
[app.py](app.py) and [scripts/](scripts/), update
[CLAUDE.md](CLAUDE.md). Keep image tagged `python-final` in 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](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](scripts/czech_utils.py) — `normalize` and
`parse_month_references` underpin every member/month match across the
system. 45 Czech month declensions, range wrap-around, year inference.
- [app.py](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](scripts/sync_fio_to_sheets.py) —
`generate_sync_id` defines the dedup contract against existing rows in
the live sheet. Any drift creates duplicates.
- [scripts/attendance.py](scripts/attendance.py) — fee math + merged-month
logic + junior `"?"` sentinel.
- [scripts/cache_utils.py](scripts/cache_utils.py) — Drive `modifiedTime`
gating + two-TTL fallback that must be reproduced for shared-cache
safety.
- [templates/adults.html](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 --help` prints subcommand
list. `make web-go` serves :8080 in parallel with `make web-py` on :5001.
- **M2-M3**: `cd go && go test -tags=parity ./tests/parity/pure/...` green.
Spot-check: feed a known Czech-message string through both
`parse_month_references` implementations, 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,payments`
daily for 7 days, zero non-allowlisted diffs. User confirms Go UI is
feature-complete vs Python UI side-by-side.
- **M8**: `make web-py` removed from Makefile; `make web` points 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](.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.

View File

@@ -0,0 +1,233 @@
# Plan: Go rewrite — M1 kickoff (skeleton + tooling)
Companion to [2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md)
and the progress tracker
[2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md).
## Context
The master plan for a full Go rewrite of the Flask backend is approved
(2026-05-04). No Go code exists yet — this plan executes **M1** end-to-end:
a working `go/` skeleton, a `fuj` binary with a `server` subcommand serving
a hello page on `:8080`, lint config, Makefile + CI integration, and an
`internal/config` package mirroring [scripts/config.py](scripts/config.py).
After M1, both backends run side-by-side locally (`make web-py` on `:5001`,
`make web-go` on `:8080`) — that side-by-side capability is what unblocks
M2's parity testing and every later milestone.
## Locked-in decisions
| # | Decision | Choice |
|---|---|---|
| 1 | CLI dispatcher | stdlib `flag` + `os.Args[1]` switch (no cobra) |
| 2 | Go module path | `fuj-management/go` |
| 3 | Go version | `1.26` (latest stable; user toolchain is `go1.26.1`) |
| 4 | M1 scope | all 10 progress-tracker sub-tasks in one session |
| 5 | Lint | `golangci-lint` with govet, staticcheck, errcheck, gofumpt, unused |
| 6 | Logging | `log/slog` text handler, level from `LOG_LEVEL` env |
| 7 | HTTP | `net/http.ServeMux` (Go 1.22+ pattern matching) |
| 8 | Container base | `golang:1.26` builder → `gcr.io/distroless/static:nonroot` runtime |
| 9 | CI | extend [.gitea/workflows/build.yaml](.gitea/workflows/build.yaml) with a `go-build` job parallel to existing Python `build` job; tag suffix `-go` |
## Files to create
```
go/
go.mod # module fuj-management/go, go 1.26
go.sum # empty / generated
.golangci.yml # govet, staticcheck, errcheck, gofumpt, unused
cmd/fuj/main.go # subcommand dispatcher + version vars
internal/
config/config.go # env loader mirroring scripts/config.py
logging/logger.go # slog setup honoring LOG_LEVEL
web/
server.go # `fuj server` handler: ServeMux on :8080, hello page
middleware/timer.go # request-timer middleware (parity with Python `get_render_time`)
build/
Dockerfile # multi-stage golang:1.26 → distroless/static
```
No `embed.FS`, no templates, no static assets in M1 — the hello page is
inline HTML in `server.go`. Templates land in M6.
## Files to edit
- [Makefile](Makefile) — add Go targets, rename `web``web-py`, keep
`web` as transitional alias to `web-py` until M8.
- [.gitignore](.gitignore) — add `bin/` and `go/.cache/` (if any).
- [.gitea/workflows/build.yaml](.gitea/workflows/build.yaml) — add
`go-build` job that builds and pushes `<tag>-go` image.
- [CHANGELOG.md](CHANGELOG.md) — top-of-file entry per CLAUDE.md convention.
- [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md)
— tick M1.1M1.10 with commit SHAs as they land.
## Execution sequence
Order is tight: each step keeps the tree compilable and lint-clean.
1. **Skeleton (M1.1)**`mkdir -p go/{cmd/fuj,internal/{config,logging,web/middleware},build}` and `cd go && go mod init fuj-management/go`. Pin `go 1.26` in `go.mod`.
2. **Config + logger (M1.8, M1.9)** — write `internal/config/config.go` mirroring [scripts/config.py](scripts/config.py): exported constants for `AttendanceSheetID`, `PaymentsSheetID`, `JuniorSheetGID`, env-driven `CredentialsPath`, `BankAccount`, `CacheTTL`, `CacheAPICheckTTL`, `LogLevel`, `FioAPIToken`. Write `internal/logging/logger.go` with a `New() *slog.Logger` honoring `LOG_LEVEL` (`DEBUG|INFO|WARN|ERROR`).
3. **Web middleware + handler (M1.3)**`internal/web/middleware/timer.go` logs `method path status ms` for every request. `internal/web/server.go` exposes `Run(ctx, addr) error`: `http.ServeMux` with `GET /` returning a minimal HTML hello page that includes `version`, `commit`, and `buildDate` (linker-injected via `-X main.version=…`).
4. **Subcommand dispatcher (M1.2)**`cmd/fuj/main.go`:
- Package-level `var version, commit, buildDate string` for `-ldflags -X` injection.
- `os.Args[1]` switch over `server | version | fees | reconcile | sync | infer | help`. M1 implements `server` and `version`; the rest print `<cmd>: not implemented yet (lands in M2/M4)` and exit 2.
- Each subcommand parses its own `flag.NewFlagSet`. `server` flags: `--addr` (default `:8080`).
5. **Lint config (M1.6)**`go/.golangci.yml` enabling `govet`, `staticcheck`, `errcheck`, `gofumpt`, `unused`. Run `golangci-lint run ./...` to confirm clean.
6. **Makefile (M1.4, M1.5)** — add:
```make
GO_BIN := bin/fuj
GO_SRC := go
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 --addr :8080
```
Rename existing `web:` target to `web-py:` and add `web: web-py` as alias.
7. **Dockerfile + CI (M1.7)** — `go/build/Dockerfile`:
```dockerfile
FROM golang:1.26 AS build
WORKDIR /src
COPY go/go.mod go/go.sum ./
RUN go mod download
COPY go/ ./
ARG GIT_TAG=unknown
ARG GIT_COMMIT=unknown
ARG BUILD_DATE=unknown
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X main.version=${GIT_TAG} -X main.commit=${GIT_COMMIT} -X main.buildDate=${BUILD_DATE}" \
-o /out/fuj ./cmd/fuj
FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/fuj /usr/local/bin/fuj
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/usr/local/bin/fuj","server"]
```
In [.gitea/workflows/build.yaml](.gitea/workflows/build.yaml), add a parallel job:
```yaml
build-go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker login ...
- run: |
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 gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG-go .
docker push gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG-go
```
8. **Smoke verify (M1.10)** — see Verification section below; then append a CHANGELOG entry and tick M1 boxes in the progress tracker with commit SHAs.
## Reuse / parity with Python side
- `internal/config` mirrors [scripts/config.py](scripts/config.py) **exactly** — same env var names, same defaults. No new env knobs in M1.
- Request-timer middleware records elapsed milliseconds; this is the Go-side
equivalent of the Python `get_render_time` helper that supplies
`render_time.total` to templates. Allowlisted as volatile in the future
parity diff (M5).
- Constants `AttendanceSheetID`, `PaymentsSheetID`, `JuniorSheetGID` are
copied verbatim from [scripts/config.py](scripts/config.py); they don't
get used until M4 but live in `internal/config` from day one.
## Verification
Run from repo root after all changes are in place:
```bash
# 1. Builds clean
make go-build && test -x bin/fuj
# 2. Lint clean
make go-lint
# 3. Subcommand dispatcher works
./bin/fuj help
./bin/fuj version # prints version/commit/buildDate
./bin/fuj fees # prints "not implemented yet" and exits 2
# 4. Server runs and hello page is served
make web-go &
GO_PID=$!
sleep 1
curl -sf http://localhost:8080/ | grep -q "fuj"
kill $GO_PID
# 5. Side-by-side: both backends up
make web-py & # :5001
PY_PID=$!
make web-go & # :8080
GO_PID=$!
sleep 2
curl -sf http://localhost:5001/ >/dev/null && echo "py OK"
curl -sf http://localhost:8080/ >/dev/null && echo "go OK"
kill $PY_PID $GO_PID
# 6. Race-free unit tests pass (none yet beyond a smoke test, but harness works)
make go-test
# 7. Docker image builds locally
docker build -f go/build/Dockerfile -t fuj-go:dev .
docker run --rm -p 8080:8080 fuj-go:dev &
sleep 1
curl -sf http://localhost:8080/ >/dev/null && echo "container OK"
docker stop $(docker ps -lq)
```
All seven steps must succeed. Then update the progress tracker and
CHANGELOG.
## Out of scope for M1 (deferred to later milestones)
- Domain logic — `czech.Normalize`, fees, reconcile, etc. → **M2**.
- Fixture capture and parity tests → **M3**.
- Sheets/Drive/Fio clients and `internal/io/*` → **M4**.
- `/api/*` JSON routes and `cmd/parity` → **M5**.
- HTML templates, static assets, `embed.FS` → **M6**.
- Removing the Python backend → **M8**.
## Open items / forks the user can override at review
- **CI tag suffix**: `<tag>-go` proposed. Alternative: separate image
repository (`fuj-management-go:<tag>`). The suffix keeps things in one
registry path; speak up if separate repos are preferred.
- **Distroless variant**: `nonroot` chosen for least privilege. If the
existing Python container runs as root and the user expects parity,
switch to `gcr.io/distroless/static` (root). Doesn't affect M1
functionality.
- **Hello page content**: minimal HTML mentioning `fuj`, version, commit,
build date, link list to future routes. Speak up if you want a different
shape — it gets thrown away in M6 anyway.
## Critical files
- [docs/plans/2026-05-03-2349-go-backend-rewrite.md](docs/plans/2026-05-03-2349-go-backend-rewrite.md) — master plan (approved 2026-05-04)
- [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) — task tracker; tick M1.1M1.10 here
- [Makefile](Makefile) — current target structure (renaming `web` → `web-py`)
- [scripts/config.py](scripts/config.py) — source of truth for env vars / IDs that `internal/config` mirrors
- [build/Dockerfile](build/Dockerfile) — Python container (unchanged); the new Go Dockerfile lives at `go/build/Dockerfile`
- [.gitea/workflows/build.yaml](.gitea/workflows/build.yaml) — extended with parallel `build-go` job

View File

@@ -0,0 +1,81 @@
# Exact full-name match for payment inference
## Context
A bank payment with the message `Henrietta Ottová (Heny): 04/2026` is being inferred to **two** members: the correct `Henrietta Ottová` *and* the unrelated `Tomáš Němeček (Tov)`. As a result, `reconcile()` splits the amount 50/50 between them, producing wrong balances.
**Root cause** (`scripts/match_payments.py:51-115`): `match_members` runs four substring checks via raw Python `in`, with no word boundaries. Tomáš's nickname `Tov` normalizes to `tov`, which is literally a substring of `ottova`. Check #3 (`match_payments.py:79-85`) treats bare nickname presence as an `auto`-confidence match, so Tomáš is appended even though no part of his name is actually in the message. There is also no short-circuit when a member's full canonical name appears verbatim — every other member is still scored against the same haystack.
**Goal:** when a member's full canonical name (diacritics-insensitive) appears in the message as whole words, return only the full-name hit(s) and skip nickname/partial scoring entirely. Additionally, harden the remaining checks with word boundaries so future substring collisions (any nickname or short name part that happens to live inside another member's surname) can't reproduce this class of bug.
## Approach
Single-file change in [scripts/match_payments.py](scripts/match_payments.py). Two coordinated edits to `match_members` (`match_payments.py:51-115`):
### 1. Add an exact-canonical-name short-circuit (new, before the existing loop)
After computing `normalized_text`, do a first pass that collects every member whose `normalized_base` (the full name minus the parenthesized nickname, normalized) appears in the haystack as **whole words**. If at least one is found, return *only* those as `auto` matches and skip the rest of the function.
Implementation sketch (inserted between [match_payments.py:58](scripts/match_payments.py#L58) and [match_payments.py:61](scripts/match_payments.py#L61)):
```python
exact_matches = []
for name in member_names:
variants = _build_name_variants(name)
full_name = variants[0] if variants else ""
if full_name and re.search(rf"\b{re.escape(full_name)}\b", normalized_text):
exact_matches.append((name, "auto"))
if exact_matches:
return exact_matches
```
This satisfies the user's primary ask: when the message literally contains the canonical name, that wins outright. Multi-member messages still work — every full-name occurrence is collected.
### 2. Replace remaining `in normalized_text` checks with `\b…\b` regex
For the three checks that survive the short-circuit (and the `review`-tier partials), swap raw `in` for whole-word regex so `tov` cannot match inside `ottova`, `dan` cannot match inside `bohdan`, etc. Affected lines:
- [match_payments.py:73](scripts/match_payments.py#L73) — first+last name both present
- [match_payments.py:82](scripts/match_payments.py#L82) — nickname presence
- [match_payments.py:94](scripts/match_payments.py#L94) — last-name partial (`review`)
- [match_payments.py:99](scripts/match_payments.py#L99) — first-name partial (`review`)
- [match_payments.py:104](scripts/match_payments.py#L104) — single-name member partial
Helper to keep the call sites tidy:
```python
def _word_in(needle: str, haystack: str) -> bool:
return bool(re.search(rf"\b{re.escape(needle)}\b", haystack))
```
Check #1 (line 67) becomes redundant once the short-circuit is in place, but leave it untouched as a defensive fallback in case `_build_name_variants` ever returns a `full_name` shorter than the 3-char filter would allow. (No code change there.)
### 3. Why this is sufficient
- The reported message `Henrietta Ottová (Heny): 04/2026` hits the new short-circuit on `henrietta ottova`, returns `[("Henrietta Ottová", "auto")]`, and never even evaluates Tomáš.
- Bare-nickname messages (e.g. `Heny 04/2026`) skip the short-circuit (no full name present) and fall into the existing nickname check — now word-bounded, so `tov` no longer collides with `ottova` even there.
- Combined-payment messages listing two full names continue to work: both are collected by the short-circuit.
### Files to modify
- [scripts/match_payments.py](scripts/match_payments.py) — only `match_members` (lines 51-115). Add `_word_in` helper just above it.
### Files to read for confidence (no edits)
- [scripts/czech_utils.py](scripts/czech_utils.py) — confirm `normalize()` semantics (NFKD strip + lowercase). Already understood; relevant because `re.escape` on already-normalized lowercase ASCII is safe.
- [scripts/infer_payments.py](scripts/infer_payments.py) — confirm it just consumes the `match_members` output verbatim and writes comma-joined names. No change needed; the upstream fix propagates.
- [scripts/match_payments.py:336-362](scripts/match_payments.py#L336-L362) — `reconcile()` only re-runs inference when `Person` is empty, so existing wrong rows in the sheet must be cleared by hand or via the `manual fix`/blank-cell workflow before re-running `make infer`.
## Verification
1. **Unit test** — add `tests/test_match_members.py` (new file, mirroring `tests/test_reconcile_exceptions.py` style). Cases:
- `match_members("Henrietta Ottová (Heny): 04/2026", ["Henrietta Ottová", "Tomáš Němeček (Tov)"])``[("Henrietta Ottová", "auto")]` only.
- `match_members("Heny 04/2026", ["Tomáš Němeček (Tov)", "Henrietta Ottová"])` → no match for Tomáš (the substring trap is closed); whatever the legitimate behavior for "Heny" is, document it.
- Combined payment: `match_members("Henrietta Ottová a Tomáš Němeček 04/2026", ["Henrietta Ottová", "Tomáš Němeček (Tov)"])` → both as `auto`.
- Sanity: `match_members("VS 1234 Tomáš Němeček", [...])` still returns Tomáš.
2. **Run the suite**: `make test`.
3. **End-to-end**: clear the buggy row's `Person`/`Purpose` cells in the payments sheet, then `make infer`, then `make reconcile`. Confirm the payment now allocates fully to Henrietta and balance reflects it.
4. **Changelog**: per [CLAUDE.md](CLAUDE.md), append an entry to [CHANGELOG.md](CHANGELOG.md) once the user confirms the fix works in production. Format: `## 2026-05-04 HH:MM TZ — fix: payment inference exact-match short-circuit`.

View File

@@ -0,0 +1,99 @@
# Member modal — raw payments debug list
## Context
When a payer's bank message doesn't follow our convention, [`infer_payments.py`](scripts/infer_payments.py) may map the transfer to the wrong period (or none), and today the member detail modal hides this — it only shows the post-allocation, per-month splits produced by [`reconcile()`](scripts/match_payments.py:295). To diagnose these cases the user needs to see the **original sheet rows** that were attributed to a member: full `Amount`, `Inferred Amount`, `Person`, `Purpose`, `Sender`, `Message`, `Bank ID`, `manual fix`. The list should be hidden by default and revealed by a small toggle, since it is only relevant during debugging.
## Approach
Reuse the grouping logic that already exists in the [`/payments` route](app.py:540-553): group raw `tx` dicts by parsed `Person`, expose that mapping to the modal, and render it on demand under a new collapsible section.
### 1. Backend — group raw txs by member
In [`app.py`](app.py):
- Factor the existing per-person grouping in [`payments()`](app.py:530-568) into a small helper near the top of the file:
```python
def group_payments_by_person(transactions):
grouped = {}
for tx in transactions:
person = str(tx.get("person", "")).strip()
if not person:
continue # unmatched rows are not tied to a member
for p in person.split(","):
p = re.sub(r"\[\?\]\s*", "", p).strip()
if not p:
continue
grouped.setdefault(p, []).append(tx)
for rows in grouped.values():
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
return grouped
```
Call it from [`payments()`](app.py:530), [`adults_view()`](app.py:160) and [`juniors_view()`](app.py:326) — the existing `payments()` body collapses to one line.
- In `adults_view()` and `juniors_view()`, after `transactions = get_cached_data(...)`, build `raw_payments_by_person = group_payments_by_person(transactions)` and pass it to `render_template` as `raw_payments_json=json.dumps(raw_payments_by_person)`.
- Note: rows where `Person` is empty are skipped on purpose — those have no member to attach to and are already shown by the dashboard's `Unmatched` block.
### 2. Templates — add a collapsible raw section to the modal
In [`templates/adults.html`](templates/adults.html) and [`templates/juniors.html`](templates/juniors.html), make the same structural and JS changes (the modal markup is mirrored in both files — `adults.html:677-682` and `juniors.html:658-663`).
- Inject the new dataset alongside the existing `memberData`:
```html
const rawPaymentsByPerson = {{ raw_payments_json| safe }};
```
(next to [`adults.html:696`](templates/adults.html#L696)).
- Add a new section directly **after** the Payment History block:
```html
<div class="modal-section">
<div class="modal-section-title">
Raw Payments
<a href="#" id="rawPaymentsToggle" class="raw-toggle"
onclick="toggleRawPayments(event)">[show]</a>
</div>
<div id="modalRawList" class="tx-list" style="display: none;">
<!-- Filled by JS -->
</div>
</div>
```
Add a small CSS rule for `.raw-toggle` (muted color, smaller font, `margin-left: 8px`) — a few lines next to the existing `.modal-section-title` style. Don't restyle the whole modal.
- In `showMemberDetails(name)`:
- Reset the toggle to `[show]` and the `#modalRawList` to `display: none` on every open (so the state doesn't leak between members).
- Populate `#modalRawList` from `rawPaymentsByPerson[name] || []`. For each row render: `Date | Purpose` on the meta line, `Amount CZK` (with `Inferred: X CZK` annotation when `inferred_amount` differs from `amount`), `Sender`, `Person` (full string — useful when split between multiple people), `Message`, and a small footer with `Bank ID` and a `[manual fix]` marker if `manual_fix` is truthy. Reuse the existing `tx-item` / `tx-meta` / `tx-main` / `tx-msg` styles to match the rest of the modal.
- When the list is empty, render `<div style="color: #444; font-style: italic; padding: 10px 0;">No raw payments tied to this member.</div>` (same idiom used at [`adults.html:813`](templates/adults.html#L813)).
- Add the toggle handler near `closeModal`:
```js
function toggleRawPayments(ev) {
ev.preventDefault();
const list = document.getElementById('modalRawList');
const link = document.getElementById('rawPaymentsToggle');
const hidden = list.style.display === 'none';
list.style.display = hidden ? 'block' : 'none';
link.textContent = hidden ? '[hide]' : '[show]';
}
```
### 3. Why not extend `reconcile()` instead
`reconcile()` already collapses each row into per-month allocated shares and drops `purpose`, `inferred_amount`, `bank_id`, `manual_fix`, and the gross `amount` ([trace](scripts/match_payments.py:436-469)). Carrying the raw `tx` through `reconcile()` would inflate the contract for every consumer when only the modal needs it. Grouping the already-fetched `transactions` list at the route level is one extra dict per request and reuses the cached payments data — no new sheet reads.
## Critical files
- [app.py](app.py) — add `group_payments_by_person()` helper; call it in `adults_view()`, `juniors_view()`, and `payments()`; pass `raw_payments_json` to the two dashboard templates.
- [templates/adults.html](templates/adults.html) — modal section + JS + tiny CSS for the toggle link.
- [templates/juniors.html](templates/juniors.html) — same changes as adults.html.
## Verification
1. `make web-debug` and open `http://localhost:5001/adults`.
2. Pick a member known to have multiple payments (use the existing `/payments` page as a cross-reference).
3. Click `[i]` → modal opens, raw list is hidden, link shows `[show]`. Click the link → list appears with the raw rows; click again → hides, link returns to `[show]`.
4. Switch to another member via keyboard (ArrowDown) — the toggle resets to hidden and the list updates to the new member's rows (no leaking).
5. Compare the raw rows in the modal against the `/payments` page grouping for the same person — same set of rows, same `Date`/`Amount`/`Message`.
6. Pick a row with a non-conformant message (e.g. one where `Person` was inferred to multiple people) — confirm `Person` shows the full comma-separated string and `Inferred Amount` is visible when it differs from `Amount`.
7. Repeat the click-through on `/juniors` to confirm parity.
8. `make test` — no backend behavior change is expected, but run to catch template/route smoke breakage.

View File

@@ -0,0 +1,135 @@
# Tolerate diacritic / case / whitespace mismatches between `Person` column and member names
## Context
For "Mária Maco" there is a payment row in the payments sheet with `Purpose = 2026-04`, but the modal for that member shows neither a paid 2026-04 cell **nor** a row in payment history. Both symptoms collapse to a single root cause in [`reconcile()`](scripts/match_payments.py#L295), confirmed by reading the code:
- [`scripts/match_payments.py:404`](scripts/match_payments.py#L404) — `if member_name not in ledger:` is a **byte-exact** comparison. `member_name` is the `Person` cell from the payments sheet with only `.strip()` and `[?]` markers removed ([:349-353](scripts/match_payments.py#L349-L353)). `ledger` keys are the canonical names from the attendance sheet. There is no diacritic, case, or whitespace normalization on this path. (`czech_utils.normalize` is imported and used for the `exceptions` lookup at [:282-283 / :321-322](scripts/match_payments.py#L282-L322), but **not** for member-name matching.)
- When a row falls through that check, it is appended to `unmatched` and never reaches `ledger[member_name][m]['paid']` or `['transactions']`. The dashboard's per-month "paid" cell stays unpaid, and because the modal's payment history is built from `data.months[m].transactions` ([`templates/adults.html:772-776`](templates/adults.html#L772-L776)), the row also disappears from the modal's history list.
- The new "Raw Payments" debug section ([`templates/adults.html:861`](templates/adults.html#L861)) uses `rawPaymentsByPerson[name]`. Its keys come from [`group_payments_by_person()` in `app.py:60-73`](app.py#L60-L73), which also stores the **literal** `Person` string (only `.strip()` and `[?]` stripped). So if the attendance-sheet name and the `Person` cell differ at the byte level, that section also returns an empty list — which is why the user does not see the row anywhere in the modal.
The most likely cause for "Mária Maco" specifically: the `Person` cell was typed (or pasted) without the `á` diacritic — `Maria Maco` vs `Mária Maco`. Other plausible variants the current code silently drops: case differences (`mária maco`), trailing/embedded extra whitespace, and NBSP characters.
The fix is to make the matching tolerant via the existing [`czech_utils.normalize()`](scripts/czech_utils.py#L22-L25) helper (NFKD + lowercase), with a small whitespace-collapse on top, and apply the same canonicalization in `group_payments_by_person()` so the modal's raw-payments lookup uses the canonical attendance-sheet name as the key.
## Approach
### 1. `scripts/match_payments.py` — tolerant `Person` → `ledger` resolution in `reconcile()`
- Add a small private helper at module scope:
```python
def _canonical_key(name: str) -> str:
return re.sub(r"\s+", " ", normalize(name)).strip()
```
Uses the existing `normalize()` from `czech_utils` ([:22-25](scripts/czech_utils.py#L22-L25)) and additionally collapses whitespace runs to a single space so `"Mária Maco"` and `"Mária Maco"` both reduce to `"maria maco"`.
- Inside [`reconcile()`](scripts/match_payments.py#L295), right after `member_names` is computed ([:308](scripts/match_payments.py#L308)), build a lookup dict once:
```python
canonical_by_key: dict[str, str] = {}
for name in member_names:
key = _canonical_key(name)
canonical_by_key.setdefault(key, name) # first wins; ambiguity handled below
```
- Replace the byte-exact check at [:404](scripts/match_payments.py#L404). Resolve each `member_name` from `matched_members` to the canonical attendance-sheet name before any ledger / credits access:
```python
for raw_member_name, confidence in matched_members:
member_name = canonical_by_key.get(_canonical_key(raw_member_name))
if member_name is None:
logger.warning(
"Payment matched to unknown member %r (tx: %s, %s) — adding to unmatched",
raw_member_name, tx.get("date", "?"), tx.get("message", "?"),
)
unmatched.append(tx)
continue
if member_name != raw_member_name:
logger.info(
"Person cell %r resolved to canonical member %r — consider fixing the sheet",
raw_member_name, member_name,
)
# ... rest of the loop body unchanged: ledger[member_name], credits[member_name], …
```
The `logger.info` line lets the user see (in `make web-debug` logs) which sheet rows have a non-canonical `Person` value, so they can clean them up at their own pace — without breaking allocation in the meantime.
- Leave the rest of the function untouched. Once `member_name` is the canonical name, every downstream key (`ledger[member_name]`, `credits[member_name]`, `other_ledger[member_name]`, the `tx["person"]` echo into `transactions`) is already correct.
### 2. `app.py` — canonicalize the raw-payments grouping key
- The current [`group_payments_by_person()`](app.py#L60-L73) cannot canonicalize on its own because it does not know the attendance-sheet member list. Extend its signature to accept the member list and reuse `_canonical_key`:
```python
from match_payments import _canonical_key # or re-export via a tiny public name
def group_payments_by_person(transactions, member_names=None):
canonical_by_key = (
{_canonical_key(n): n for n in member_names} if member_names else {}
)
grouped = {}
for tx in transactions:
person = str(tx.get("person", "")).strip()
if not person:
continue
for p in person.split(","):
p = re.sub(r"\[\?\]\s*", "", p).strip()
if not p:
continue
key = canonical_by_key.get(_canonical_key(p), p) # fallback: keep raw
grouped.setdefault(key, []).append(tx)
for rows in grouped.values():
rows.sort(key=lambda t: str(t.get("date", "")), reverse=True)
return grouped
```
- Update the three call sites to pass `member_names`:
- `adults_view()` around [`app.py:333`](app.py#L333) — `members` is already in scope; pass `[name for name, _, _ in members]`.
- `juniors_view()` around [`app.py:539`](app.py#L539) — same.
- `payments()` around [`app.py:549`](app.py#L549) — same; needs the adult+junior member names so the `/payments` per-person grouping is consistent.
- Naming: `_canonical_key` starts with an underscore inside `match_payments.py`. To avoid leaking a private symbol, expose it as `canonical_member_key` (no underscore) in `match_payments.py` and import that name from `app.py`.
### 3. Why not also touch `infer_payments.py`
`infer_payments.py` already writes canonical attendance-sheet names into the `Person` column (it picks from `member_names`). The bug only manifests when the cell was filled in **manually** by a human (typed without diacritics, different case) or was written by an older inference that has since drifted from a renamed attendance row. Making `reconcile()` tolerant fixes the symptom for both cases without changing inference. The `logger.info` line is sufficient signal for the user to clean up the sheet on their own schedule.
### 4. Tests
**4a. Delete obsolete route tests in [tests/test_app.py](tests/test_app.py).** Four tests target Flask routes that no longer exist (the old fee/reconcile pages were merged into `/adults` and `/juniors`); they currently fail with 404. Their coverage is already provided by `test_adults_route`, `test_juniors_route`, and `test_payments_route`. Delete:
- `test_fees_route` ([tests/test_app.py:22-35](tests/test_app.py#L22-L35)) — hits `/fees`
- `test_fees_juniors_route` ([tests/test_app.py:37-55](tests/test_app.py#L37-L55)) — hits `/fees-juniors`
- `test_reconcile_route` ([tests/test_app.py:57-81](tests/test_app.py#L57-L81)) — hits `/reconcile`; also asserts a literal `OK` string the merged dashboard no longer renders
- `test_reconcile_juniors_route` ([tests/test_app.py:101-131](tests/test_app.py#L101-L131)) — hits `/reconcile-juniors`; same `OK` assertion mismatch
The two tests that reference junior-only formatting (`? / 1 (J)` and `500 CZK / 4 (1A+3J)`) are testing a retired template, not the live `/juniors` page — no need to migrate those assertions; the live `/juniors` format is already covered by `test_juniors_route`.
**4b. Add `tests/test_match_payments.py`** (new file) covering the resolution helper and `reconcile()` end-to-end for the canonicalization fix:
- `_canonical_key("Mária Maco") == _canonical_key("maria maco")`
- `reconcile()` with member `"Mária Maco"` and a tx `{person: "Maria Maco", purpose: "2026-04", amount: 750, ...}` produces:
- `result['members']['Mária Maco']['months']['2026-04']['paid'] == 750`
- the tx appears in `result['members']['Mária Maco']['months']['2026-04']['transactions']`
- `result['unmatched']` is empty
- `reconcile()` with `Person = "Někdo Neznámý"` (no match in members) still routes to `unmatched`.
## Critical files
- [scripts/match_payments.py](scripts/match_payments.py) — add `canonical_member_key()` helper; build `canonical_by_key` once in `reconcile()`; resolve `raw_member_name` → `member_name` before ledger access at [:404](scripts/match_payments.py#L404).
- [app.py](app.py) — extend `group_payments_by_person()` to accept `member_names` and key the grouped dict by canonical attendance-sheet name; update three call sites.
- [tests/test_app.py](tests/test_app.py) — delete the four obsolete route tests listed in §4a.
- [tests/test_match_payments.py](tests/test_match_payments.py) — add the cases above (create the file if missing).
- [docs/plans/](docs/plans/) — per project [CLAUDE.md](CLAUDE.md), move this plan file to `docs/plans/2026-05-05-1640-payment-person-name-canonicalization.md` once execution starts (the plan-mode harness writes to `~/.claude/plans/` by default).
## Verification
1. **Reproduce first.** Before touching code, open `/adults`, click `[i]` next to "Mária Maco", and confirm both: 2026-04 is unpaid and the payment is missing from history. Inspect the actual `Person` cell value in the payments sheet for the 2026-04 row — confirm it differs from `"Mária Maco"` (likely missing the `á`). Record the exact string for the test case.
2. `make test` — new tests pass; existing tests still green.
3. `make web-debug` and reload `/adults`. The 2026-04 cell for "Mária Maco" turns green (`cell-ok`); the modal's payment history shows the row; the "Raw Payments" section also shows the row. Server log emits `Person cell 'Maria Maco' resolved to canonical member 'Mária Maco' — consider fixing the sheet`.
4. Cross-check `/payments` — the row appears under the `Mária Maco` group (canonical key), not under a separate `Maria Maco` group.
5. Spot-check one member with the conventionally-correct `Person` value (e.g. one of the recent payers visible on the dashboard) — paid cells and history are unchanged, no spurious resolution log line.
6. Confirm a payment with a genuinely unknown `Person` (typo of a non-member) still ends up in the dashboard's `Unmatched` block and emits the existing `Payment matched to unknown member …` warning.
7. Append a `CHANGELOG.md` entry per [CLAUDE.md](CLAUDE.md) once the user confirms the fix works.

View File

@@ -0,0 +1,83 @@
# Branch-per-feature + Gitea MR workflow
## Context
Until now, Claude has been committing feature work directly to `main`
(see recent history: `feat: Lower adult monthly fee…`, `feat: Go rewrite M1…`,
all on `main`). The user wants to switch to a branch-per-feature flow with
review via a Gitea merge request, so that:
- Feature work is reviewable as a self-contained diff before it lands.
- `main` stays releasable.
- The change history shows reviewed merges, not unsupervised pushes.
The remote is Gitea (`https://gitea.home.hrajfrisbee.cz/kacerr/fuj-management.git`),
which supports the standard pull/merge-request flow.
This plan only modifies `CLAUDE.md`. No code changes.
## Scope clarification (from user)
- **MR creation method:** Claude pushes the branch and prints the Gitea
"compare" URL. The user opens / merges the MR in the browser. No `tea` CLI,
no API calls.
- **When the flow applies:** Features only. Small bug fixes and hotfixes can
still be committed straight to `main`. Claude decides feature-vs-fix based
on scope; when uncertain, ask.
- **Branch naming:** `feat/<slug>` for features, `fix/<slug>` for the
occasional bug-fix branch the user explicitly requests. `<slug>` is
kebab-case, short, descriptive.
## Change
Add a new top-level section to `CLAUDE.md` titled **"Branching & merge requests"**,
placed immediately before the existing `## Git Commits` section so the workflow
context appears before the commit-message convention.
### Proposed section content
```markdown
## Branching & merge requests
The remote is Gitea (`gitea.home.hrajfrisbee.cz/kacerr/fuj-management`).
For **features**, do not commit to `main` directly. Use a branch + merge
request flow:
1. **Create a branch off `main`** before starting work:
- `feat/<slug>` for features (e.g. `feat/qr-code-overlay`)
- `fix/<slug>` for bug-fix branches the user explicitly asks for
- `<slug>` is short kebab-case
2. **Commit on the branch** following the existing commit conventions
(Co-Authored-By trailer, etc.).
3. **Push the branch** to `origin` with `-u` so it tracks.
4. **Print the Gitea compare URL** so the user can open the MR in the
browser:
`https://gitea.home.hrajfrisbee.cz/kacerr/fuj-management/compare/main...<branch>`
Do **not** use `tea`, `gh`, or call the Gitea API — the user opens and
merges the MR themselves.
5. **Do not merge or delete the branch** from the CLI. The user does that
in Gitea.
**Exceptions — when committing straight to `main` is fine:**
- Small bug fixes / hotfixes the user describes as such.
- Typo / comment / formatting tweaks.
- Edits the user explicitly says to push to `main`.
When uncertain whether something is "feature" or "small fix", ask before
committing.
```
## Files to modify
- [CLAUDE.md](CLAUDE.md) — insert the new `## Branching & merge requests`
section just above the existing `## Git Commits` section (around line 95).
## Verification
- Re-read `CLAUDE.md` and confirm the new section is well-placed and the
existing structure (`## Git Commits`, `## Changelog`, `## Plans`) is intact.
- `git diff CLAUDE.md` should show only an additive change.
- No code, tests, or runtime behavior changes — nothing else to test.
- Behavior verification happens on the **next** feature request: Claude
should create a `feat/<slug>` branch, commit there, push, and print the
compare URL instead of committing on `main`.

View File

@@ -0,0 +1,154 @@
# Plan: Go rewrite — M2.1 `domain/czech.Normalize`
## Context
The Go rewrite finished M1 (skeleton, tooling, hello server) in commit
`cf0f176` on 2026-05-04. The next milestone, **M2 — Pure-domain helpers**,
is current per [progress tracker](2026-05-03-2349-go-backend-rewrite-progress.md)
but has no work landed yet (all 12 sub-tasks unchecked).
This plan covers only the **first** M2 task: porting Python's
`normalize` from [scripts/czech_utils.py](../../scripts/czech_utils.py)
to Go as `internal/domain/czech.Normalize`. It is the lowest-level helper
in the domain — `parse_month_references`, `_build_name_variants`,
`match_members`, exception keys, and `reconcile` all transitively depend
on it. Getting it byte-equivalent first removes a class of "why does my
match not fire" failures from every later M2 task.
**Decision (confirmed in plan-mode Q):** start with hand-written Go unit
tests for fresh Czech edge cases. Defer parity-fixture wiring until
M3.1/M3.2 land (separate task); add the parity test for `Normalize`
retroactively at that point.
## Scope
- New package `go/internal/domain/czech/` with `Normalize` and unit tests.
- Add `golang.org/x/text` dependency to `go/go.mod` (currently zero deps).
- **Out of scope:** `ParseMonthReferences` (M2.2), fixture tooling
(M3.1/M3.2), CLI subcommand wiring (M2.11/M2.12), parity test runner.
## Recommended approach
### Python contract to match
```python
def normalize(text: str) -> str:
nfkd = unicodedata.normalize("NFKD", text)
return "".join(c for c in nfkd if not unicodedata.combining(c)).lower()
```
Three semantic operations:
1. NFKD decompose
2. Drop characters where `unicodedata.combining(c)` is non-zero
3. Lowercase
### Go implementation
`go/internal/domain/czech/normalize.go`:
```go
package czech
import (
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
func Normalize(s string) string {
decomposed := norm.NFKD.String(s)
var b strings.Builder
b.Grow(len(decomposed))
for _, r := range decomposed {
if unicode.In(r, unicode.Mn) {
continue
}
b.WriteRune(r)
}
return strings.ToLower(b.String())
}
```
**Two precision points worth flagging:**
1. **`unicode.Mn` not `unicode.IsMark`.** The plan's library-choices
table mentions `unicode.IsMark`, but that covers Mn + Mc + Me. Python
`unicodedata.combining()` returns 0 for Mc/Me (their canonical
combining class is 0), so it effectively filters only Mn. Use
`unicode.In(r, unicode.Mn)` for byte-equivalence with Python. Cite
this in a one-line code comment; it's the kind of thing a future
reader will second-guess.
2. **`strings.ToLower` vs Go's locale-aware tools.** Python's `.lower()`
on already-decomposed Latin is straight ASCII lowercase for Czech.
Stdlib `strings.ToLower` matches; do not pull in `golang.org/x/text/cases`.
### Tests
`go/internal/domain/czech/normalize_test.go` — table-driven, covers:
- ASCII passthrough: `"Honza" → "honza"`
- Czech lowercase diacritics: `"žluťoučký" → "zlutoucky"`
- Mixed case + diacritics: `"Příliš" → "prilis"`
- Czech caron + ring: `"Dvořák" → "dvorak"`, `"Růžena" → "ruzena"`
- Hard letters: `"Čeněk" → "cenek"`, `"Kačer" → "kacer"`
- Empty string: `"" → ""`
- Already-normalized: `"prilis" → "prilis"` (idempotence)
- Pre-composed vs decomposed input both produce the same output (NFC
`"é"` and `"é"` both → `"e"`)
- Whitespace preserved: `"Jan Novák" → "jan novak"`
Run a one-shot cross-check against the live Python implementation for
each test input before locking the table:
```
PYTHONPATH=scripts:. python -c \
'from czech_utils import normalize; print(repr(normalize("Dvořák")))'
```
This is the manual stand-in for the M3 parity fixtures.
### Wire-up
- `go get golang.org/x/text@latest` (run from `go/`); `go mod tidy`.
- No CLI changes — `cmd/fuj` already stubs `fees`/`reconcile` with
exit code 2; no need to touch dispatcher for this task. `Normalize`
is consumed by other domain code, not by users directly.
## Critical files
- New: [go/internal/domain/czech/normalize.go](../../go/internal/domain/czech/normalize.go)
- New: [go/internal/domain/czech/normalize_test.go](../../go/internal/domain/czech/normalize_test.go)
- Modified: [go/go.mod](../../go/go.mod), `go/go.sum` (new)
- Reference (read-only): [scripts/czech_utils.py](../../scripts/czech_utils.py) — the porting source
- Reference (read-only): [docs/plans/2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md) — risk #3 (NFKD edge cases)
## Verification
End-to-end checks before marking M2.1 done:
1. `cd go && go build ./...` — clean compile.
2. `cd go && go test ./internal/domain/czech/...` — all table cases green.
3. `cd go && go test -race ./...` — race-clean.
4. `cd go && golangci-lint run` (or `make go-lint` from repo root) — clean.
5. **Spot parity** (manual, will be automated in M3): for each Go test
input, run the Python `normalize` via `PYTHONPATH=scripts:. python -c
'...'` and confirm bytes match. Capture the diff in the commit
message if anything surprises.
6. `make go-build && make go-test && make go-lint` from repo root — proves
the existing M1 gate still passes.
## Branching & follow-up
Per [CLAUDE.md](../../CLAUDE.md), this is feature work → branch + Gitea MR:
- Branch: `feat/m2-1-czech-normalize` off `main`.
- Single commit, Co-Authored-By trailer.
- Push with `-u`, print compare URL
`https://gitea.home.hrajfrisbee.cz/kacerr/fuj-management/compare/main...feat/m2-1-czech-normalize`
- User opens/merges the MR.
- After merge: tick `M2.1` in the progress tracker with the commit SHA;
add a one-line CHANGELOG entry; record any porting surprise in the
tracker's "Notes & decisions" section (e.g. the `Mn`-vs-`IsMark`
precision point if it bears noting).
Next task after this lands is **M2.2 `ParseMonthReferences`** — the
larger, edge-case-heavier sibling. Whether to start it before or after
M3.1/M3.2 is a separate decision the user can make then.

View File

@@ -0,0 +1,205 @@
# Plan: Go rewrite — M2.2 `domain/czech.ParseMonthReferences`
## Context
M2.1 (`domain/czech.Normalize`) merged via PR #4 (`d9a61b3`) on
2026-05-05. Per the [progress tracker](2026-05-03-2349-go-backend-rewrite-progress.md),
**M2.2** is next: port `parse_month_references` from
[scripts/czech_utils.py](../../scripts/czech_utils.py) to Go as
`internal/domain/czech.ParseMonthReferences`.
This function is the second-most-load-bearing pure helper after
`reconcile`: every payment-message → month inference goes through it.
Risk #4 in the [parent plan](2026-05-03-2349-go-backend-rewrite.md)
specifically calls out its semantics — wrap-around year inference and
the `m >= 10 → previous year` standalone heuristic — as easy to mis-port.
This plan locks the test table against the live Python implementation
*before* coding, so the Go port has a verified parity baseline even
before the M3.1/M3.2 fixture infrastructure exists.
## Scope
- New file `go/internal/domain/czech/parse_month_references.go` in the
existing `czech` package (alongside [normalize.go](../../go/internal/domain/czech/normalize.go)).
- New file `go/internal/domain/czech/parse_month_references_test.go`
with the test table below.
- **Out of scope:** parity-fixture wiring (M3.1/M3.2); CLI hook-up
(M2.11/M2.12); any consumer call-sites.
- **No new dependencies** — stdlib `regexp`, `sort`, `strconv`, `strings`
plus the existing `czech.Normalize` cover everything.
## Recommended approach
### Python contract to mirror
Three regex passes, all run on `normalize(text)`:
1. `([\d+]+)\s*/\s*(\d{2,4})` — captures `"11+12/2025"`, `"01/26"`, `"1/26"`.
Split the months part on `+`, keep digit-only tokens, validate `1..12`.
Year < 100 → year + 2000.
2. `(\d{1,2})\s*\.\s*(\d{4})` — captures `"12.2025"`. **4-digit year only**
(so `"1.26"` does not match).
3. Czech month names. First the **range** sub-pass:
`(name)\s*-\s*(name)` finds pairs; walk start→end with `m % 12 + 1`,
stopping when `m == end_m`. Wrap rule: if `start_m > end_m`, months
`>= start_m` are `defaultYear - 1`, the rest are `defaultYear`. Both
matched names go into a `foundInRanges` set.
Then the **standalone** sub-pass: `\b(name)\b`, skipping any name in
`foundInRanges`. For each remaining match, `m >= 10 → defaultYear - 1`,
else `defaultYear`.
Output: sorted, deduplicated `[]string` of `"YYYY-MM"`.
### Go signature
```go
package czech
// ParseMonthReferences extracts YYYY-MM month references from Czech
// free text. defaultYear seeds two heuristics: standalone month names
// with m >= 10 are treated as defaultYear-1 (out-of-year backfill), and
// wrap-around ranges (e.g. listopad-leden) place months >= start in
// defaultYear-1.
func ParseMonthReferences(text string, defaultYear int) []string
```
Required `defaultYear` (no default value — Go convention).
### Implementation sketch
```go
var czechMonths = map[string]int{
"leden": 1, "ledna": 1, "lednu": 1,
"unor": 2, "unora": 2, "unoru": 2,
"brezen": 3, "brezna": 3, "breznu": 3,
"duben": 4, "dubna": 4, "dubnu": 4,
"kveten": 5, "kvetna": 5, "kvetnu": 5,
"cerven": 6, "cervna": 6, "cervnu": 6,
"cervenec": 7, "cervnce": 7, "cervenci": 7,
"srpen": 8, "srpna": 8, "srpnu": 8,
"zari": 9,
"rijen": 10, "rijna": 10, "rijnu": 10,
"listopad": 11, "listopadu": 11,
"prosinec": 12, "prosince": 12, "prosinci": 12,
}
// Sorted by descending length at init, so longer alternatives win in
// the regex (e.g. "cervenec" beats "cerven"). Mirrors Python's
// sorted(..., key=len, reverse=True).
var monthNameAlt = buildMonthNameAlt()
var (
numericRe = regexp.MustCompile(`([\d+]+)\s*/\s*(\d{2,4})`)
dotRe = regexp.MustCompile(`(\d{1,2})\s*\.\s*(\d{4})`)
rangeRe = regexp.MustCompile(`(` + monthNameAlt + `)\s*-\s*(` + monthNameAlt + `)`)
standRe = regexp.MustCompile(`\b(` + monthNameAlt + `)\b`)
)
```
Three Go-specific gotchas worth a code comment:
1. **RE2 alternation is leftmost-first**, same as Python `re`. Sorting
month names by descending length is therefore necessary (otherwise
`"cervenec"` matches as `"cerven"` + leftover `"ec"`). Mirror the
Python sort exactly.
2. **Map iteration is randomized in Go.** Build the alternation list
from a sorted slice of keys, not by iterating the map.
3. **`\d` and `\b`** in Go RE2 are ASCII-only, which matches the
effective behavior on `Normalize`'d input (NFKD already collapsed
any Unicode digits/letters that would matter; standalone Devanagari
digits in member messages aren't a real-world concern).
The walk loop uses a bounded counter (max 12 iterations) defensively in
Go; Python's `while True` is fine because every range terminates within
12 hops, but a future reader appreciates the bound.
### Test table (verified against live Python — `default_year=2026`)
Locked outputs from `PYTHONPATH=scripts:. python -c 'from czech_utils
import parse_month_references; print(parse_month_references(<input>, 2026))'`
on 2026-05-05.
| # | Input | Expected | Path exercised |
|---|---|---|---|
| 1 | `""` | `[]` | empty |
| 2 | `"11+12/2025"` | `["2025-11", "2025-12"]` | numeric, plus-split |
| 3 | `"1/2026"` | `["2026-01"]` | numeric, single |
| 4 | `"01/26"` | `["2026-01"]` | 2-digit year normalization |
| 5 | `"11+12/25"` | `["2025-11", "2025-12"]` | plus-split + 2-digit year |
| 6 | `"12+1+2/2026"` | `["2026-01", "2026-02", "2026-12"]` | sorting |
| 7 | `"12.2025"` | `["2025-12"]` | dot pattern |
| 8 | `"1.26"` | `[]` | dot pattern requires 4-digit year |
| 9 | `"leden"` | `["2026-01"]` | standalone, m<10 |
| 10 | `"prosinec"` | `["2025-12"]` | standalone, m≥10 → previous year |
| 11 | `"prosince"` | `["2025-12"]` | declension |
| 12 | `"lednu"` | `["2026-01"]` | declension |
| 13 | `"rijen"` | `["2025-10"]` | m≥10 boundary (10 itself) |
| 14 | `"zari"` | `["2026-09"]` | m<10 just below boundary |
| 15 | `"listopad-leden"` | `["2025-11", "2025-12", "2026-01"]` | wrap range Nov→Jan |
| 16 | `"rijen-leden"` | `["2025-10", "2025-11", "2025-12", "2026-01"]` | wrap from October |
| 17 | `"unor-kveten"` | `["2026-02", "2026-03", "2026-04", "2026-05"]` | non-wrap range |
| 18 | `"leden-leden"` | `["2026-01"]` | degenerate range |
| 19 | `"unor-listopad"` | `["2026-02", ..., "2026-11"]` (10 entries) | range spans m≥10 — heuristic does NOT fire (range exclusion) |
| 20 | `"cervenec-srpen"` | `["2026-07", "2026-08"]` | longest-match alt (`cervenec` not `cerven`+`ec`) |
| 21 | `"listopad-leden, prosinec"` | `["2025-11", "2025-12", "2026-01"]` | range + standalone, dedup |
| 22 | `"prosinec leden"` | `["2025-12", "2026-01"]` | two standalones, no range |
| 23 | `"11+12/2025, leden-brezen"` | `["2025-11", "2025-12", "2026-01", "2026-02", "2026-03"]` | numeric + range mix |
| 24 | `"11+12/25 a listopad"` | `["2025-11", "2025-12"]` | dedup across passes |
| 25 | `"prosince/2025"` | `["2025-12"]` | numeric pattern fails (no digits before `/`); standalone fires |
| 26 | `"listopad-prosinec/2025"` | `["2026-11", "2026-12"]` | range wins; numeric pattern fails |
| 27 | `"01.2026 / 02.2026"` | `["2026-01", "2026-02"]` | dot pattern only; numeric matches `(2026, 02)` but month 2026 is out of range |
| 28 | `"/12/2025"` | `["2025-12"]` | numeric matches at second `/` |
| 29 | `"PROSINEC"` | `["2025-12"]` | normalize lowercases |
| 30 | `"Žluťoučký prosinec"` | `["2025-12"]` | normalize strips diacritics |
| 31 | `"Únor - květen"` | `["2026-02", ..., "2026-05"]` | range tolerates spaces around `-`, diacritics survive normalize |
| 32 | `"platba 11/2025 a leden"` | `["2025-11", "2026-01"]` | mixed natural-language |
| 33 | `"December"` | `[]` | English month names not recognized |
| 34 | `"11+12/2025 11+12/2025"` | `["2025-11", "2025-12"]` | dedup of repeated input |
| 35 | `"leden 2026"` | `["2026-01"]` | trailing year is ignored unless dot/slash separator present |
35 cases is enough to lock semantics; the M3.x corpus will pile on
real-message fixtures later.
### Wire-up
- No `go.mod` changes (stdlib only).
- No CLI changes.
- `Normalize` is in the same package, so call it directly.
## Critical files
- New: [go/internal/domain/czech/parse_month_references.go](../../go/internal/domain/czech/parse_month_references.go)
- New: [go/internal/domain/czech/parse_month_references_test.go](../../go/internal/domain/czech/parse_month_references_test.go)
- Reference (read-only): [scripts/czech_utils.py](../../scripts/czech_utils.py) — the porting source
- Reference (read-only): [docs/plans/2026-05-03-2349-go-backend-rewrite.md](2026-05-03-2349-go-backend-rewrite.md) — risk #4
- Reuses: [go/internal/domain/czech/normalize.go](../../go/internal/domain/czech/normalize.go) — `Normalize` is called once at the top of `ParseMonthReferences`
## Verification
End-to-end checks before marking M2.2 done:
1. `cd go && go build ./...` — clean compile.
2. `cd go && go test ./internal/domain/czech/...` — all 35 table cases green.
3. `cd go && go test -race ./...` — race-clean (regex compiles are global; verify no init races).
4. `cd go && golangci-lint run` (or `make go-lint` from repo root) — clean, gofumpt-formatted.
5. **Spot parity** (manual, will be automated in M3.x): each test input has its expected output captured from the live Python implementation on 2026-05-05; the test table itself is the parity record. If any case diverges during implementation, re-run Python with the exact input to confirm the truth and update either the Go code or the test entry.
6. `make go-build && make go-test && make go-lint` from repo root — proves M1/M2.1 gate still passes.
## Branching & follow-up
Per [CLAUDE.md](../../CLAUDE.md), this is feature work → branch + Gitea MR via `tea`:
- Branch: `feat/m2-2-parse-month-references` off `main`.
- Single focused commit, Co-Authored-By trailer.
- Push with `-u`.
- Open MR with `tea pr create --title "feat(go/M2.2): port czech.ParseMonthReferences" --description ... --base main --head feat/m2-2-parse-month-references`. Print the MR URL for the user.
- User merges/deletes the branch in Gitea — never from the CLI.
After merge (small doc edits land straight on `main` per CLAUDE.md exception):
- Tick `M2.2` in the [progress tracker](2026-05-03-2349-go-backend-rewrite-progress.md) with the merge SHA.
- Add a one-line `CHANGELOG.md` entry (timestamp via `date "+%Y-%m-%d %H:%M %Z"`).
- Record any porting surprise (e.g. an unexpected diff between Go RE2 and Python `re`) in the tracker's "Notes & decisions" section.
Next task is **M2.3 `domain/fees.CalculateFee`** — straightforward constants table; no parser semantics to debate.

View File

@@ -0,0 +1,199 @@
# M2.5 — Port `parse_czk_amount` to `domain/money.ParseCZK`
> On execution, this plan should be moved to
> `docs/plans/2026-05-06-0928-go-m2-5-money-parse-czk.md` per project CLAUDE.md
> (`docs/plans/YYYY-MM-DD-HHMM-<slug>.md`). Plan mode forces it to live under
> `~/.claude/plans/` until then.
## Context
Continuing the Go backend rewrite tracked in
[2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md).
M2.1M2.4 are landed. Next leaf-level pure function is
`parse_czk_amount` from [scripts/infer_payments.py:17-45](../../srv/personal/fuj-management/scripts/infer_payments.py#L17-L45),
the Czech-locale amount parser used at [scripts/infer_payments.py:124](../../srv/personal/fuj-management/scripts/infer_payments.py#L124)
when reading the `Inferred Amount` column out of the payments sheet.
It's a small, isolated string→float helper, but its heuristic for
disambiguating `.` and `,` as decimal vs thousand separator is
non-obvious and needs to behave identically in Go to keep parity once
the Go infer pipeline lands in M4.8.
## Python behaviour (the spec)
```py
def parse_czk_amount(val) -> float:
if val is None or val == "":
return 0.0
if isinstance(val, (int, float)):
return float(val)
val = str(val)
val = val.replace("", "").replace("CZK", "").strip()
if "," in val:
# 1.500,00 -> 1500.00 — comma is decimal sep
val = val.replace(".", "").replace(" ", "").replace(",", ".")
else:
if val.count(".") > 1:
# 1.500.000 -> 1500000 — multiple dots = thousand sep
val = val.replace(".", "").replace(" ", "")
else:
# "1 500.00" -> "1500.00", "1.500" stays "1.500" (= 1.5)
val = val.replace(" ", "")
try:
return float(val)
except ValueError:
return 0.0
```
Key behavioural notes for the Go port:
1. Empty / None → 0, no error.
2. `"1.500"` (single dot, no comma) is parsed as **1.5**, not 1500.
The heuristic intentionally treats a lone dot as decimal.
3. `"1.500,00"` → 1500.0 (comma wins, dots are thousand seps).
4. `"1.500.000"` → 1500000.0 (multiple dots → all thousand seps).
5. `"1 500"` / `"1 500.00"` / `"500 Kč"` → spaces stripped.
6. Garbage → 0, no error in Python.
7. Strips literal substrings `"Kč"` and `"CZK"` (case-sensitive in Python).
## Approach
Create new package `internal/domain/money` mirroring the layout of
`internal/domain/fees` (single-file module + test file alongside).
### Signature
```go
// Package money ports Czech-locale currency parsing from
// scripts/infer_payments.py.
package money
// ParseCZK parses a Czech-locale amount string and returns the value
// in CZK as a float64.
//
// Mirrors scripts/infer_payments.py parse_czk_amount:
// - empty input → (0, nil)
// - "Kč"/"CZK" suffixes are stripped (case-sensitive, like Python)
// - if input contains ",", comma is the decimal separator and
// dots/spaces are thousand separators ("1.500,00" → 1500.0)
// - else if input contains 2+ dots, all dots are thousand seps
// ("1.500.000" → 1500000.0)
// - else single dot stays as the decimal point ("1.500" → 1.5,
// matching the Python heuristic)
// - on parse failure, returns (0, ErrInvalidAmount). Callers wanting
// Python-equivalent silent-zero behaviour can discard the error.
func ParseCZK(s string) (float64, error)
```
`ErrInvalidAmount` is a package-level sentinel:
```go
var ErrInvalidAmount = errors.New("money: invalid CZK amount")
```
Why `(float64, error)` instead of mirroring Python's silent zero:
- Go idiom prefers explicit errors.
- The single Python call site doesn't distinguish parse-fail from
empty-input (both → 0), so if we want byte-equal behaviour at the
Go infer site (M4.8), the caller can `v, _ := money.ParseCZK(s)`
and get exactly the Python result.
- Future callers (e.g. user-facing import flows) may want to surface
the error.
This matches the precedent set in M2.4 where we used
`Expected{Unknown bool}` rather than copying the Python `"?"` sentinel
verbatim — Go-idiomatic surface, parity-preserving semantics.
### Polymorphic input?
Python's `parse_czk_amount` also accepts raw int/float (passed through
unchanged) because Google Sheets API can return numeric cells as
`float64` rather than strings. **Skip this in Go.** The Sheets IO
adapter is M4.2, and that's where the `[]any` → string normalisation
will live. Keeping `ParseCZK` string-only keeps the leaf function tiny.
### Tests
`money_test.go` mirrors the existing `fees_test.go` table-driven style,
including the verification comment showing the Python command used to
confirm each expected value:
```sh
PYTHONPATH=scripts:. python -c '
from infer_payments import parse_czk_amount
for v in [None, "", "0", "500", "500 Kč", "500 CZK",
"1 500", "1500.00", "1 500.00",
"1.500,00", "1500,5", "1.500.000",
"1.500", "abc", " ", "100,5 Kč"]:
print(repr(v), "->", parse_czk_amount(v))
'
```
Cases to cover (all numeric outputs verified against the Python output
of the snippet above):
| input | expected |
|---|---|
| `""` | 0 |
| `"0"` | 0 |
| `"500"` | 500 |
| `"500 Kč"` | 500 |
| `"500 CZK"` | 500 |
| `"1 500"` | 1500 |
| `"1500.00"` | 1500 |
| `"1 500.00"` | 1500 |
| `"1.500,00"` | 1500 |
| `"1500,5"` | 1500.5 |
| `"1.500.000"` | 1500000 |
| `"1.500"` | 1.5 *(heuristic — single dot = decimal)* |
| `"100,5 Kč"` | 100.5 |
| `"abc"` | 0, returns `ErrInvalidAmount` |
| `" "` | 0, returns `ErrInvalidAmount` *(or 0 nil — confirm against Python; trim leaves `""`, then `float("")` raises → Python returns 0; Go test will assert whichever Python actually produces)* |
The `" "` row is the only one that needs the Python verification step
to settle — once verified, lock the behaviour in.
Also add a "documentation example" assertion in the test that
`v, _ := ParseCZK(s)` recovers the Python silent-zero contract for
every garbage input, so we don't lose that property at the Go infer
call site.
## Files to create
- `go/internal/domain/money/money.go` — package + `ParseCZK` + `ErrInvalidAmount`
- `go/internal/domain/money/money_test.go` — table-driven tests
No existing Go files need editing.
## Verification
```sh
cd go && go test ./internal/domain/money/...
make go-lint
make go-build # sanity: nothing else broke
```
Also run the Python snippet from the Tests section above and diff its
output against the test table to confirm parity.
## Out of scope (explicit non-goals)
- Polymorphic `any` input — leave for M4.2 IO adapter.
- Hooking into the Tier-1 parity runner — that comes with M3.5
(`-tags=parity` build constraint). M2.5 just needs unit tests.
- Any callsite migration — `infer_payments.py` keeps using its own
Python function until M4.8.
## Progress tracker + changelog
After the commit lands:
- Tick `M2.5` in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md)
with the commit SHA, mirroring the M2.4 entry style.
- Add a CHANGELOG.md entry at top:
`## YYYY-MM-DD HH:MM TZ — feat(go/M2.5): port domain/money.ParseCZK`.
Branch: `feat/m2-5-money-parse-czk` (per CLAUDE.md branch-per-feature
workflow). Push, open MR via `tea pr create`, leave merge to the user.

View File

@@ -0,0 +1,265 @@
## Context
Continuing the Go backend rewrite tracked in
[2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md).
M2.1M2.5 are landed. Next leaf-level pure function is `generate_sync_id`
from [scripts/sync_fio_to_sheets.py:62-77](../../srv/personal/fuj-management/scripts/sync_fio_to_sheets.py#L62-L77).
It computes a SHA-256 hash over a fixed seven-field projection of a Fio
transaction (`date|amount|currency|sender|vs|message|bank_id`) and is
the deduplication key written into column K (`Sync ID`) of the payments
sheet. The Go port must produce a **byte-identical** digest for the same
transaction; otherwise the Go-side sync (M4.7) would re-append rows
already written by the Python sync, double-counting payments.
The non-trivial part is the `amount` field's string serialisation:
upstream `fio_utils.py` always supplies `amount` as a Python `float`
(API path: `float(val(1) or 0)`; HTML path: `parse_czech_amount(...)`
which returns `float`). Python's `str(float)` produces `"500.0"` for
whole-valued floats; Go's `strconv.FormatFloat(f, 'g', -1, 64)` produces
`"500"`. This is the gotcha called out in the M2.6 line of the progress
tracker.
## Python behaviour (the spec)
```py
def generate_sync_id(tx: dict) -> str:
components = [
str(tx.get("date", "")),
str(tx.get("amount", "")),
str(tx.get("currency", "CZK")),
str(tx.get("sender", "")),
str(tx.get("vs", "")),
str(tx.get("message", "")),
str(tx.get("bank_id", "")),
]
raw_str = "|".join(components).lower()
return hashlib.sha256(raw_str.encode("utf-8")).hexdigest()
```
Behavioural notes for the Go port:
1. **Field order is load-bearing.** `date|amount|currency|sender|vs|message|bank_id` exactly.
2. **Separator is `"|"`.**
3. **Whole string is `.lower()`-ed before hashing** (so e.g. "ABC" sender vs "abc" hash identically). Unicode lower; in practice Fio data is ASCII + Czech diacritics.
4. **`currency` defaults to `"CZK"`** when missing from the dict (HTML scraper path never sets it). Other fields default to `""`.
5. **`amount` is a `float`.** Always. Real Fio data is `500.0`, `1234.56`, etc. — no NaN/Inf, but parity test must pin the format.
6. **Output is `hashlib.sha256(...).hexdigest()`** — 64-char lowercase hex.
7. **Encoding is UTF-8.**
### `str(float)` cases observed in real Fio amounts
| float64 | Python `str(f)` | Go `strconv.FormatFloat(f,'g',-1,64)` | Need |
|---|---|---|---|
| `500.0` | `"500.0"` | `"500"` | append `.0` |
| `1234.56` | `"1234.56"` | `"1234.56"` | matches |
| `0.0` | `"0.0"` | `"0"` | append `.0` |
| `-500.0` | `"-500.0"` | `"-500"` | append `.0` |
| `0.1` | `"0.1"` | `"0.1"` | matches |
| `99999.99` | `"99999.99"` | `"99999.99"` | matches |
For the Fio amount domain (signed CZK, ≤ ~7 digits, ≤2 decimal places),
the rule "`'g'` with prec -1, then append `.0` if result has no `.` and
no `e`/`E`" is exact. We do not need to handle Python's
scientific-notation crossover (`>= 1e16`) for real data, but the
implementation should still cope with it correctly via the same rule.
## Approach
Create new package `internal/domain/synch` mirroring the layout of
`internal/domain/money` (single-file module + test file alongside).
### Package + signature
```go
// Package synch ports the bank-sync deduplication helper from
// scripts/sync_fio_to_sheets.py.
package synch
// Transaction is the projection of a Fio transaction that participates
// in the Sync ID hash. Other fields (ks, ss, sender_account, …) are
// intentionally excluded — they are not part of the Python hash.
//
// Currency: leave "" to inherit the Python default of "CZK" (matches
// the HTML scraper path which omits the key entirely).
type Transaction struct {
Date string
Amount float64
Currency string
Sender string
VS string
Message string
BankID string
}
// GenerateSyncID returns the lowercase SHA-256 hex digest of
// "date|amount|currency|sender|vs|message|bank_id" (lower-cased), used
// as the dedup key in column K of the payments sheet.
//
// Byte-stable with scripts/sync_fio_to_sheets.py generate_sync_id.
func GenerateSyncID(tx Transaction) string
```
### `Currency` default
In Go every struct field is always present, so we lose Python's
"missing key vs empty string" distinction. Real-world data either sets
`currency = "CZK"` (API path) or omits the key (HTML path → `"CZK"`
default). Empty string never occurs in practice. The Go port collapses
the two by treating `Currency == ""` as "use `CZK`":
```go
currency := tx.Currency
if currency == "" {
currency = "CZK"
}
```
This is byte-equal to Python for every input we will ever see in
production, and avoids forcing callers to pass a `*string`.
### Float formatter
Internal helper, unexported:
```go
// formatAmount mimics Python's str(float) for the float values that
// appear in Fio transactions. For mundane decimal amounts the rule
// is: format with 'g' precision -1, then append ".0" if the result
// has no decimal point and no exponent.
func formatAmount(f float64) string {
s := strconv.FormatFloat(f, 'g', -1, 64)
if !strings.ContainsAny(s, ".eE") {
s += ".0"
}
return s
}
```
Tested explicitly (see Tests below) so the edge cases (`0`, whole
numbers, negatives, large/small with exponent) stay locked.
### Hash composition
```go
func GenerateSyncID(tx Transaction) string {
currency := tx.Currency
if currency == "" {
currency = "CZK"
}
raw := strings.ToLower(strings.Join([]string{
tx.Date,
formatAmount(tx.Amount),
currency,
tx.Sender,
tx.VS,
tx.Message,
tx.BankID,
}, "|"))
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
```
(`crypto/sha256` + `encoding/hex` — both stdlib, no `go.mod` change.)
## Tests
`synch_test.go` mirrors `money_test.go`'s table-driven style with the
verification snippet at the top of the function. Two test functions:
### 1. `TestGenerateSyncID`
Each row's expected digest is computed from the Python source:
```sh
PYTHONPATH=scripts:. python -c '
from sync_fio_to_sheets import generate_sync_id
cases = [
{"date":"2026-01-15","amount":500.0,"currency":"CZK","sender":"Jan Novak","vs":"123","message":"clenske 1/2026","bank_id":"abc123"},
{"date":"2026-01-15","amount":500.0,"sender":"Jan Novak","vs":"123","message":"clenske 1/2026","bank_id":"abc123"}, # currency missing → CZK
{"date":"2026-02-10","amount":1234.56,"currency":"CZK","sender":"ABC SRO","vs":"","message":"FAKTURA 42","bank_id":"xyz"}, # mixed case → lowercased
{"date":"2026-03-01","amount":-500.0,"currency":"CZK","sender":"refund","vs":"","message":"","bank_id":""}, # negative
{"date":"2026-04-01","amount":0.0,"currency":"CZK","sender":"","vs":"","message":"","bank_id":""}, # zero amount
{}, # empty dict — every field falls back to default
]
for c in cases:
print(repr(c), "->", generate_sync_id(c))
'
```
Cases (one row per dict above), each asserting the exact 64-char hex
digest the snippet prints. Cover:
- Happy path with all fields set.
- `Currency: ""``"CZK"` default (parity with missing key).
- Mixed-case sender/message → lowercased before hashing.
- Negative amount.
- Zero amount.
- Zero-value `Transaction{}` — every field at Go zero, currency defaults
to `"CZK"`, hash matches Python `generate_sync_id({})`.
### 2. `TestFormatAmount`
Pin the float formatter against Python's `str(float)`:
```sh
PYTHONPATH=scripts:. python -c '
for v in [0.0, 500.0, -500.0, 0.1, 1234.56, 99999.99, 1500000.0, 1e16, 1e-5]:
print(repr(v), "->", repr(str(v)))
'
```
Table of `(float64, expected string)` pairs. Whole numbers must end in
`.0`; existing decimal representations pass through unchanged;
exponent-form floats (`1e16`, `1e-5`) keep their format.
## Files to create
- `go/internal/domain/synch/synch.go` — package, `Transaction`,
`GenerateSyncID`, internal `formatAmount`.
- `go/internal/domain/synch/synch_test.go``TestGenerateSyncID` +
`TestFormatAmount`.
No existing Go files need editing.
## Verification
```sh
cd go && go test ./internal/domain/synch/...
make go-lint
make go-build # sanity: nothing else broke
```
Plus run the two Python snippets in the Tests section and diff their
output against the test tables to confirm parity.
## Out of scope (explicit non-goals)
- **Hooking into the Tier-1 parity runner.** That comes with M3.5
(`-tags=parity` build constraint and `tests/fixtures/pure/`). M2.6
ships with hand-written, Python-verified test tables — same approach
used by M2.1M2.5.
- **A richer `Transaction` struct** covering ks/ss/note/sender_account.
Those fields aren't part of the hash. M4.4 (Fio IO adapter) will
decide whether to reuse `synch.Transaction` or define its own struct
and convert at the boundary.
- **Polymorphic input** (e.g. accepting a `map[string]any`). Python's
duck-typing is a non-goal in Go.
- **Any Python callsite migration.** `sync_fio_to_sheets.py` keeps using
its own `generate_sync_id` until M4.7 ports the sync service.
## Progress tracker + changelog
After the commit lands:
- Tick `M2.6` in
[docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md)
with the commit SHA, mirroring the M2.5 entry style.
- Add a `CHANGELOG.md` entry at top:
`## YYYY-MM-DD HH:MM TZ — feat(go/M2.6): port domain/synch.GenerateSyncID`.
Branch: `feat/m2-6-synch-generate-sync-id` (per CLAUDE.md
branch-per-feature workflow). Push, open MR via `tea pr create`, leave
merge to the user.

View File

@@ -0,0 +1,126 @@
# M2.7 + M2.8 + M2.9 — Port `matching` package to Go
> On approval: copy this plan to `docs/plans/2026-05-06-1305-go-m2-7-2-9-matching.md` per [CLAUDE.md](../../srv/personal/fuj-management/CLAUDE.md) plan-location convention.
## Context
The Go rewrite (tracked in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md)) is in milestone M2 — porting pure-domain helpers leaf-first from Python to Go. M2.1 through M2.6 are complete (`czech.Normalize`, `czech.ParseMonthReferences`, `fees.CalculateFee`, `fees.CalculateJuniorFee`, `money.ParseCZK`, `synch.GenerateSyncID`).
M2.7, M2.8, and M2.9 cover three helpers from [scripts/match_payments.py](../../srv/personal/fuj-management/scripts/match_payments.py) that form a tight chain: `InferTransactionDetails` calls `MatchMembers` which calls `BuildNameVariants` and the same Sheets-serial date logic that `FormatDate` uses. The user requested they be done together because the dependency graph makes per-milestone commits awkward — `MatchMembers` would either reference an unexported helper not yet committed or commit dead code.
This unblocks M2.10 (`reconcile`, the load-bearing function) and M5 parity tests, since reconciliation consumes `InferTransactionDetails` output.
## Approach
**One commit, one branch, one MR.** Branch: `feat/m2-7-2-9-matching-package`. The three milestone checkboxes get ticked together on merge.
### Package layout
New package `go/internal/domain/matching/` mirroring the existing `go/internal/domain/{czech,fees,money,synch}` convention (one file per public symbol, tests alongside as `*_test.go`):
| File | Contents |
|---|---|
| `doc.go` | `// Package matching ports name/member matching from scripts/match_payments.py.` |
| `name_variants.go` | `BuildNameVariants` + unexported `wordIn` helper (mirrors Python's `_word_in` co-location at [match_payments.py:60-62](../../srv/personal/fuj-management/scripts/match_payments.py#L60)) |
| `match_members.go` | `Confidence` typed string + constants, `Match` struct, `MatchMembers` |
| `infer.go` | `Transaction`, `InferredDetails`, `InferTransactionDetails` |
| `format_date.go` | `FormatDate` |
| `name_variants_test.go`, `match_members_test.go`, `infer_test.go`, `format_date_test.go` | table-driven tests, each with a top-of-file comment quoting the live Python one-liner used to verify expected values (mirrors [synch_test.go:7-20](../../srv/personal/fuj-management/go/internal/domain/synch/synch_test.go#L7)) |
### Public API
```go
type Confidence string
const (
ConfidenceAuto Confidence = "auto"
ConfidenceReview Confidence = "review"
)
type Match struct {
Name string
Confidence Confidence
}
func BuildNameVariants(name string) []string
func MatchMembers(text string, memberNames []string) []Match
type Transaction struct {
Sender string
Message string
UserID string
Date any // string | int | float64 — see "Parity concerns"
}
type InferredDetails struct {
Members []Match
Months []string
SearchText string // matches Python's "search_text" key, not the misleading "matched_text" docstring
}
func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear int) InferredDetails
func FormatDate(val any) string
```
### Algorithms (port verbatim — these are the load-bearing details)
**`BuildNameVariants`** ([match_payments.py:33-57](../../srv/personal/fuj-management/scripts/match_payments.py#L33)): extract `(nickname)` regex, strip parens for `base`, normalize via `czech.Normalize`, append last + first when ≥2 parts, **filter <3 chars**. `variants[0]` must always be the full normalized base — `MatchMembers` relies on this.
**`MatchMembers`** ([match_payments.py:65-137](../../srv/personal/fuj-management/scripts/match_payments.py#L65)):
1. **Exact short-circuit** ([:77-84](../../srv/personal/fuj-management/scripts/match_payments.py#L77)): if any member's `variants[0]` whole-word matches in `Normalize(text)`, return ONLY those `(name, auto)`. Prevents nickname `tov` matching inside `ottova`.
2. Otherwise per-member first-match-wins: full-name substring → `\b first \b` AND `\b last \b` (any order) → `\b nickname \b` — each yields `auto` and continues.
3. **Review tier** ([:113-129](../../srv/personal/fuj-management/scripts/match_payments.py#L113)): ≥2-part names → last name `len ≥ 4` AND not in `{"novak","novakova","prach"}` → review; else first name `len ≥ 3` → review. 1-part names → `len ≥ 4` → review.
4. **Final filter** ([:131-137](../../srv/personal/fuj-management/scripts/match_payments.py#L131)): if ANY auto exists, drop ALL review. Two-pass — don't try to fuse with the loop.
**`InferTransactionDetails`** ([match_payments.py:144-184](../../srv/personal/fuj-management/scripts/match_payments.py#L144)): `search_text = sender + " " + message + " " + user_id`; month parse uses `message + " " + user_id` (excludes sender); fallback 1 retries members on sender alone; fallback 2 derives months from `tx.Date` (Sheets serial or `YYYY-MM-DD`).
**`FormatDate`** ([match_payments.py:187-206](../../srv/personal/fuj-management/scripts/match_payments.py#L187)): nil/empty → `""`; int/float → Sheets serial since 1899-12-30 formatted `YYYY-MM-DD`; pre-formatted `YYYY-MM-DD` (length 10, dashes at idx 4/7) → as-is; else `strings.TrimSpace(fmt.Sprint(v))`. **No raise on bad input** — parity contract.
## Parity concerns
- **RE2 `\b`**: Equivalent to Python `\b` on ASCII-folded input (`Normalize` strips diacritics + lowercases). Use `regexp.QuoteMeta` for `re.escape`.
- **Sheets epoch**: 1899-12-30 (NOT 1900-01-01). `time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)`.
- **Fractional serials**: Python `timedelta(days=44197.5)` adds 12 hours, then `.strftime("%Y-%m-%d")` discards time. To match exactly use `base.Add(time.Duration(val * 24 * float64(time.Hour)))` then `Format("2006-01-02")`. **Do NOT** use `base.AddDate(0, 0, int(val))` — that silently drops fractional days from real Sheets exports of timestamped cells.
- **`Transaction.Date any`**: Python `tx["date"]` accepts int/float/string transparently. Sheets API returns serial dates as `float64` from JSON; FIO scraper returns `string`. `any` is the faithful port; type-switch inside `FormatDate` and the date fallback in `InferTransactionDetails`.
- **`SearchText` vs `MatchedText`**: Python docstring says `matched_text`, code returns `"search_text"`. Port the code, not the docstring.
- **Default year plumbing**: Go's `czech.ParseMonthReferences(text, defaultYear)` requires explicit year. Python defaults to 2026. Plumb `defaultYear` as the third arg to `InferTransactionDetails`.
- **Empty slices not nil**: Python `match_members` returns `[]` when nothing matches; ensure Go returns `[]Match{}` not `nil` so consumers don't have to nil-check (matches `synch` package style).
## Tests
Port all 6 cases from [tests/test_match_members.py](../../srv/personal/fuj-management/tests/test_match_members.py) verbatim into `match_members_test.go` as one table-driven `TestMatchMembers`. Each row: `name`, `text`, `wantContains []string`, `wantExcludes []string`, `wantAllAuto bool`.
Add table cases for:
- `BuildNameVariants` — docstring example `František Vrbík (Štrúdl)` → 4 variants; nickname filtered (len<3); single-part name; whitespace inside parens
- `FormatDate``nil``""`, `""``""`, `int(44197)``"2020-12-31"`, `float64(44197.5)``"2020-12-31"`, `"2026-04-15"``"2026-04-15"`, `"garbage"``"garbage"`, `" 2026-04-15 "``"2026-04-15"`
- `InferTransactionDetails` — members from search_text, members from sender fallback, months from date-string fallback, months from serial-date fallback, both-paths-fail returns empty slices
Verify expectations against live Python and quote the one-liner in a top-of-file comment, e.g.:
```
PYTHONPATH=scripts:. python -c '
from match_payments import format_date
for v in [None, "", 44197, 44197.5, "2026-04-15", "garbage", " 2026-04-15 "]: print(repr(format_date(v)))
'
```
## Critical files
- **Read for parity** — [scripts/match_payments.py:33-206](../../srv/personal/fuj-management/scripts/match_payments.py#L33), [tests/test_match_members.py](../../srv/personal/fuj-management/tests/test_match_members.py)
- **Reuse** — `czech.Normalize` ([go/internal/domain/czech/normalize.go](../../srv/personal/fuj-management/go/internal/domain/czech/normalize.go#L15)), `czech.ParseMonthReferences` ([parse_month_references.go:61](../../srv/personal/fuj-management/go/internal/domain/czech/parse_month_references.go#L61))
- **Mirror conventions** — [go/internal/domain/synch/synch.go](../../srv/personal/fuj-management/go/internal/domain/synch/synch.go), [go/internal/domain/synch/synch_test.go](../../srv/personal/fuj-management/go/internal/domain/synch/synch_test.go)
- **New** — `go/internal/domain/matching/{doc,name_variants,match_members,infer,format_date}.go` + `*_test.go`
## Out of scope (M2.10 / M4 territory — DO NOT touch)
- `canonical_member_key` ([match_payments.py:20](../../srv/personal/fuj-management/scripts/match_payments.py#L20))
- `reconcile`, `fetch_sheet_data`, `fetch_exceptions` — M2.10 / M4
- Sheets/Drive/FIO I/O glue
- Fixture capture (`tests/fixtures/pure/`) — M3.3 separately
## Verification
1. `cd go && make go-build` — clean build.
2. `cd go && make go-test ./internal/domain/matching/...` — all table tests green.
3. `cd go && make go-lint` — clean (govet, staticcheck, errcheck, gofumpt, unused).
4. Spot-check: pick 23 random non-trivial cases (e.g. `MatchMembers` with mixed auto/review, `FormatDate(44197.5)`) and run the live Python one-liner from each test's comment block to confirm bytes match.
5. Append CHANGELOG entry per [CLAUDE.md](../../srv/personal/fuj-management/CLAUDE.md) (timestamp via `date "+%Y-%m-%d %H:%M %Z"`).
6. Tick M2.7, M2.8, M2.9 in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) with the merge SHA.
7. Push branch, open MR via `tea pr create --title "feat(go): port matching helpers (M2.7-2.9)" --base main --head feat/m2-7-2-9-matching-package`, print URL, leave merge to user.

View File

@@ -0,0 +1,129 @@
# Include junior members in payment inference roster
## Context
A bank payment from sender `JIŘÍ KUBÍK` with the message
`Jáchym Kubík: 01/2026+03/2026+04/2026` is being inferred as
`[?] Jáchym Hrušák (G)` instead of the obvious `Jáchym Kubík`, even though
the message contains his exact full name.
**Root cause** (confirmed with the user): `Jáchym Kubík` is in the **junior**
attendance sheet only — he does not appear on the main/adults sheet. But
[scripts/infer_payments.py:101-102](scripts/infer_payments.py#L101-L102)
builds `member_names` by calling `get_members_with_fees()`
([scripts/attendance.py:170](scripts/attendance.py#L170)), which reads only
`EXPORT_URL` (the adults sheet). Junior-only members are therefore invisible
to the matcher.
With Kubík absent from `member_names`, the matcher in
[scripts/match_payments.py:65](scripts/match_payments.py#L65) processes the
combined text `jiri kubik jachym kubik: 01/2026+03/2026+04/2026` against an
adults-only roster:
- The exact-full-name short-circuit (`match_payments.py:75-84`) finds nothing —
no adult's full name is in the text.
- Hrušák `(G)` is the only adult with first name `Jáchym`. He fails the
auto-rules (his surname isn't in the text) but hits the partial-first-name
review rule (`match_payments.py:123-125`) → returned as `("Jáchym Hrušák (G)",
"review")`, rendered as `[?] Jáchym Hrušák (G)`.
The user's original framing — "exact match in message should win over
everything" — is already implemented for any candidate that **is** in the
roster (the May-04 short-circuit). The bug is upstream: the right candidate
was never even considered.
**Goal:** make `infer_payments` consider junior members as candidates, so
junior-only names like `Jáchym Kubík` get matched correctly.
## Approach
Single-file change in [scripts/infer_payments.py](scripts/infer_payments.py).
Replace the adults-only roster lookup with a union of the adult and junior
rosters. `attendance.py` already exposes both:
[`get_members_with_fees()`](scripts/attendance.py#L170) for adults (and tier-J
juniors who train with adults) and
[`get_junior_members_with_fees()`](scripts/attendance.py#L208) for everyone in
the junior sheet.
### Edit at [scripts/infer_payments.py:15](scripts/infer_payments.py#L15)
```python
from attendance import get_members_with_fees, get_junior_members_with_fees
```
### Edit at [scripts/infer_payments.py:99-102](scripts/infer_payments.py#L99-L102)
```python
print("Fetching member list for matching...")
adult_members, _ = get_members_with_fees()
junior_members, _ = get_junior_members_with_fees()
# Union rosters, preserving first-seen order, deduping by canonical key
seen: set[str] = set()
member_names: list[str] = []
for m in adult_members + junior_members:
name = m[0]
key = canonical_member_key(name)
if key in seen:
continue
seen.add(key)
member_names.append(name)
```
`canonical_member_key` already lives in
[scripts/match_payments.py:20](scripts/match_payments.py#L20) — import it
alongside `infer_transaction_details`. It normalizes diacritics/case/whitespace,
so `"Maria Maco"` and `"Mária Maco"` collapse to the same key.
### Why downstream reconciliation still works
`reconcile()` is invoked twice per page — once with the adults roster
([app.py:200](app.py#L200)) and once with the juniors roster
([app.py:384](app.py#L384)). Each call resolves the `Person` cell against its
own roster; a junior name resolves cleanly in the juniors call and lands in
"unmatched" in the adults call. That's already the existing behavior for any
junior payment manually entered into the `Person` column, so no further
changes are needed.
### Files to modify
- [scripts/infer_payments.py](scripts/infer_payments.py) — only the
import + roster construction. ~10-line change.
### Files to read for confidence (no edits)
- [scripts/attendance.py:208-289](scripts/attendance.py#L208-L289) —
`get_junior_members_with_fees` returns `(name, tier, …)` tuples just like
the adults version, so `m[0]` works for both.
- [scripts/match_payments.py:65-137](scripts/match_payments.py#L65-L137) —
`match_members` already handles the precedence the user wants (exact full-name
short-circuit), so once Kubík is in `member_names`, the case will be auto-matched
with no `[?]`.
## Verification
1. **Manual sanity** — re-run inference on the offending row:
- Clear `Person`/`Purpose` for the Kubík row in the payments sheet.
- `make infer`.
- Expect `Person = Jáchym Kubík`, `Purpose = 2026-01, 2026-03, 2026-04`,
no `[?]`.
2. **Unit test** — extend
[tests/test_match_members.py](tests/test_match_members.py) (or add a small
`tests/test_infer_payments.py`) to assert that, given a roster that
includes `Jáchym Hrušák (G)` and `Jáchym Kubík`, the message
`Jáchym Kubík: 01/2026+03/2026+04/2026` resolves to
`[("Jáchym Kubík", "auto")]` only. This is really a regression test for
the May-04 short-circuit — the new behavior under test is just that
`infer_payments` now feeds in juniors.
3. **Run the suite**: `make test`.
4. **Dashboard smoke**`make web`, open `/payments`, confirm the row now
shows the correct member; open `/juniors`, confirm the payment is
credited to Kubík for the three months listed.
5. **Changelog** — once the user confirms the fix, append an entry to
[CHANGELOG.md](CHANGELOG.md) per [CLAUDE.md](CLAUDE.md):
`## YYYY-MM-DD HH:MM TZ — fix: include juniors in payment-inference roster`.

View File

@@ -0,0 +1,215 @@
# M2.11 + M2.12 — `fuj fees` and `fuj reconcile` subcommands (stubbed IO)
> On approval: copy this plan to `docs/plans/2026-05-06-1738-go-m2-11-12-fees-reconcile-cli.md` per [CLAUDE.md](../../CLAUDE.md) plan-location convention.
## Context
The Go rewrite (tracked in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md)) finished M2.1M2.10 — every pure-domain helper (`czech`, `fees`, `money`, `synch`, `matching`, `reconcile`) is ported. M2.11 and M2.12 close out the M2 milestone by wiring two CLI subcommands to those helpers.
Both subcommands today are reported as "not implemented" by the dispatcher in [go/cmd/fuj/main.go:32-34](../../go/cmd/fuj/main.go#L32). After this change:
- `fuj fees` will compose `domain/fees` with a (stubbed) attendance loader and a fees-table formatter.
- `fuj reconcile` will compose `domain/reconcile` with stubbed transaction + exception + attendance loaders and a balance-report formatter.
- Both will exit with a clean, actionable error message until M4 wires real Google Sheets IO behind the loader interfaces.
The user asked to do M2.11 and M2.12 together because they share the same loader scaffolding and formatter package — splitting them would either commit half the package or duplicate work.
## Approach
**One commit, one branch, one MR.** Branch: `feat/m2-11-12-fees-reconcile-cli`. Both M2.11 and M2.12 checkboxes get ticked on merge.
The CLI subcommand is the user-facing layer. It owns nothing. All work lives in a new `services/membership` package: a) loader interfaces, b) the stub implementations that fail with a clear error, c) the orchestration functions, and d) the text formatters. M4 will later add real loader implementations behind the same interfaces — no other code needs to change.
### Package layout
| Path | Contents |
|---|---|
| `go/internal/services/membership/doc.go` | Package doc: orchestrates `domain/fees` + `domain/reconcile` against pluggable IO loaders. |
| `go/internal/services/membership/loader.go` | `AttendanceLoader`, `TransactionLoader`, `ExceptionLoader` interfaces. Aggregate `Sources` interface. `ErrIOPending` sentinel. `NewStubSources()` factory returning a struct that satisfies all three with `ErrIOPending`. |
| `go/internal/services/membership/fees.go` | `FeesReport(ctx, AttendanceLoader, io.Writer) error` — loads adults, formats, writes. |
| `go/internal/services/membership/reconcile.go` | `ReconcileReport(ctx, Sources, defaultYear int, io.Writer) error` — loads adults + txns + exceptions, calls `reconcile.Reconcile`, formats, writes. |
| `go/internal/services/membership/format_fees.go` | `printFeesTable(w, members, sortedMonths)` — fixed-width table mirroring [calculate_fees.py:9-49](../../scripts/calculate_fees.py#L9). |
| `go/internal/services/membership/format_reconcile.go` | `printReconcileReport(w, result, sortedMonths)` — header / summary table / credits / debts / unmatched / matched-tx detail mirroring [match_payments.py:521-640](../../scripts/match_payments.py#L521). |
| `go/internal/services/membership/*_test.go` | Table tests for both formatters using fixture data (no IO); separate test confirms `NewStubSources()` returns `ErrIOPending` from each method. |
| `go/cmd/fuj/main.go` | Add `feesCmd(args)` and `reconcileCmd(args)`. Drop `"fees"` and `"reconcile"` from the not-implemented case (leave `"sync"`, `"infer"`). |
### Public API
```go
// loader.go
type AttendanceLoader interface {
LoadAdults(ctx context.Context) (members []reconcile.Member, sortedMonths []string, err error)
}
type TransactionLoader interface {
LoadTransactions(ctx context.Context) ([]reconcile.Transaction, error)
}
type ExceptionLoader interface {
LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error)
}
// Sources is the aggregate that fuj reconcile needs.
type Sources interface {
AttendanceLoader
TransactionLoader
ExceptionLoader
}
// ErrIOPending is returned by every stub loader method.
var ErrIOPending = errors.New(
"io layer not yet wired up; lands in milestone M4 (sheets/drive/fio)")
func NewStubSources() Sources
// fees.go
func FeesReport(ctx context.Context, l AttendanceLoader, out io.Writer) error
// reconcile.go
func ReconcileReport(ctx context.Context, s Sources, defaultYear int, out io.Writer) error
```
### Loader return types
The interfaces return `domain/reconcile` types directly (`reconcile.Member`, `reconcile.Transaction`, `reconcile.ExceptionKey`, `reconcile.Exception`). These are already shaped for the reconcile algorithm and cover the fees report's needs (`Member.Fees[month].Expected` is precomputed by whatever loader populates it — the Python `get_members_with_fees()` does the same). No translation layer needed; no parallel struct hierarchy.
### Stub implementation pattern
```go
type stubSources struct{}
func (stubSources) LoadAdults(context.Context) ([]reconcile.Member, []string, error) {
return nil, nil, ErrIOPending
}
func (stubSources) LoadTransactions(context.Context) ([]reconcile.Transaction, error) {
return nil, ErrIOPending
}
func (stubSources) LoadExceptions(context.Context) (
map[reconcile.ExceptionKey]reconcile.Exception, error) {
return nil, ErrIOPending
}
func NewStubSources() Sources { return stubSources{} }
```
### Subcommand wiring
```go
// cmd/fuj/main.go
case "fees":
feesCmd(args)
case "reconcile":
reconcileCmd(args)
case "sync", "infer": // keep these in the M4 placeholder
fmt.Fprintf(os.Stderr, "fuj %s: not implemented yet (lands in M4)\n", cmd)
os.Exit(2)
func feesCmd(args []string) {
fs := flag.NewFlagSet("fees", flag.ExitOnError)
fs.Usage = func() { fmt.Fprintln(os.Stderr, "usage: fuj fees") }
if err := fs.Parse(args); err != nil { ... }
sources := membership.NewStubSources()
ctx := context.Background()
if err := membership.FeesReport(ctx, sources, os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "fuj fees: %v\n", err)
os.Exit(1)
}
}
func reconcileCmd(args []string) {
fs := flag.NewFlagSet("reconcile", flag.ExitOnError)
fs.Usage = func() { fmt.Fprintln(os.Stderr, "usage: fuj reconcile") }
if err := fs.Parse(args); err != nil { ... }
sources := membership.NewStubSources()
ctx := context.Background()
if err := membership.ReconcileReport(ctx, sources, time.Now().Year(), os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "fuj reconcile: %v\n", err)
os.Exit(1)
}
}
```
Both subcommands accept zero positional args today (matching Python `calculate_fees.py` which has none, and skipping the Python `match_payments.py` flags `--sheet-id` / `--credentials` / `--bank` until M4 needs them — no point pre-empting flag design while there's nothing to plumb them into). Update `usage()` in `main.go` to remove the `[M2]` annotations from `fees` / `reconcile`.
### Formatter ports — what to mirror byte-for-byte
The formatters are pure post-processing. Ship them now so M4 only adds real loader plumbing.
**`printFeesTable`** ports [calculate_fees.py:9-49](../../scripts/calculate_fees.py#L9):
- Filter to `tier == "A"` only.
- Month label format: `"Jan 2026"` (`time.Parse("2006-01", m).Format("Jan 2006")`).
- Column widths: `name_width = max(len(name))`, `col_width = 15`.
- Cell format: `"%d CZK (%d)"` when `count > 0`, else `"-"`. Right-aligned in col_width.
- Totals row: monthly sums, label `"TOTAL"`, cells `"%d CZK"`.
- Print "No data." when members is empty.
**`printReconcileReport`** ports [match_payments.py:521-640](../../scripts/match_payments.py#L521):
- Header banner (`"=" * 80`, "PAYMENT RECONCILIATION REPORT", banner).
- Per-adult summary table — cell logic: `expected==0 && paid==0 → "-"`, `paid>=expected && expected>0 → "OK"`, `paid>0 → "{paid}/{expected}"`, else `"UNPAID {expected}"`. Balance column: `"+N"` / `"-N"` / `"0"`.
- TOTAL footer line carrying the `Expected/Paid/Balance` summary.
- Optional sections: `TOTAL CREDITS` (positive total balances), `TOTAL DEBTS` (negative — print `abs`), `UNMATCHED TRANSACTIONS`, `MATCHED TRANSACTION DETAILS`.
- Use sorted member-name iteration (`sort.Strings`) — Python uses `sorted(adults.keys())`.
- Float printing: amounts use `%.0f` (Python `:.0f`), `paid` is cast via `int(...)` before formatting in some places — preserve these (cast to `int` then `%d`).
Tests use a small handcrafted `reconcile.Result` with one paid member, one debtor, one credit, one unmatched tx and assert exact byte equality of the formatted output (golden string in the test source — not file-based).
### Tests
**`format_fees_test.go`**: handcrafted `[]reconcile.Member` covering: tier-filter (J/X excluded), zero-attendance cell, single-attendance cell, multi-attendance cell, empty result → `"No data."`. Golden output strings inline.
**`format_reconcile_test.go`**: handcrafted `reconcile.Result` exercising every branch in the cell logic + each optional section. Golden strings inline. (Don't blindly copy the Python `print(...)` string-formatting bugs — the live Python f-string `f"\n{'TOTAL CREDITS (advance payments or surplus):'}"` is intentional whitespace; reproduce as plain `"\nTOTAL CREDITS (advance payments or surplus):"` and verify identical bytes by running the live Python on the same fixture.)
**`stub_test.go`**: assert each `NewStubSources()` method returns `ErrIOPending` (use `errors.Is`).
**`fees_test.go` / `reconcile_test.go`**: pass a fake loader that returns canned `[]reconcile.Member` / `[]reconcile.Transaction` / exceptions; assert `FeesReport` / `ReconcileReport` write the expected formatter output. This proves the orchestration glues correctly without involving stubs.
Verify formatter golden strings against live Python with one-liner comments at top of each test file, e.g.:
```
PYTHONPATH=scripts:. python -c '
from match_payments import print_report
result = {"members": {...}, "unmatched": [...]}
print_report(result, ["2026-04"])
'
```
## Parity concerns
- **`czech.Normalize` keeps "%" semantics** — exception keys in `reconcile.Reconcile` use `czech.Normalize(name)` and `czech.Normalize(period)`. The `ExceptionLoader` stub doesn't return any, so this isn't exercised in M2.11/M2.12 — but real loaders in M4 must also normalize at load time (matching Python `fetch_exceptions`).
- **Sorted month iteration** — formatter must respect the `sortedMonths` argument order, not iterate maps directly (Go map iteration is randomized).
- **Sorted member iteration** — adults sorted by name (`sort.Strings`); Python uses `sorted(adults.keys())` which is byte-order. Czech-diacritic names sort by codepoint either way.
- **Empty unmatched list** — Python prints nothing; Go must skip the section header when `len(result.Unmatched) == 0`.
- **`int(paid)` truncation** — Python `int(mdata["paid"])` truncates toward zero. Go `int(float64)` matches. Use `int(paid)` not `math.Round`.
- **Stub error stable string** — `ErrIOPending.Error()` text is part of the user-facing CLI contract for the duration of M2.11..M3; tests assert `errors.Is`, not the string. Don't change the wording without bumping the changelog.
- **Default year = `time.Now().Year()`** — `reconcile.Reconcile` needs `defaultYear` for the inference fallback. `time.Now().Year()` matches the Python implicit default for current-year operation. Tests use a fixed year (2026).
## Critical files
- **Read for parity** — [scripts/calculate_fees.py](../../scripts/calculate_fees.py) (full), [scripts/match_payments.py:521-640](../../scripts/match_payments.py#L521), [scripts/match_payments.py:647-684](../../scripts/match_payments.py#L647) (CLI entry shape).
- **Reuse** — `domain/reconcile.{Member, Transaction, ExceptionKey, Exception, Result, Reconcile}` ([reconcile.go](../../go/internal/domain/reconcile/reconcile.go)), `domain/fees.{CalculateFee, AdultFeeMonthlyRate}` ([fees.go](../../go/internal/domain/fees/fees.go)).
- **Mirror conventions** — package layout from [go/internal/domain/matching/](../../go/internal/domain/matching/) (one symbol per file, `*_test.go` siblings, top-of-test live-Python verification comments).
- **New** — `go/internal/services/membership/{doc,loader,fees,reconcile,format_fees,format_reconcile}.go` + `*_test.go`.
- **Modify** — [go/cmd/fuj/main.go](../../go/cmd/fuj/main.go) (add `feesCmd`/`reconcileCmd`, drop `"fees"`/`"reconcile"` from the M2 not-implemented case, drop `[M2]` annotations from `usage()`).
## Out of scope (explicitly DO NOT touch)
- Real Google Sheets / Drive / Fio loader implementations — M4.1M4.6.
- Web routes / handlers — M5.
- `fuj sync` and `fuj infer` subcommands — M4.7/M4.8.
- Junior fees report — current Python `make fees` only prints adults; preserve. (`get_junior_members_with_fees` is consumed by the web frontend, not the CLI.)
- Bank-direct mode (`--bank` flag in [match_payments.py:659](../../scripts/match_payments.py#L659)) — M4 territory.
- Fixture capture (`tests/fixtures/`) — M3 milestone.
## Verification
1. `cd go && go build ./...` — clean build.
2. `cd go && go test -race ./internal/services/membership/...` — formatter golden strings match, stub returns `ErrIOPending`, orchestration glues fake loader → formatter correctly.
3. `cd go && make go-lint` — clean (govet, staticcheck, errcheck, gofumpt, unused).
4. **End-to-end CLI smoke**:
- `make go-build && ./bin/fuj fees` → exits non-zero, stderr contains `"io layer not yet wired up; lands in milestone M4"`.
- `./bin/fuj reconcile` → same shape.
- `./bin/fuj help` → no longer says `[M2]` next to `fees`/`reconcile`; still says `[M4]` next to `sync`/`infer`.
5. **Formatter parity spot-check** — pick one fixture (1 adult with 2 months of fees, 1 unmatched tx); run the Python equivalent with the same input; confirm Go output is byte-identical (modulo trailing-whitespace lines — `diff -w` if needed, but aim for clean `diff` first).
6. Append CHANGELOG entry per [CLAUDE.md](../../CLAUDE.md) (timestamp via `date "+%Y-%m-%d %H:%M %Z"`).
7. Tick M2.11 and M2.12 in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md) with the merge SHA. Update the M2 milestone summary line if M2 is now fully closed.
8. Push branch, open MR via `tea pr create --title "feat(go): wire fuj fees + fuj reconcile (M2.11-12)" --base main --head feat/m2-11-12-fees-reconcile-cli`, print URL, leave merge to user.

View File

@@ -0,0 +1,261 @@
# M3 — Fixture capture + characterization framework
> On approval: copy this plan to `docs/plans/2026-05-06-2111-go-m3-fixture-capture.md` per [CLAUDE.md](../../srv/personal/fuj-management/CLAUDE.md) plan-location convention.
## Context
The Go rewrite (tracked in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md)) finished M2.1M2.12 — every pure-domain helper is ported and the `fuj fees` / `fuj reconcile` CLIs are wired. M3 closes the loop: it builds the **parity safety net** that proves Go output matches Python output for every ported function. Without it, M2 is "trust me", and the rewrite has no defensible cutover criterion.
M3 has three deliverables:
1. **A capture pipeline** (`scripts/capture_fixtures.py` + `scripts/scrub_fixtures.py`) that produces deterministic, PII-free JSON fixtures from the live Python implementations.
2. **A fixture corpus** at [go/tests/fixtures/](../../srv/personal/fuj-management/go/tests/fixtures/) covering the 10 pure functions of M2 (M2.1M2.9) plus 10 reconcile cases spanning every code path of `reconcile()` (M2.10).
3. **A parity test runner** in [go/tests/parity/](../../srv/personal/fuj-management/go/tests/parity/) under `//go:build parity` that replays each fixture and asserts byte/value equality against the Go port.
User-confirmed scope decisions:
- **Single MR** for all six sub-tasks (M3.1M3.6) — they're tightly coupled; no half-state is committable.
- **Type envelope only where it matters** — four fields (`generate_sync_id.tx.amount`, `parse_czk_amount.val`, `format_date.val`, `infer_transaction_details.tx.date`) use `{"type":..., "value":...}` to disambiguate int/float/none. Everything else uses raw JSON.
- **Real seeds for `parse_month_references` and `match_members` only** — read curated message strings from `tmp/payments_transactions_cache.json`, scrub, ship. Other functions stay on handcrafted seeds.
- **Plan committed at `docs/plans/2026-05-06-2111-go-m3-fixture-capture.md`** — same convention as every M-series predecessor.
## Branch + landing
- Branch: `feat/m3-fixture-capture`. Single MR via `tea pr create`. Tick M3.1M3.6 on merge with the SHA.
- No edits to existing Python or Go production code. M3 is purely additive: new scripts, new fixtures, new test files, new Makefile targets, README, CHANGELOG entry, plan archive, progress tracker tick.
## File layout
**Python (capture pipeline):**
- [scripts/capture_fixtures.py](../../srv/personal/fuj-management/scripts/capture_fixtures.py) — dispatcher CLI; one entry per function via `--func`.
- [scripts/scrub_fixtures.py](../../srv/personal/fuj-management/scripts/scrub_fixtures.py) — stdin→stdout deterministic bijection scrubber.
- [scripts/_fixture_seeds.py](../../srv/personal/fuj-management/scripts/_fixture_seeds.py) — internal: handcrafted seeds keyed by `(func, case_id)`, plus the curated real-message extractor.
**Fixture corpus** (committed, PII-free):
- [go/tests/fixtures/README.md](../../srv/personal/fuj-management/go/tests/fixtures/README.md) — refresh workflow + scrubbing audit guide.
- `go/tests/fixtures/pure/<func>/<case>.json` — one directory per function (10 functions: `normalize`, `parse_month_references`, `calculate_fee`, `calculate_junior_fee`, `parse_czk_amount`, `generate_sync_id`, `build_name_variants`, `match_members`, `infer_transaction_details`, `format_date`).
- `go/tests/fixtures/reconcile/<NN>_<case>.json` — 10 numbered reconcile cases.
**Go parity tests** (all under `//go:build parity`):
- [go/tests/parity/parityio.go](../../srv/personal/fuj-management/go/tests/parity/parityio.go) — shared loader with generic `Case[I,O]` walker, type envelopes mirrored from §3, float tolerance helper.
- [go/tests/parity/pure/<func>/<func>_parity_test.go](../../srv/personal/fuj-management/go/tests/parity/pure/) — one file per function, ~30 lines each.
- [go/tests/parity/reconcile/reconcile_parity_test.go](../../srv/personal/fuj-management/go/tests/parity/reconcile/) — bespoke comparator using `math.Abs(got-want) <= 0.01` for `paid` floats, exact equality on int balances.
**Modified:**
- [Makefile](../../srv/personal/fuj-management/Makefile) — append `go-parity`, `go-test-all`, `capture-fixtures` targets.
- [CHANGELOG.md](../../srv/personal/fuj-management/CHANGELOG.md) — single entry at top.
- [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) — tick M3.1M3.6 with SHA.
## Capture invocation interface
Two-stage pipeline (capture | scrub) so each stage is independently debuggable:
```bash
python scripts/capture_fixtures.py --func <name> --case <id> --input-seed <id> \
| python scripts/scrub_fixtures.py \
> go/tests/fixtures/pure/<func>/<id>.json
```
Capture flags:
- `--func` — target function (`normalize`, `reconcile`, etc.).
- `--case` — human-authored case ID, becomes the file stem. Never auto-generated (auto-IDs cause git churn).
- `--input-seed <id>` — pull from `_fixture_seeds.py` registry (the default mode for handcrafted cases).
- `--input-stdin` — read a single JSON `{"args":[...], "kwargs":{...}}` doc from stdin (used by the real-message extractor for `parse_month_references` / `match_members`).
- `--all` — iterate every seed for one function, emit newline-delimited JSON to stdout. Used by the `make capture-fixtures` recipe.
Capture **never writes files**. Output goes to stdout; the caller redirects. The scrubber is always stdin→stdout. Both are pure transforms.
The `make capture-fixtures` target codifies the full refresh workflow. Humans read the target before they read the README.
## Fixture JSON shape (normative)
One JSON object per case:
```json
{
"case": "range_wrap_nov_to_jan",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": { ... },
"output": { ... }
}
```
`captured_at` is date-only — same-day re-runs produce byte-identical files. No git SHA, no hostname, no time component.
### Per-function input/output schemas
The schema is the **stable contract** between Python capture and Go consumption. Where Python returns heterogeneous types, the capture step pre-translates to the typed shape Go expects.
| Function | Input | Output |
|---|---|---|
| `normalize` | `{"text":"…"}` | `{"text":"…"}` |
| `parse_month_references` | `{"text":"…","default_year":2026}` | `{"months":["2026-01",…]}` |
| `calculate_fee` | `{"attendance_count":3,"month_key":"2026-02"}` | `{"fee":750}` |
| `calculate_junior_fee` | `{"attendance_count":1,"month_key":"2026-02"}` | `{"value":0,"unknown":true}` (mirrors `fees.Expected{Value, Unknown}`) |
| `parse_czk_amount` | `{"val":<envelope>}` | `{"amount":1500.0}` |
| `generate_sync_id` | `{"tx":{"date":"…","amount":<envelope>,"currency":"CZK","sender":"…","vs":"…","message":"…","bank_id":"…"}}` | `{"sync_id":"<sha256-hex>"}` |
| `_build_name_variants` | `{"name":"…"}` | `{"variants":["…"]}` |
| `match_members` | `{"text":"…","member_names":["…"]}` | `{"matches":[{"name":"…","confidence":"auto"}]}` |
| `infer_transaction_details` | `{"tx":{"sender":"…","message":"…","user_id":"…","date":<envelope>},"member_names":[…],"default_year":2026}` | `{"members":[…],"months":[…],"search_text":"…"}` |
| `format_date` | `{"val":<envelope>}` | `{"date":"…"}` |
**Type envelope** (used in 4 fields above):
```json
{"type":"int","value":750} // distinguishes 750 from 750.0
{"type":"float","value":750.0}
{"type":"string","value":"…"}
{"type":"none"}
```
The envelope is the answer to the `generate_sync_id` parity risk: Python's `str(750.0) == "750.0"` vs `str(750) == "750"` produces different SHA-256 inputs. JSON natively conflates these; the envelope round-trips them. Go's loader switches on `type` and constructs the matching native value before calling the port.
**`reconcile`** uses raw JSON for everything (its inputs are typed maps/slices already), with one nuance: the `Member.fees[month]` value can be an `int` or a `(fee, count)` tuple per [match_payments.py:339-340](../../srv/personal/fuj-management/scripts/match_payments.py#L339). Capture normalises both to `{"fee":int,"count":int}` so Go side has one shape.
## Scrubber strategy
`scrub_fixtures.py`: stdin → stdout, no state, no salt, no random. Deterministic plain SHA-256. Re-runs are idempotent. Trade-off acknowledged: an attacker with the script can mathematically reverse the mapping. That's fine — the scrubber's job is to keep PII out of git diffs and Claude transcripts, not to defend against an adversary with the source tree.
### Scramble whitelist (only these field keys are scrambled)
`name`, `member_names[]`, `person`, `sender`, `sender_account`, `account`, `vs`, `bank_id`, `user_id`, `note`. Plus a per-document name-substring sweep over `message` strings — applied **before** the field-key walk, because real names show up embedded in message text.
Everything else (dates, amounts, currency, `month_key`, `attendance_count`, `purpose`, `confidence`, `expected`, `paid`, `total_balance`, `fee`, all `YYYY-MM` keys, `match`/`matches` structure) is preserved verbatim. **Whitelist-of-scramble** (not blacklist-of-preserve): when a new field appears, it stays raw until someone explicitly adds it to the list. Fails safe.
### Scrambling functions
- **Names**: `Member_<8hex>` where `<8hex> = sha256(name).hexdigest()[:8]`. Same name → same pseudonym across the whole document and across all fixtures. Stable diffs.
- **Account numbers** (`[0-9]+/[0-9]{4}`): scramble prefix and bank-suffix separately, preserving length and format.
- **VS / bank_id / user_id**: digit-string-preserving hash to a same-length numeric token. Non-numeric input → `id_<8hex>`.
- **Note**: replaced verbatim with `"<scrubbed>"`. Notes are never load-bearing for any test.
- **Message** (free text): name-sweep applied; rest preserved. Corpus author spot-checks before commit. README §5 documents the audit grep.
## Reconcile fixtures (10 handcrafted cases)
All seeds live in `_fixture_seeds.py` as triples `(members, sorted_months, transactions, exceptions, default_year)`. Capture runs the live Python `reconcile()` and emits canonical JSON; scrubber is a no-op for handcrafted synthetic names but runs anyway for uniformity.
| File | Branch exercised |
|---|---|
| `01_greedy_exact.json` | Greedy: amount == sum(expected); zero credit. |
| `02_greedy_overpayment_credit.json` | Greedy with overflow → credit. |
| `03_proportional_remainder.json` | Underpayment across 3 months with non-integer split (last month absorbs float remainder per [match_payments.py:421+](../../srv/personal/fuj-management/scripts/match_payments.py#L421)). |
| `04_even_split_prepayment.json` | All `expected == 0` → even-split fallback. |
| `05_out_of_window_credit.json` | Month outside `sorted_months` → that share goes to credits, in-window proportional for the rest. |
| `06_exception_override.json` | Exception entry overrides expected. |
| `07_other_purpose_split.json` | `purpose="other:tournament"` with two members. |
| `08_junior_question_mark.json` | Junior with attendance count 1 → `Expected{Unknown:true}`; reconcile reads it as 0 expected. |
| `09_multiperson_multimonth.json` | `person="Alice, Bob", purpose="2026-01, 2026-02"` → 2x2 fan-out: even-split-by-people then proportional-by-month. |
| `10_unmatched.json` | Empty `person`, garbage message → goes to `unmatched`. |
The seed registry is the **single source of truth** for these inputs. If Python behaviour drifts intentionally, fixtures regenerate cleanly via `make capture-fixtures`.
## Real-data seeds (for `parse_month_references` and `match_members` only)
`_fixture_seeds.py` reads `tmp/payments_transactions_cache.json` (already gitignored) and selects:
- **`parse_month_references`**: ~15 distinct messages exercising the 45 Czech month declensions, range wraps (`"prosinec-leden"`), year inference, and the `m >= 10 → previous year` heuristic. Selection done once interactively, the chosen indices hardcoded into `_fixture_seeds.py` so re-runs are deterministic. Messages flow through capture (which calls `parse_month_references(msg, default_year=2026)`) then scrubber (name-sweep against the live member roster).
- **`match_members`**: ~10 distinct `(message, member_names)` pairs exercising auto vs review confidence, common-surname filter, exact-short-circuit. Same pipeline.
**Out of scope for real seeds**: `normalize`, `_build_name_variants`, `reconcile`. These either don't benefit from real data (synthetic exhaustively covers `normalize`, `_build_name_variants`) or have surgical-input requirements that real data can't reliably hit (`reconcile`'s 10 branches).
## Go parity-test layout
One file per function, one Go package per function, mirroring the fixture tree. Each file is short (~30 lines):
```go
//go:build parity
package normalize_parity_test
import (
"fuj-management/go/internal/domain/czech"
"fuj-management/go/tests/parity"
"testing"
)
func TestNormalizeParity(t *testing.T) {
t.Parallel()
parity.RunAll(t, "../../../fixtures/pure/normalize",
func(in parity.NormalizeIn) parity.NormalizeOut {
return parity.NormalizeOut{Text: czech.Normalize(in.Text)}
})
}
```
The shared [go/tests/parity/parityio.go](../../srv/personal/fuj-management/go/tests/parity/parityio.go) (also `//go:build parity`) provides:
- `Case[I, O any]` generic loader: walks a fixture directory, decodes each `.json`, returns `(name, input, want)` triples.
- `RunAll[I, O any](t, dir, fn func(I) O)`: invokes `fn`, compares against `want` with `reflect.DeepEqual` (sorted-slice normalisation for the few sets-cast-to-lists Python returns); for floats uses `math.Abs(got-want) <= 0.01`.
- One typed `<Func>In` / `<Func>Out` struct pair per function (10 pairs), mirroring §3's JSON shape exactly. Envelope decoder helpers (`AmountEnvelope`, `ValueEnvelope`) live here.
**Reconcile is bespoke**`reconcile/reconcile_parity_test.go` doesn't use `RunAll` because it needs cell-by-cell tolerant float compare across nested maps. It walks the fixture dir directly.
**Why one-file-per-function** (instead of an umbrella runner): each function lives in a different domain package, so tests must `import` a different package; an umbrella would obscure which package is being checked. Split also enables `go test -tags=parity ./tests/parity/pure/normalize/` to iterate on a single port.
**Why a separate test tree** (instead of co-located parity tests): the M2 unit tests are co-located by convention (e.g. [go/internal/domain/czech/normalize_test.go](../../srv/personal/fuj-management/go/internal/domain/czech/normalize_test.go)). The progress tracker explicitly says fixtures live at `go/tests/fixtures/` and the gate is `go test -tags=parity ./tests/parity/pure/...`. Co-location would scatter fixtures across packages — messy. Separate tree wins.
## Build tag + Makefile
Every parity test file starts with `//go:build parity`. Default `make go-test` excludes them; `make go-parity` runs them:
```makefile
go-parity:
cd $(GO_SRC) && go test -tags=parity ./tests/parity/...
go-test-all: go-test go-parity
capture-fixtures:
@bash scripts/capture_all_fixtures.sh # invokes capture | scrub for every seed
```
Parity is **not** folded into default `go-test`: keeps the M2 unit-test loop fast, and a missing-fixture failure shouldn't block routine work. CI runs both targets independently so a parity break is a distinct red signal from a unit-test break.
## README content (`go/tests/fixtures/README.md`)
Six sections, ~120 lines:
1. **What's in this tree** — directory map; one line per fixture function explaining what it validates.
2. **Fixture format** — link to schemas in §3; worked example for `parse_month_references` and one for `reconcile`.
3. **Refresh workflow**`make capture-fixtures` regenerates everything; single-file recipe for incremental updates. Always diff before committing.
4. **When to refresh** — bullet list (schema change, new Czech declension, new fee tier, new reconcile branch). **Do not refresh to "fix" a parity failure** without first proving the Python behaviour is the intended one.
5. **Verifying scrubbing**`git diff` should show only `Member_<hex>`-shaped names, `<scrubbed>` notes, structurally-preserved account/VS digits. Audit grep: `git ls-files go/tests/fixtures | xargs grep -l '<your real name>'` should return zero before commit.
6. **Adding a new fixture** — three steps (add to `_fixture_seeds.py`, run capture, add `In/Out` Go struct fields if needed).
## Parity concerns
- **Float arithmetic in reconcile proportional phase**: ordering-sensitive, may diverge between Python and Go due to FMA. Tolerance `0.01` already in [go/internal/domain/reconcile/reconcile_test.go](../../srv/personal/fuj-management/go/internal/domain/reconcile/reconcile_test.go); parity uses the same tolerance.
- **Sync-ID float-vs-int stringification**: handled by the envelope (§3). Capture two paired cases per amount value (`amount_750_int.json`, `amount_750_float.json`) so any Go-side conflation surfaces immediately.
- **NFKD edge cases**: capture set must include rare characters from real names. The handcrafted `normalize` seeds enumerate every distinct character observed in the live member roster (extracted once from `tmp/attendance_regular_cache.json`, hardcoded into `_fixture_seeds.py` as a single-character-per-case sweep).
- **Czech month declensions**: the real-message seeds for `parse_month_references` cover the wild; handcrafted seeds cover the corner cases (`prosinec-leden` wrap, `m >= 10` heuristic).
- **Insertion-order determinism in `reconcile`**: Python 3.7+ dict iteration is insertion-ordered; the seed registry preserves order. Go side iterates `sortedMonths` slice explicitly; the parity test verifies this.
- **`infer_transaction_details` default_year**: Python signature defaults to 2026; capture passes `default_year` as an explicit input. Go side reads it from the fixture.
## Out of scope (explicitly DO NOT touch)
- Real Google Sheets / Drive / Fio loader implementations — M4.1M4.6.
- Web routes / handlers — M5.
- `fuj sync` and `fuj infer` subcommands — M4.7/M4.8.
- Tier-2 JSON-API parity (`cmd/parity/main.go`) — M5.4.
- Any change to existing Python code (capture is read-only against the production scripts).
- Any change to existing Go production code under `go/internal/`.
## Verification
1. `make go-build` — clean build (parity tests excluded by default tag).
2. `make go-test` — all M2 unit tests still green; no parity test runs.
3. `make go-parity` — every fixture in `go/tests/fixtures/pure/` and `go/tests/fixtures/reconcile/` deserialises and passes its parity assertion.
4. `make go-lint` — clean (parity test files lint-clean under `-tags=parity` since `golangci-lint` honours build tags via `.golangci.yml`).
5. **Capture round-trip**: pick one fixture (e.g. `parse_month_references/range_wrap_nov_to_jan.json`), regenerate via `python scripts/capture_fixtures.py --func parse_month_references --case range_wrap_nov_to_jan --input-seed range_wrap_nov_to_jan | python scripts/scrub_fixtures.py`, confirm byte-identical to the committed file.
6. **Scrubbing audit**: run the README §5 grep against any name from the live roster — zero hits.
7. **Reconcile branch coverage**: read each of the 10 reconcile fixture files, confirm the `output` field shows the expected branch (e.g. `02_greedy_overpayment_credit.json` has a non-zero `credits` entry; `04_even_split_prepayment.json` has equal `paid` across all months).
8. Append CHANGELOG entry per [CLAUDE.md](../../srv/personal/fuj-management/CLAUDE.md) (timestamp via `date "+%Y-%m-%d %H:%M %Z"`).
9. Tick M3.1M3.6 in [docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md](../../srv/personal/fuj-management/docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md) with the merge SHA. Update the M3 milestone summary line if M3 is now fully closed.
10. Push branch, open MR via `tea pr create --title "feat(go): fixture capture + characterization framework (M3)" --base main --head feat/m3-fixture-capture`, print URL, leave merge to user.
## Critical files
- **Read for parity** — [scripts/czech_utils.py:22](../../srv/personal/fuj-management/scripts/czech_utils.py#L22), [scripts/czech_utils.py:28](../../srv/personal/fuj-management/scripts/czech_utils.py#L28), [scripts/attendance.py:91](../../srv/personal/fuj-management/scripts/attendance.py#L91), [scripts/attendance.py:100](../../srv/personal/fuj-management/scripts/attendance.py#L100), [scripts/infer_payments.py:17](../../srv/personal/fuj-management/scripts/infer_payments.py#L17), [scripts/sync_fio_to_sheets.py:62](../../srv/personal/fuj-management/scripts/sync_fio_to_sheets.py#L62), [scripts/match_payments.py:33](../../srv/personal/fuj-management/scripts/match_payments.py#L33), [scripts/match_payments.py:65](../../srv/personal/fuj-management/scripts/match_payments.py#L65), [scripts/match_payments.py:144](../../srv/personal/fuj-management/scripts/match_payments.py#L144), [scripts/match_payments.py:187](../../srv/personal/fuj-management/scripts/match_payments.py#L187), [scripts/match_payments.py:304](../../srv/personal/fuj-management/scripts/match_payments.py#L304).
- **Reuse** — `domain/czech.{Normalize, ParseMonthReferences}`, `domain/fees.{CalculateFee, CalculateJuniorFee, Expected}`, `domain/money.ParseCZK`, `domain/synch.GenerateSyncID`, `domain/matching.{BuildNameVariants, MatchMembers, InferTransactionDetails, FormatDate}`, `domain/reconcile.{Member, Transaction, ExceptionKey, Exception, Result, Reconcile}`.
- **Mirror conventions** — package layout from [go/internal/domain/matching/](../../srv/personal/fuj-management/go/internal/domain/matching/) (one symbol per file, top-of-test provenance comments, `t.Parallel()`, `// [Go]` markers for Go-only cases).
- **New** — `scripts/{capture_fixtures,scrub_fixtures,_fixture_seeds}.py`; `go/tests/fixtures/README.md` + the corpus; `go/tests/parity/parityio.go` + 10 parity test files + 1 reconcile parity test file.
- **Modify** — `Makefile` (3 new targets), `CHANGELOG.md` (1 entry), `docs/plans/2026-05-03-2349-go-backend-rewrite-progress.md` (tick M3.1M3.6).

11
go/.golangci.yml Normal file
View File

@@ -0,0 +1,11 @@
linters:
enable:
- govet
- staticcheck
- errcheck
- gofumpt
- unused
linters-settings:
gofumpt:
extra-rules: true

30
go/build/Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG GIT_TAG=unknown
ARG GIT_COMMIT=unknown
ARG BUILD_DATE=unknown
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w \
-X main.version=${GIT_TAG} \
-X main.commit=${GIT_COMMIT} \
-X main.buildDate=${BUILD_DATE}" \
-o /out/fuj ./cmd/fuj
FROM alpine:3
RUN addgroup -S fuj && adduser -S fuj -G fuj
COPY --from=build /out/fuj /usr/local/bin/fuj
EXPOSE 8080
USER fuj
ENTRYPOINT ["/usr/local/bin/fuj", "server"]

125
go/cmd/fuj/main.go Normal file
View File

@@ -0,0 +1,125 @@
package main
import (
"context"
"flag"
"fmt"
"fuj-management/go/internal/config"
"fuj-management/go/internal/logging"
"fuj-management/go/internal/services/membership"
"fuj-management/go/internal/web"
"os"
"time"
)
// Injected at build time via -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=..."
var (
version = "dev"
commit = "unknown"
buildDate = "unknown"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
cmd, args := os.Args[1], os.Args[2:]
switch cmd {
case "server":
serverCmd(args)
case "version":
versionCmd()
case "fees":
feesCmd(args)
case "reconcile":
reconcileCmd(args)
case "sync", "infer":
fmt.Fprintf(os.Stderr, "fuj %s: not implemented yet (lands in M4)\n", cmd)
os.Exit(2)
case "-h", "--help", "help":
usage()
default:
fmt.Fprintf(os.Stderr, "fuj: unknown command %q\n\n", cmd)
usage()
os.Exit(2)
}
}
func serverCmd(args []string) {
fs := flag.NewFlagSet("server", flag.ExitOnError)
addr := fs.String("addr", "", "listen address (default from SERVER_ADDR env or :8080)")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "usage: fuj server [--addr :8080]")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
cfg := config.Load()
if *addr != "" {
cfg.ServerAddr = *addr
}
logger := logging.New(cfg.LogLevel)
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
if err := web.Run(logger, cfg.ServerAddr, build); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func feesCmd(args []string) {
fs := flag.NewFlagSet("fees", flag.ExitOnError)
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "usage: fuj fees")
}
if err := fs.Parse(args); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
sources := membership.NewStubSources()
if err := membership.FeesReport(context.Background(), sources, os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "fuj fees: %v\n", err)
os.Exit(1)
}
}
func reconcileCmd(args []string) {
fs := flag.NewFlagSet("reconcile", flag.ExitOnError)
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "usage: fuj reconcile")
}
if err := fs.Parse(args); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
sources := membership.NewStubSources()
if err := membership.ReconcileReport(context.Background(), sources, time.Now().Year(), os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "fuj reconcile: %v\n", err)
os.Exit(1)
}
}
func versionCmd() {
fmt.Printf("fuj %s (%s) built %s\n", version, commit, buildDate)
}
func usage() {
fmt.Fprintln(os.Stderr, `usage: fuj <command> [flags]
Commands:
server Start HTTP server (default :8080)
version Print version information
fees Calculate monthly fees
reconcile Show balance report
sync Sync Fio transactions [M4]
infer Infer payment details [M4]`)
}

5
go/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module fuj-management/go
go 1.26.1
require golang.org/x/text v0.36.0

2
go/go.sum Normal file
View File

@@ -0,0 +1,2 @@
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=

View File

@@ -0,0 +1,56 @@
package config
import (
"os"
"strconv"
"time"
)
// Google Sheets IDs — change in code if sheets change (not from env).
const (
AttendanceSheetID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA"
PaymentsSheetID = "1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y"
JuniorSheetGID = "1213318614"
)
// Config holds all runtime configuration loaded from environment variables.
// Mirrors scripts/config.py.
type Config struct {
CredentialsPath string
BankAccount string
CacheTTL time.Duration
CacheAPICheckTTL time.Duration
LogLevel string
FioAPIToken string
ServerAddr string
}
// Load reads configuration from the environment, applying defaults that
// match the Python side.
func Load() Config {
return Config{
CredentialsPath: env("CREDENTIALS_PATH", ".secret/fuj-management-bot-credentials.json"),
BankAccount: env("BANK_ACCOUNT", "CZ8520100000002800359168"),
CacheTTL: envDuration("CACHE_TTL_SECONDS", 300),
CacheAPICheckTTL: envDuration("CACHE_API_CHECK_TTL_SECONDS", 300),
LogLevel: env("LOG_LEVEL", "INFO"),
FioAPIToken: env("FIO_API_TOKEN", ""),
ServerAddr: env("SERVER_ADDR", ":8080"),
}
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func envDuration(key string, defaultSeconds int) time.Duration {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
return time.Duration(n) * time.Second
}
}
return time.Duration(defaultSeconds) * time.Second
}

View File

@@ -0,0 +1,26 @@
package czech
import (
"strings"
"unicode"
"golang.org/x/text/unicode/norm"
)
// Normalize strips diacritics and lowercases s.
//
// Matches Python: unicodedata.normalize("NFKD", s) then filter out
// combining characters (unicode.Mn only — not Mc/Me, which have
// combining class 0 in Python's unicodedata.combining()).
func Normalize(s string) string {
decomposed := norm.NFKD.String(s)
var b strings.Builder
b.Grow(len(decomposed))
for _, r := range decomposed {
if unicode.In(r, unicode.Mn) {
continue
}
b.WriteRune(r)
}
return strings.ToLower(b.String())
}

View File

@@ -0,0 +1,31 @@
package czech
import "testing"
func TestNormalize(t *testing.T) {
cases := []struct {
in string
want string
}{
{"Honza", "honza"},
{"žluťoučký", "zlutoucky"},
{"Příliš", "prilis"},
{"Dvořák", "dvorak"},
{"Růžena", "ruzena"},
{"Čeněk", "cenek"},
{"Kačer", "kacer"},
{"", ""},
{"prilis", "prilis"}, // idempotent
{"Jan Novák", "jan novak"}, // whitespace preserved
{"é", "e"}, // precomposed é (NFC)
{"é", "e"}, // decomposed e + combining acute
{"Ondřej Procházka", "ondrej prochazka"}, // realistic full name
}
for _, tc := range cases {
got := Normalize(tc.in)
if got != tc.want {
t.Errorf("Normalize(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}

View File

@@ -0,0 +1,154 @@
package czech
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
)
var czechMonths = map[string]int{
"leden": 1, "ledna": 1, "lednu": 1,
"unor": 2, "unora": 2, "unoru": 2,
"brezen": 3, "brezna": 3, "breznu": 3,
"duben": 4, "dubna": 4, "dubnu": 4,
"kveten": 5, "kvetna": 5, "kvetnu": 5,
"cerven": 6, "cervna": 6, "cervnu": 6,
"cervenec": 7, "cervnce": 7, "cervenci": 7,
"srpen": 8, "srpna": 8, "srpnu": 8,
"zari": 9,
"rijen": 10, "rijna": 10, "rijnu": 10,
"listopad": 11, "listopadu": 11,
"prosinec": 12, "prosince": 12, "prosinci": 12,
}
var (
numericRe *regexp.Regexp
dotRe *regexp.Regexp
rangeRe *regexp.Regexp
standRe *regexp.Regexp
)
func init() {
// Sort by descending length so longer alternatives win in RE2 leftmost-first
// matching (e.g. "cervenec" is tried before "cerven").
names := make([]string, 0, len(czechMonths))
for name := range czechMonths {
names = append(names, name)
}
sort.Slice(names, func(i, j int) bool {
if len(names[i]) != len(names[j]) {
return len(names[i]) > len(names[j])
}
return names[i] < names[j]
})
alt := strings.Join(names, "|")
numericRe = regexp.MustCompile(`([\d+]+)\s*/\s*(\d{2,4})`)
dotRe = regexp.MustCompile(`(\d{1,2})\s*\.\s*(\d{4})`)
rangeRe = regexp.MustCompile(`(` + alt + `)\s*-\s*(` + alt + `)`)
standRe = regexp.MustCompile(`\b(` + alt + `)\b`)
}
// ParseMonthReferences extracts YYYY-MM month references from Czech free text.
//
// defaultYear seeds two heuristics: standalone month names with m >= 10 are
// treated as defaultYear-1 (out-of-year backfill), and wrap-around ranges
// (e.g. listopad-leden) place months >= start_m in defaultYear-1.
//
// Returns a sorted, deduplicated slice of "YYYY-MM" strings.
func ParseMonthReferences(text string, defaultYear int) []string {
normalized := Normalize(text)
seen := map[string]struct{}{}
add := func(year, m int) {
if m >= 1 && m <= 12 {
seen[fmt.Sprintf("%04d-%02d", year, m)] = struct{}{}
}
}
// Pass 1: numeric months — "11+12/2025", "01/26", "1/2026"
for _, groups := range numericRe.FindAllStringSubmatch(normalized, -1) {
monthsPart, yearStr := groups[1], groups[2]
year, err := strconv.Atoi(yearStr)
if err != nil {
continue
}
if year < 100 {
year += 2000
}
for mStr := range strings.SplitSeq(monthsPart, "+") {
mStr = strings.TrimSpace(mStr)
if mStr == "" {
continue
}
allDigits := true
for _, c := range mStr {
if c < '0' || c > '9' {
allDigits = false
break
}
}
if !allDigits {
continue
}
m, err := strconv.Atoi(mStr)
if err != nil {
continue
}
add(year, m)
}
}
// Pass 2: dot-separated month.year — "12.2025" (4-digit year only)
for _, groups := range dotRe.FindAllStringSubmatch(normalized, -1) {
m, _ := strconv.Atoi(groups[1])
year, _ := strconv.Atoi(groups[2])
add(year, m)
}
// Pass 3a: Czech month name ranges — "listopad-leden"
foundInRanges := map[string]struct{}{}
for _, groups := range rangeRe.FindAllStringSubmatch(normalized, -1) {
startName, endName := groups[1], groups[2]
foundInRanges[startName] = struct{}{}
foundInRanges[endName] = struct{}{}
startM := czechMonths[startName]
endM := czechMonths[endName]
wraps := startM > endM
m := startM
for range 12 {
year := defaultYear
if wraps && m >= startM {
year = defaultYear - 1
}
add(year, m)
if m == endM {
break
}
m = m%12 + 1
}
}
// Pass 3b: standalone Czech month names (not part of a range)
for _, groups := range standRe.FindAllStringSubmatch(normalized, -1) {
name := groups[1]
if _, inRange := foundInRanges[name]; inRange {
continue
}
m := czechMonths[name]
year := defaultYear
if m >= 10 {
year = defaultYear - 1
}
add(year, m)
}
result := make([]string, 0, len(seen))
for k := range seen {
result = append(result, k)
}
sort.Strings(result)
return result
}

View File

@@ -0,0 +1,244 @@
package czech
import (
"reflect"
"testing"
)
func TestParseMonthReferences(t *testing.T) {
t.Parallel()
// All expected outputs verified against live Python implementation on 2026-05-05:
// PYTHONPATH=scripts:. python -c 'from czech_utils import parse_month_references; print(parse_month_references("<input>", 2026))'
tests := []struct {
name string
input string
defaultYear int
want []string
}{
{
name: "empty",
input: "",
defaultYear: 2026,
want: []string{},
},
{
name: "numeric plus-split two months full year",
input: "11+12/2025",
defaultYear: 2026,
want: []string{"2025-11", "2025-12"},
},
{
name: "numeric single month full year",
input: "1/2026",
defaultYear: 2026,
want: []string{"2026-01"},
},
{
name: "numeric 2-digit year",
input: "01/26",
defaultYear: 2026,
want: []string{"2026-01"},
},
{
name: "numeric plus-split with 2-digit year",
input: "11+12/25",
defaultYear: 2026,
want: []string{"2025-11", "2025-12"},
},
{
name: "numeric three months sorted",
input: "12+1+2/2026",
defaultYear: 2026,
want: []string{"2026-01", "2026-02", "2026-12"},
},
{
name: "dot pattern",
input: "12.2025",
defaultYear: 2026,
want: []string{"2025-12"},
},
{
name: "dot pattern requires 4-digit year",
input: "1.26",
defaultYear: 2026,
want: []string{},
},
{
name: "standalone month below m10 threshold",
input: "leden",
defaultYear: 2026,
want: []string{"2026-01"},
},
{
name: "standalone month m10 heuristic",
input: "prosinec",
defaultYear: 2026,
want: []string{"2025-12"},
},
{
name: "declension prosince",
input: "prosince",
defaultYear: 2026,
want: []string{"2025-12"},
},
{
name: "declension lednu",
input: "lednu",
defaultYear: 2026,
want: []string{"2026-01"},
},
{
name: "standalone m10 boundary (rijen = October)",
input: "rijen",
defaultYear: 2026,
want: []string{"2025-10"},
},
{
name: "standalone m9 just below boundary (zari = September)",
input: "zari",
defaultYear: 2026,
want: []string{"2026-09"},
},
{
name: "range wrap Nov-Jan",
input: "listopad-leden",
defaultYear: 2026,
want: []string{"2025-11", "2025-12", "2026-01"},
},
{
name: "range wrap starting at October",
input: "rijen-leden",
defaultYear: 2026,
want: []string{"2025-10", "2025-11", "2025-12", "2026-01"},
},
{
name: "range no wrap",
input: "unor-kveten",
defaultYear: 2026,
want: []string{"2026-02", "2026-03", "2026-04", "2026-05"},
},
{
name: "degenerate range same month",
input: "leden-leden",
defaultYear: 2026,
want: []string{"2026-01"},
},
{
name: "range spanning m10 — heuristic does NOT fire for range members",
input: "unor-listopad",
defaultYear: 2026,
want: []string{"2026-02", "2026-03", "2026-04", "2026-05", "2026-06", "2026-07", "2026-08", "2026-09", "2026-10", "2026-11"},
},
{
name: "longest-match alternation cervenec beats cerven",
input: "cervenec-srpen",
defaultYear: 2026,
want: []string{"2026-07", "2026-08"},
},
{
name: "range plus standalone — range excludes, dedup",
input: "listopad-leden, prosinec",
defaultYear: 2026,
want: []string{"2025-11", "2025-12", "2026-01"},
},
{
name: "two standalones no range",
input: "prosinec leden",
defaultYear: 2026,
want: []string{"2025-12", "2026-01"},
},
{
name: "numeric plus range mix",
input: "11+12/2025, leden-brezen",
defaultYear: 2026,
want: []string{"2025-11", "2025-12", "2026-01", "2026-02", "2026-03"},
},
{
name: "dedup across numeric and standalone passes",
input: "11+12/25 a listopad",
defaultYear: 2026,
want: []string{"2025-11", "2025-12"},
},
{
name: "no digits before slash — standalone fires instead",
input: "prosince/2025",
defaultYear: 2026,
want: []string{"2025-12"},
},
{
name: "range with trailing slash-year — numeric fails, range wins",
input: "listopad-prosinec/2025",
defaultYear: 2026,
want: []string{"2026-11", "2026-12"},
},
{
name: "dot pattern only — numeric matches but month out of 1-12 range",
input: "01.2026 / 02.2026",
defaultYear: 2026,
want: []string{"2026-01", "2026-02"},
},
{
name: "leading slash — numeric matches at second slash",
input: "/12/2025",
defaultYear: 2026,
want: []string{"2025-12"},
},
{
name: "uppercase input normalized",
input: "PROSINEC",
defaultYear: 2026,
want: []string{"2025-12"},
},
{
name: "diacritics stripped by Normalize",
input: "Žluťoučký prosinec",
defaultYear: 2026,
want: []string{"2025-12"},
},
{
name: "diacritics in range with spaces around dash",
input: "Únor - květen",
defaultYear: 2026,
want: []string{"2026-02", "2026-03", "2026-04", "2026-05"},
},
{
name: "natural language mixed with numeric and standalone",
input: "platba 11/2025 a leden",
defaultYear: 2026,
want: []string{"2025-11", "2026-01"},
},
{
name: "English month name not recognized",
input: "December",
defaultYear: 2026,
want: []string{},
},
{
name: "duplicate input deduped",
input: "11+12/2025 11+12/2025",
defaultYear: 2026,
want: []string{"2025-11", "2025-12"},
},
{
name: "trailing year without separator ignored",
input: "leden 2026",
defaultYear: 2026,
want: []string{"2026-01"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := ParseMonthReferences(tc.input, tc.defaultYear)
if got == nil {
got = []string{}
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("ParseMonthReferences(%q, %d)\n got %v\n want %v",
tc.input, tc.defaultYear, got, tc.want)
}
})
}
}

View File

@@ -0,0 +1,34 @@
// Package fees ports fee calculation from scripts/attendance.py.
package fees
const (
AdultFeeDefault = 700 // CZK fallback for 2+ practices when month not in AdultFeeMonthlyRate
AdultFeeSingle = 200 // CZK for exactly 1 practice
)
// AdultFeeMonthlyRate mirrors ADULT_FEE_MONTHLY_RATE in scripts/attendance.py.
// Months absent from this map fall back to AdultFeeDefault.
var AdultFeeMonthlyRate = map[string]int{
"2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750,
"2026-01": 750, "2026-02": 750, "2026-03": 350,
"2026-04": 700, "2026-05": 700,
}
// CalculateFee returns the adult fee in CZK for attendanceCount practices in
// the given monthKey (format "YYYY-MM").
//
// 0 practices → 0
// 1 practice → AdultFeeSingle (200)
// 2+ → AdultFeeMonthlyRate[monthKey] or AdultFeeDefault
func CalculateFee(attendanceCount int, monthKey string) int {
if attendanceCount == 0 {
return 0
}
if attendanceCount == 1 {
return AdultFeeSingle
}
if rate, ok := AdultFeeMonthlyRate[monthKey]; ok {
return rate
}
return AdultFeeDefault
}

View File

@@ -0,0 +1,37 @@
package fees
import "testing"
func TestCalculateFee(t *testing.T) {
t.Parallel()
// All expected outputs verified against live Python implementation on 2026-05-06:
// PYTHONPATH=scripts:. python -c 'from attendance import calculate_fee; print([calculate_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2026-03"),(2,"2025-09"),(5,"2026-05"),(2,"2027-01"),(2,"")]])'
tests := []struct {
name string
count int
month string
want int
}{
{"zero short-circuits", 0, "2026-05", 0},
{"zero empty month", 0, "", 0},
{"single practice", 1, "2026-05", 200},
{"single ignores monthKey", 1, "unknown", 200},
{"two practices configured month", 2, "2026-05", 700},
{"two practices reduced march", 2, "2026-03", 350},
{"two practices early season", 2, "2025-09", 750},
{"high count same as two", 5, "2026-05", 700},
{"unknown future month falls back", 2, "2027-01", 700},
{"empty month falls back", 2, "", 700},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := CalculateFee(tc.count, tc.month)
if got != tc.want {
t.Errorf("CalculateFee(%d, %q) = %d, want %d", tc.count, tc.month, got, tc.want)
}
})
}
}

View File

@@ -0,0 +1,37 @@
package fees
const JuniorFeeDefault = 500 // CZK fallback for 2+ practices when month not in JuniorFeeMonthlyRate
// JuniorFeeMonthlyRate mirrors JUNIOR_MONTHLY_RATE in scripts/attendance.py.
// Months absent from this map fall back to JuniorFeeDefault.
var JuniorFeeMonthlyRate = map[string]int{
"2025-09": 250,
"2026-03": 250,
}
// Expected is the result of a junior fee calculation.
// When Unknown is true the fee requires manual review (Python returns "?");
// in that case Value is meaningless — always check Unknown first.
type Expected struct {
Value int
Unknown bool
}
// CalculateJuniorFee returns the junior fee for attendanceCount practices in
// the given monthKey (format "YYYY-MM").
//
// 0 practices → Expected{Value: 0}
// 1 practice → Expected{Unknown: true} (manual review; Python sentinel "?")
// 2+ → Expected{Value: JuniorFeeMonthlyRate[monthKey] or JuniorFeeDefault}
func CalculateJuniorFee(attendanceCount int, monthKey string) Expected {
if attendanceCount == 0 {
return Expected{Value: 0}
}
if attendanceCount == 1 {
return Expected{Unknown: true}
}
if rate, ok := JuniorFeeMonthlyRate[monthKey]; ok {
return Expected{Value: rate}
}
return Expected{Value: JuniorFeeDefault}
}

View File

@@ -0,0 +1,37 @@
package fees
import "testing"
func TestCalculateJuniorFee(t *testing.T) {
t.Parallel()
// All expected outputs verified against live Python implementation on 2026-05-06:
// PYTHONPATH=scripts:. python -c 'from attendance import calculate_junior_fee; print([calculate_junior_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2025-09"),(2,"2026-03"),(5,"2025-09"),(2,"2027-01"),(2,"")]])'
tests := []struct {
name string
count int
month string
want Expected
}{
{"zero short-circuits", 0, "2026-05", Expected{Value: 0}},
{"zero empty month", 0, "", Expected{Value: 0}},
{"single practice sentinel", 1, "2026-05", Expected{Unknown: true}},
{"single ignores monthKey", 1, "unknown", Expected{Unknown: true}},
{"two practices default month", 2, "2026-05", Expected{Value: 500}},
{"two practices reduced sept", 2, "2025-09", Expected{Value: 250}},
{"two practices reduced march", 2, "2026-03", Expected{Value: 250}},
{"high count same as two", 5, "2025-09", Expected{Value: 250}},
{"unknown future month falls back", 2, "2027-01", Expected{Value: 500}},
{"empty month falls back", 2, "", Expected{Value: 500}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := CalculateJuniorFee(tc.count, tc.month)
if got != tc.want {
t.Errorf("CalculateJuniorFee(%d, %q) = %+v, want %+v", tc.count, tc.month, got, tc.want)
}
})
}
}

View File

@@ -0,0 +1,2 @@
// Package matching ports name/member matching from scripts/match_payments.py.
package matching

View File

@@ -0,0 +1,41 @@
package matching
import (
"fmt"
"strings"
"time"
)
var sheetsEpoch = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)
// FormatDate normalizes a date value from Google Sheets.
//
// Accepts nil, empty string, int/float64 Sheets serial days since 1899-12-30,
// a pre-formatted "YYYY-MM-DD" string (returned as-is), or any other value
// (returned as fmt.Sprint(v).TrimSpace). Never returns an error.
//
// Ports scripts/match_payments.py format_date.
func FormatDate(val any) string {
if val == nil {
return ""
}
switch v := val.(type) {
case int:
return sheetsEpoch.Add(time.Duration(float64(v) * 24 * float64(time.Hour))).Format("2006-01-02")
case int64:
return sheetsEpoch.Add(time.Duration(float64(v) * 24 * float64(time.Hour))).Format("2006-01-02")
case float64:
return sheetsEpoch.Add(time.Duration(v * 24 * float64(time.Hour))).Format("2006-01-02")
case string:
s := strings.TrimSpace(v)
if s == "" {
return ""
}
if len(s) == 10 && s[4] == '-' && s[7] == '-' {
return s
}
return s
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}

View File

@@ -0,0 +1,49 @@
package matching
// Expected values verified against scripts/match_payments.py on 2026-05-06:
//
// PYTHONPATH=scripts:. python3 -c '
// from match_payments import format_date
// for v in [None, "", 44197, 44197.5, "2026-04-15", "garbage", " 2026-04-15 "]:
// print(repr(format_date(v)))
// '
//
// Output:
//
// ''
// ''
// '2021-01-01'
// '2021-01-01'
// '2026-04-15'
// 'garbage'
// '2026-04-15'
import "testing"
func TestFormatDate(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input any
want string
}{
{name: "nil", input: nil, want: ""},
{name: "empty string", input: "", want: ""},
{name: "serial int", input: int(44197), want: "2021-01-01"},
{name: "serial float fractional", input: float64(44197.5), want: "2021-01-01"},
{name: "already formatted", input: "2026-04-15", want: "2026-04-15"},
{name: "garbage string", input: "garbage", want: "garbage"},
{name: "padded date string trimmed", input: " 2026-04-15 ", want: "2026-04-15"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := FormatDate(tc.input)
if got != tc.want {
t.Errorf("FormatDate(%v) = %q, want %q", tc.input, got, tc.want)
}
})
}
}

View File

@@ -0,0 +1,89 @@
package matching
import (
"fmt"
"fuj-management/go/internal/domain/czech"
"time"
)
// Transaction is the subset of a payment row used by InferTransactionDetails.
// Date accepts string ("YYYY-MM-DD"), float64 (Sheets serial), or int — matching
// the heterogeneous types returned by the Sheets API and the FIO scraper.
type Transaction struct {
Sender string
Message string
UserID string
Date any
}
// InferredDetails is the result of InferTransactionDetails.
type InferredDetails struct {
Members []Match
Months []string
SearchText string
}
// InferTransactionDetails infers which member(s) and month(s) a transaction belongs to.
//
// Search text for member matching: sender + message + user_id.
// Month search text: message + user_id only (sender excluded, matching Python).
// Fallback 1: if no members found, retry match on sender alone.
// Fallback 2: if no months found, derive from tx.Date (Sheets serial or YYYY-MM-DD).
//
// defaultYear seeds czech.ParseMonthReferences (Python defaulted to the current year;
// callers should pass time.Now().Year() or a fixed year for deterministic tests).
//
// Ports scripts/match_payments.py infer_transaction_details.
func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear int) InferredDetails {
searchText := fmt.Sprintf("%s %s %s", tx.Sender, tx.Message, tx.UserID)
members := MatchMembers(searchText, memberNames)
months := czech.ParseMonthReferences(tx.Message+" "+tx.UserID, defaultYear)
if len(members) == 0 {
members = MatchMembers(tx.Sender, memberNames)
}
if len(months) == 0 && tx.Date != nil && tx.Date != "" {
if ym := inferMonthFromDate(tx.Date); ym != "" {
months = []string{ym}
}
}
if months == nil {
months = []string{}
}
return InferredDetails{
Members: members,
Months: months,
SearchText: searchText,
}
}
// inferMonthFromDate converts a date value to "YYYY-MM" for the month fallback.
// Returns "" on any error, matching Python's bare except pass.
func inferMonthFromDate(val any) string {
switch v := val.(type) {
case int:
dt := sheetsEpoch.Add(time.Duration(float64(v) * 24 * float64(time.Hour)))
return dt.Format("2006-01")
case int64:
dt := sheetsEpoch.Add(time.Duration(float64(v) * 24 * float64(time.Hour)))
return dt.Format("2006-01")
case float64:
dt := sheetsEpoch.Add(time.Duration(v * 24 * float64(time.Hour)))
return dt.Format("2006-01")
case string:
if v == "" {
return ""
}
dt, err := time.Parse("2006-01-02", v)
if err != nil {
return ""
}
return dt.Format("2006-01")
default:
return ""
}
}

View File

@@ -0,0 +1,108 @@
package matching
// Expected values verified against scripts/match_payments.py on 2026-05-06:
//
// PYTHONPATH=scripts:. python3 << 'EOF'
// from match_payments import infer_transaction_details
// MEMBERS = ["Tomáš Němeček (Tov)", "Jana Nováková"]
// cases = [
// ({"sender":"Tomas Nemecek","message":"clenske 04/2026","user_id":"","date":"2026-04-15"}, "full match"),
// ({"sender":"Tomas Nemecek","message":"","user_id":"","date":"2026-04-15"}, "sender fallback month"),
// ({"sender":"Jana Novakova","message":"","user_id":"","date":44197}, "serial int date"),
// ({"sender":"neznamy","message":"","user_id":"","date":""}, "no match"),
// ({"sender":"Tomas Nemecek","message":"","user_id":"","date":44197.5}, "serial float date"),
// ]
// for tx, label in cases:
// r = infer_transaction_details(tx, MEMBERS)
// print(label + ": members=" + repr(r["members"]) + " months=" + repr(r["months"]) + " search_text=" + repr(r["search_text"]))
// EOF
//
// Output:
//
// full match: members=[('Tomáš Němeček (Tov)', 'auto')] months=['2026-04'] search_text='Tomas Nemecek clenske 04/2026 '
// sender fallback month: members=[('Tomáš Němeček (Tov)', 'auto')] months=['2026-04'] search_text='Tomas Nemecek '
// serial int date: members=[('Jana Nováková', 'auto')] months=['2021-01'] search_text='Jana Novakova '
// no match: members=[] months=[] search_text='neznamy '
// serial float date: members=[('Tomáš Němeček (Tov)', 'auto')] months=['2021-01'] search_text='Tomas Nemecek '
import (
"reflect"
"testing"
)
var inferMembers = []string{"Tomáš Němeček (Tov)", "Jana Nováková"}
func TestInferTransactionDetails(t *testing.T) {
t.Parallel()
cases := []struct {
name string
tx Transaction
defaultYear int
wantMembers []Match
wantMonths []string
wantSearchText string
}{
{
name: "full match — members and months from search text",
tx: Transaction{Sender: "Tomas Nemecek", Message: "clenske 04/2026", UserID: "", Date: "2026-04-15"},
defaultYear: 2026,
wantMembers: []Match{{Name: "Tomáš Němeček (Tov)", Confidence: ConfidenceAuto}},
wantMonths: []string{"2026-04"},
// Python: sender + " " + message + " " + user_id (no trim)
wantSearchText: "Tomas Nemecek clenske 04/2026 ",
},
{
// months not in message → fall back to date string
name: "months fall back to date string",
tx: Transaction{Sender: "Tomas Nemecek", Message: "", UserID: "", Date: "2026-04-15"},
defaultYear: 2026,
wantMembers: []Match{{Name: "Tomáš Němeček (Tov)", Confidence: ConfidenceAuto}},
wantMonths: []string{"2026-04"},
wantSearchText: "Tomas Nemecek ",
},
{
// months fall back to Sheets serial int date
name: "months fall back to serial int date",
tx: Transaction{Sender: "Jana Novakova", Message: "", UserID: "", Date: int(44197)},
defaultYear: 2026,
wantMembers: []Match{{Name: "Jana Nováková", Confidence: ConfidenceAuto}},
wantMonths: []string{"2021-01"},
wantSearchText: "Jana Novakova ",
},
{
// months fall back to Sheets serial float64 date
name: "months fall back to serial float date",
tx: Transaction{Sender: "Tomas Nemecek", Message: "", UserID: "", Date: float64(44197.5)},
defaultYear: 2026,
wantMembers: []Match{{Name: "Tomáš Němeček (Tov)", Confidence: ConfidenceAuto}},
wantMonths: []string{"2021-01"},
wantSearchText: "Tomas Nemecek ",
},
{
name: "no match — both slices empty not nil",
tx: Transaction{Sender: "neznamy", Message: "", UserID: "", Date: ""},
defaultYear: 2026,
wantMembers: []Match{},
wantMonths: []string{},
wantSearchText: "neznamy ",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := InferTransactionDetails(tc.tx, inferMembers, tc.defaultYear)
if !reflect.DeepEqual(got.Members, tc.wantMembers) {
t.Errorf("Members\n got %v\n want %v", got.Members, tc.wantMembers)
}
if !reflect.DeepEqual(got.Months, tc.wantMonths) {
t.Errorf("Months\n got %v\n want %v", got.Months, tc.wantMonths)
}
if got.SearchText != tc.wantSearchText {
t.Errorf("SearchText\n got %q\n want %q", got.SearchText, tc.wantSearchText)
}
})
}
}

View File

@@ -0,0 +1,131 @@
package matching
import (
"fuj-management/go/internal/domain/czech"
"strings"
)
// Confidence indicates how certain a member match is.
type Confidence string
const (
ConfidenceAuto Confidence = "auto"
ConfidenceReview Confidence = "review"
)
// Match pairs a canonical member name with the confidence of the match.
type Match struct {
Name string
Confidence Confidence
}
var commonSurnames = map[string]bool{
"novak": true,
"novakova": true,
"prach": true,
}
// MatchMembers finds members mentioned in text and returns them with a
// confidence level of "auto" (reliable) or "review" (needs human verification).
//
// Algorithm (ported verbatim from scripts/match_payments.py match_members):
// 1. Exact short-circuit: if any member's full normalized name appears as whole
// words in normalize(text), return ONLY those matches as auto. This prevents
// nickname "tov" from matching inside surname "ottova".
// 2. Per-member first-match-wins: full-name substring → first+last both present
// (any order) → nickname whole-word. Each yields auto.
// 3. Review tier: last name (len≥4, not a common surname) → first name (len≥3)
// → single-part name (len≥4). Each yields review.
// 4. Final filter: if any auto exists, drop all review.
func MatchMembers(text string, memberNames []string) []Match {
normalizedText := czech.Normalize(text)
// Pass 1: exact short-circuit
var exactMatches []Match
for _, name := range memberNames {
variants := BuildNameVariants(name)
if len(variants) == 0 {
continue
}
fullName := variants[0]
if fullName != "" && wordIn(fullName, normalizedText) {
exactMatches = append(exactMatches, Match{Name: name, Confidence: ConfidenceAuto})
}
}
if len(exactMatches) > 0 {
return exactMatches
}
// Pass 2 + 3: fuzzy matching
var matches []Match
for _, name := range memberNames {
variants := BuildNameVariants(name)
fullName := ""
if len(variants) > 0 {
fullName = variants[0]
}
parts := strings.Fields(fullName)
// Auto tier
if fullName != "" && strings.Contains(normalizedText, fullName) {
matches = append(matches, Match{Name: name, Confidence: ConfidenceAuto})
continue
}
if len(parts) >= 2 {
if wordIn(parts[0], normalizedText) && wordIn(parts[len(parts)-1], normalizedText) {
matches = append(matches, Match{Name: name, Confidence: ConfidenceAuto})
continue
}
}
// Nickname check
if m := nicknameRe.FindStringSubmatch(name); m != nil {
nick := czech.Normalize(m[1])
if nick != "" && wordIn(nick, normalizedText) {
matches = append(matches, Match{Name: name, Confidence: ConfidenceAuto})
continue
}
}
// Review tier
if len(parts) >= 2 {
lastName := parts[len(parts)-1]
firstName := parts[0]
if len(lastName) >= 4 && !commonSurnames[lastName] && wordIn(lastName, normalizedText) {
matches = append(matches, Match{Name: name, Confidence: ConfidenceReview})
continue
}
if len(firstName) >= 3 && wordIn(firstName, normalizedText) {
matches = append(matches, Match{Name: name, Confidence: ConfidenceReview})
continue
}
} else if len(parts) == 1 {
if len(parts[0]) >= 4 && wordIn(parts[0], normalizedText) {
matches = append(matches, Match{Name: name, Confidence: ConfidenceReview})
continue
}
}
}
// Final filter: drop review if any auto exists
hasAuto := false
for _, m := range matches {
if m.Confidence == ConfidenceAuto {
hasAuto = true
break
}
}
if hasAuto {
filtered := matches[:0]
for _, m := range matches {
if m.Confidence == ConfidenceAuto {
filtered = append(filtered, m)
}
}
return filtered
}
if matches == nil {
return []Match{}
}
return matches
}

View File

@@ -0,0 +1,156 @@
package matching
// Expected values verified against scripts/match_payments.py and
// tests/test_match_members.py on 2026-05-06:
//
// PYTHONPATH=scripts:. python3 -c '
// from match_payments import match_members
// MEMBERS = ["Henrietta Ottová", "Tomáš Němeček (Tov)", "František Vrbík (Štrúdl)", "Jana Nováková"]
// cases = [
// ("Henrietta Ottová (Heny): 04/2026", "full name guard"),
// ("platba ottova 04/2026", "ottova surname"),
// ("Henrietta Ottová a Tomáš Němeček 04/2026", "two full names"),
// ("Tov platba 04/2026", "nickname alone"),
// ("Henrietta Ottova 04/2026", "no diacritics"),
// ("Platba od Nemeček Tomas 04/2026", "reversed first+last"),
// ("vrbik clenske", "last name only review"),
// ("jana platba", "first name review"),
// ("neznamy platebce", "no match"),
// ]
// for text, label in cases: print(label + ":", match_members(text, MEMBERS))
// '
//
// Output:
//
// full name guard: [('Henrietta Ottová', 'auto')]
// ottova surname: [('Henrietta Ottová', 'review')]
// two full names: [('Henrietta Ottová', 'auto'), ('Tomáš Němeček (Tov)', 'auto')]
// nickname alone: [('Tomáš Němeček (Tov)', 'auto')]
// no diacritics: [('Henrietta Ottová', 'auto')]
// reversed first+last: [('Tomáš Němeček (Tov)', 'auto')]
// last name only review: [('František Vrbík (Štrúdl)', 'review')]
// first name review: [('Jana Nováková', 'review')]
// no match: []
import (
"testing"
)
var testMembers = []string{
"Henrietta Ottová",
"Tomáš Němeček (Tov)",
"František Vrbík (Štrúdl)",
"Jana Nováková",
}
func TestMatchMembers(t *testing.T) {
t.Parallel()
cases := []struct {
name string
text string
wantContains []string
wantExcludes []string
wantAllAuto bool
}{
{
// Short-circuit: full name matches → "tov" inside "ottova" must NOT fire
name: "full name in message returns only that member",
text: "Henrietta Ottová (Heny): 04/2026",
wantContains: []string{"Henrietta Ottová"},
wantExcludes: []string{"Tomáš Němeček (Tov)"},
wantAllAuto: true,
},
{
// "tov" is a substring of "ottova" — nickname must not match inside a surname
name: "nickname tov not matched inside ottova",
text: "platba ottova 04/2026",
wantExcludes: []string{"Tomáš Němeček (Tov)"},
wantAllAuto: false,
},
{
name: "two full names both auto",
text: "Henrietta Ottová a Tomáš Němeček 04/2026",
wantContains: []string{"Henrietta Ottová", "Tomáš Němeček (Tov)"},
wantAllAuto: true,
},
{
name: "nickname alone matches correctly",
text: "Tov platba 04/2026",
wantContains: []string{"Tomáš Němeček (Tov)"},
wantAllAuto: true,
},
{
name: "full name without diacritics auto",
text: "Henrietta Ottova 04/2026",
wantContains: []string{"Henrietta Ottová"},
wantExcludes: []string{"Tomáš Němeček (Tov)"},
wantAllAuto: true,
},
{
name: "first and last name reversed auto",
text: "Platba od Nemeček Tomas 04/2026",
wantContains: []string{"Tomáš Němeček (Tov)"},
wantAllAuto: true,
},
{
// Last name alone (len≥4, not a common surname) → review confidence
name: "last name only yields review",
text: "vrbik clenske",
wantContains: []string{"František Vrbík (Štrúdl)"},
wantAllAuto: false,
},
{
// First name alone (len≥3) → review confidence
name: "first name only yields review",
text: "jana platba",
wantContains: []string{"Jana Nováková"},
wantAllAuto: false,
},
{
name: "no match returns empty slice",
text: "neznamy platebce",
wantContains: nil,
wantAllAuto: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := MatchMembers(tc.text, testMembers)
// Check required members are present
for _, want := range tc.wantContains {
found := false
for _, m := range got {
if m.Name == want {
found = true
break
}
}
if !found {
t.Errorf("MatchMembers(%q): want %q in result, got %v", tc.text, want, got)
}
}
// Check excluded members are absent
for _, exclude := range tc.wantExcludes {
for _, m := range got {
if m.Name == exclude {
t.Errorf("MatchMembers(%q): %q should not be in result, got %v", tc.text, exclude, got)
}
}
}
// Check all-auto constraint
if tc.wantAllAuto {
for _, m := range got {
if m.Confidence != ConfidenceAuto {
t.Errorf("MatchMembers(%q): expected all auto, got %v", tc.text, got)
}
}
}
})
}
}

View File

@@ -0,0 +1,59 @@
package matching
import (
"fuj-management/go/internal/domain/czech"
"regexp"
"strings"
)
var (
nicknameRe = regexp.MustCompile(`\(([^)]+)\)`)
nicknameStripRe = regexp.MustCompile(`\s*\([^)]*\)\s*`)
)
// BuildNameVariants returns searchable lowercase ASCII variants of a member name.
//
// Example: "František Vrbík (Štrúdl)" → ["frantisek vrbik", "strudl", "vrbik", "frantisek"]
//
// variants[0] is always the full normalized base name (no nickname). MatchMembers relies on
// this invariant for the exact short-circuit pass. Variants shorter than 3 characters are
// dropped.
//
// Ports scripts/match_payments.py _build_name_variants.
func BuildNameVariants(name string) []string {
var nickname string
if m := nicknameRe.FindStringSubmatch(name); m != nil {
nickname = m[1]
}
base := strings.TrimSpace(nicknameStripRe.ReplaceAllString(name, " "))
normalizedBase := czech.Normalize(base)
normalizedNick := czech.Normalize(nickname)
variants := []string{normalizedBase}
if normalizedNick != "" {
variants = append(variants, normalizedNick)
}
parts := strings.Fields(normalizedBase)
if len(parts) >= 2 {
variants = append(variants, parts[len(parts)-1]) // last name
variants = append(variants, parts[0]) // first name
}
filtered := variants[:0]
for _, v := range variants {
if len(v) >= 3 {
filtered = append(filtered, v)
}
}
return filtered
}
// wordIn returns true if needle appears as a whole word in haystack.
// Both needle and haystack must already be ASCII-folded (via czech.Normalize).
func wordIn(needle, haystack string) bool {
pattern := `\b` + regexp.QuoteMeta(needle) + `\b`
matched, _ := regexp.MatchString(pattern, haystack)
return matched
}

View File

@@ -0,0 +1,62 @@
package matching
// Expected values verified against scripts/match_payments.py on 2026-05-06:
//
// PYTHONPATH=scripts:. python3 -c '
// from match_payments import _build_name_variants
// for n in ["František Vrbík (Štrúdl)", "Tov (St)", "Jana", " Petr Novák ( Jenda ) "]:
// print(repr(n), "->", _build_name_variants(n))
// '
//
// Output:
//
// 'František Vrbík (Štrúdl)' -> ['frantisek vrbik', 'strudl', 'vrbik', 'frantisek']
// 'Tov (St)' -> ['tov']
// 'Jana' -> ['jana']
// ' Petr Novák ( Jenda ) ' -> ['petr novak', ' jenda ', 'novak', 'petr']
import (
"reflect"
"testing"
)
func TestBuildNameVariants(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
want []string
}{
{
name: "full name with nickname",
input: "František Vrbík (Štrúdl)",
want: []string{"frantisek vrbik", "strudl", "vrbik", "frantisek"},
},
{
name: "nickname too short filtered out",
input: "Tov (St)",
want: []string{"tov"},
},
{
name: "single-part name no nickname",
input: "Jana",
want: []string{"jana"},
},
{
name: "extra whitespace inside parens preserved by normalize",
input: " Petr Novák ( Jenda ) ",
want: []string{"petr novak", " jenda ", "novak", "petr"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := BuildNameVariants(tc.input)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("BuildNameVariants(%q)\n got %q\n want %q", tc.input, got, tc.want)
}
})
}
}

View File

@@ -0,0 +1,49 @@
// Package money ports Czech-locale currency parsing from scripts/infer_payments.py.
package money
import (
"errors"
"strconv"
"strings"
)
// ErrInvalidAmount is returned by ParseCZK when the input cannot be parsed.
var ErrInvalidAmount = errors.New("money: invalid CZK amount")
// ParseCZK parses a Czech-locale amount string and returns the value in CZK
// as a float64. Mirrors scripts/infer_payments.py parse_czk_amount:
//
// - empty input → (0, nil)
// - "Kč"/"CZK" suffixes stripped (case-sensitive, like Python)
// - comma present → comma is decimal sep, dots/spaces are thousand seps
// ("1.500,00" → 1500.0)
// - no comma, 2+ dots → all dots are thousand seps ("1.500.000" → 1500000.0)
// - no comma, ≤1 dot → dot is decimal sep ("1.500" → 1.5)
// - on parse failure → (0, ErrInvalidAmount); callers wanting Python's
// silent-zero behaviour can discard the error: v, _ := ParseCZK(s)
func ParseCZK(s string) (float64, error) {
if s == "" {
return 0, nil
}
s = strings.ReplaceAll(s, "Kč", "")
s = strings.ReplaceAll(s, "CZK", "")
s = strings.TrimSpace(s)
if strings.ContainsRune(s, ',') {
s = strings.ReplaceAll(s, ".", "")
s = strings.ReplaceAll(s, " ", "")
s = strings.ReplaceAll(s, ",", ".")
} else if strings.Count(s, ".") > 1 {
s = strings.ReplaceAll(s, ".", "")
s = strings.ReplaceAll(s, " ", "")
} else {
s = strings.ReplaceAll(s, " ", "")
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, ErrInvalidAmount
}
return v, nil
}

View File

@@ -0,0 +1,67 @@
package money
import (
"testing"
)
func TestParseCZK(t *testing.T) {
t.Parallel()
// All expected outputs verified against live Python implementation on 2026-05-06:
// PYTHONPATH=scripts:. python -c '
// from infer_payments import parse_czk_amount
// for v in [None, "", "0", "500", "500 Kč", "500 CZK",
// "1 500", "1500.00", "1 500.00",
// "1.500,00", "1500,5", "1.500.000",
// "1.500", "abc", " ", "100,5 Kč"]:
// print(repr(v), "->", parse_czk_amount(v))
// '
tests := []struct {
name string
input string
want float64
wantErr bool
}{
{"empty string", "", 0, false},
{"zero string", "0", 0, false},
{"plain integer", "500", 500, false},
{"with Kč suffix", "500 Kč", 500, false},
{"with CZK suffix", "500 CZK", 500, false},
{"space thousand sep", "1 500", 1500, false},
{"dot decimal", "1500.00", 1500, false},
{"space thousands dot decimal", "1 500.00", 1500, false},
{"dot thousand comma decimal", "1.500,00", 1500, false},
{"comma decimal no thousands", "1500,5", 1500.5, false},
{"multiple dot thousand seps", "1.500.000", 1500000, false},
{"single dot is decimal heuristic", "1.500", 1.5, false},
{"comma decimal with Kč", "100,5 Kč", 100.5, false},
{"garbage text", "abc", 0, true},
{"spaces only", " ", 0, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := ParseCZK(tc.input)
if (err != nil) != tc.wantErr {
t.Errorf("ParseCZK(%q) error = %v, wantErr %v", tc.input, err, tc.wantErr)
}
if got != tc.want {
t.Errorf("ParseCZK(%q) = %v, want %v", tc.input, got, tc.want)
}
})
}
}
// TestParseCZKSilentZero documents that discarding the error recovers Python's
// silent-zero behaviour for any garbage input.
func TestParseCZKSilentZero(t *testing.T) {
t.Parallel()
for _, s := range []string{"abc", " ", "Kč", "CZK"} {
v, _ := ParseCZK(s)
if v != 0 {
t.Errorf("ParseCZK(%q) silent-zero: got %v, want 0", s, v)
}
}
}

View File

@@ -0,0 +1,393 @@
// Package reconcile ports the three-phase payment reconciliation from scripts/match_payments.py.
package reconcile
import (
"fuj-management/go/internal/domain/czech"
"fuj-management/go/internal/domain/matching"
"regexp"
"strings"
)
// ExceptionKey identifies a fee override by normalized member name and period.
type ExceptionKey struct {
Name string // czech.Normalize(memberName)
Period string // czech.Normalize("YYYY-MM")
}
// Exception is a manual fee override for one member in one period.
type Exception struct {
Amount int
Note string
}
// FeeData holds the expected fee and attendance count for one member in one month.
type FeeData struct {
Expected int
Attendance int
}
// Member is one row from the attendance sheet.
type Member struct {
Name string
Tier string
Fees map[string]FeeData // month ("YYYY-MM") → fee data
}
// Transaction is one payment row from the payments sheet.
// Date must already be a "YYYY-MM-DD" string (convert with matching.FormatDate before calling).
// InferredAmount, when non-nil, replaces Amount when person and purpose are pre-matched.
type Transaction struct {
Date string
Amount float64
Person string // comma-separated canonical names (empty → use inference)
Purpose string // comma-separated "YYYY-MM" or "other:…" (empty → use inference)
InferredAmount *float64 // nil → fall back to Amount
Sender string
Message string
UserID string
}
// TxEntry is the portion of a payment allocated to a single member+month.
type TxEntry struct {
Amount float64
Date string
Sender string
Message string
Confidence string
}
// OtherEntry is a payment with purpose "other:…" allocated to a member.
type OtherEntry struct {
Amount float64
Date string
Sender string
Message string
Purpose string
Confidence string
}
// MonthData is the ledger state for one member in one month.
type MonthData struct {
Expected int
OriginalExpected int
AttendanceCount int
Exception *Exception
Paid float64
Transactions []TxEntry
}
// MemberResult is the reconciled ledger for one member.
type MemberResult struct {
Tier string
Months map[string]MonthData
OtherTransactions []OtherEntry
TotalBalance int
}
// Result is the top-level output of Reconcile.
type Result struct {
Members map[string]MemberResult
Unmatched []Transaction
Credits map[string]int // final balance for every member (may be negative)
}
var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
// canonicalMemberKey returns a diacritic-, case-, and whitespace-insensitive key
// used to resolve Person-column values that drift from canonical attendance-sheet names.
// Ports scripts/match_payments.py canonical_member_key.
func canonicalMemberKey(name string) string {
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
}
type monthExpected struct {
month string
expected int
}
// Reconcile matches transactions to members and months using three allocation phases:
// 1. Greedy: payment ≥ total expected → fill each month exactly; overflow → credit.
// 2. Proportional: payment < total → distribute by each month's share; last absorbs float remainder.
// 3. Even-split fallback: all expected fees are 0 (prepayment) → divide equally.
//
// defaultYear seeds czech.ParseMonthReferences in the inference fallback.
// Pass time.Now().Year() in production; pass a fixed year in tests.
//
// Ports scripts/match_payments.py reconcile.
func Reconcile(
members []Member,
sortedMonths []string,
transactions []Transaction,
exceptions map[ExceptionKey]Exception,
defaultYear int,
) Result {
memberNames := make([]string, len(members))
memberTiers := make(map[string]string, len(members))
memberFees := make(map[string]map[string]FeeData, len(members))
for i, m := range members {
memberNames[i] = m.Name
memberTiers[m.Name] = m.Tier
memberFees[m.Name] = m.Fees
}
// Map canonical key → first attendance-sheet name with that key, so Person cells
// that drift in diacritics/case/whitespace still resolve to the canonical name.
canonicalByKey := make(map[string]string, len(memberNames))
for _, name := range memberNames {
key := canonicalMemberKey(name)
if _, exists := canonicalByKey[key]; !exists {
canonicalByKey[key] = name
}
}
if exceptions == nil {
exceptions = map[ExceptionKey]Exception{}
}
// Initialise ledger
ledger := make(map[string]map[string]MonthData, len(memberNames))
otherLedger := make(map[string][]OtherEntry, len(memberNames))
for _, name := range memberNames {
ledger[name] = make(map[string]MonthData, len(sortedMonths))
otherLedger[name] = []OtherEntry{}
for _, m := range sortedMonths {
fd := memberFees[name][m]
originalExpected := fd.Expected
attendanceCount := fd.Attendance
var expected int
var exInfo *Exception
exKey := ExceptionKey{
Name: czech.Normalize(name),
Period: czech.Normalize(m),
}
if ex, ok := exceptions[exKey]; ok {
expected = ex.Amount
exCopy := ex
exInfo = &exCopy
} else {
expected = originalExpected
}
ledger[name][m] = MonthData{
Expected: expected,
OriginalExpected: originalExpected,
AttendanceCount: attendanceCount,
Exception: exInfo,
Paid: 0,
Transactions: []TxEntry{},
}
}
}
var unmatched []Transaction
credits := make(map[string]int, len(memberNames))
for _, tx := range transactions {
personStr := strings.TrimSpace(tx.Person)
purposeStr := strings.TrimSpace(tx.Purpose)
personStr = questionMarkRe.ReplaceAllString(personStr, "")
isOther := strings.HasPrefix(strings.ToLower(purposeStr), "other:")
var matchedMembers []matching.Match
var matchedMonths []string
var amount float64
if personStr != "" && purposeStr != "" {
for p := range strings.SplitSeq(personStr, ",") {
p = strings.TrimSpace(p)
if p != "" {
matchedMembers = append(matchedMembers, matching.Match{
Name: p,
Confidence: matching.ConfidenceAuto,
})
}
}
if isOther {
matchedMonths = []string{purposeStr}
} else {
for m := range strings.SplitSeq(purposeStr, ",") {
m = strings.TrimSpace(m)
if m != "" {
matchedMonths = append(matchedMonths, m)
}
}
}
if tx.InferredAmount != nil {
amount = *tx.InferredAmount
} else {
amount = tx.Amount
}
} else {
// Inference fallback for rows not yet processed by infer_payments.py
inferred := matching.InferTransactionDetails(
matching.Transaction{
Sender: tx.Sender,
Message: tx.Message,
UserID: tx.UserID,
Date: tx.Date,
},
memberNames,
defaultYear,
)
matchedMembers = inferred.Members
matchedMonths = inferred.Months
amount = tx.Amount
}
if len(matchedMembers) == 0 || len(matchedMonths) == 0 {
unmatched = append(unmatched, tx)
continue
}
if isOther {
nAlloc := len(matchedMembers)
perAlloc := 0.0
if nAlloc > 0 {
perAlloc = amount / float64(nAlloc)
}
for _, m := range matchedMembers {
memberName := canonicalByKey[canonicalMemberKey(m.Name)]
if memberName != "" {
otherLedger[memberName] = append(otherLedger[memberName], OtherEntry{
Amount: perAlloc,
Date: tx.Date,
Sender: tx.Sender,
Message: tx.Message,
Purpose: purposeStr,
Confidence: string(m.Confidence),
})
}
}
continue
}
memberShare := 0.0
if len(matchedMembers) > 0 {
memberShare = amount / float64(len(matchedMembers))
}
for _, m := range matchedMembers {
memberName := canonicalByKey[canonicalMemberKey(m.Name)]
if memberName == "" {
unmatched = append(unmatched, tx)
continue
}
var inWindow []monthExpected
outCount := 0
for _, month := range matchedMonths {
if md, ok := ledger[memberName][month]; ok {
inWindow = append(inWindow, monthExpected{month: month, expected: md.Expected})
} else {
outCount++
}
}
nTotal := len(matchedMonths)
outCredit := 0.0
if outCount > 0 && nTotal > 0 {
outCredit = memberShare / float64(nTotal) * float64(outCount)
credits[memberName] += int(outCredit)
}
inWindowShare := memberShare - outCredit
if len(inWindow) == 0 {
continue
}
totalExpected := 0
for _, mw := range inWindow {
totalExpected += mw.expected
}
if totalExpected > 0 && inWindowShare >= float64(totalExpected) {
// Greedy: payment covers all expected fees; overflow → credit
credits[memberName] += int(inWindowShare - float64(totalExpected))
for _, mw := range inWindow {
alloc := float64(mw.expected)
md := ledger[memberName][mw.month]
md.Paid += alloc
md.Transactions = append(md.Transactions, TxEntry{
Amount: alloc,
Date: tx.Date,
Sender: tx.Sender,
Message: tx.Message,
Confidence: string(m.Confidence),
})
ledger[memberName][mw.month] = md
}
} else if totalExpected > 0 {
// Proportional: distribute by each month's share; last month absorbs float remainder
remaining := inWindowShare
for i, mw := range inWindow {
var alloc float64
if i == len(inWindow)-1 {
alloc = remaining
} else {
alloc = inWindowShare * float64(mw.expected) / float64(totalExpected)
}
remaining -= alloc
md := ledger[memberName][mw.month]
md.Paid += alloc
md.Transactions = append(md.Transactions, TxEntry{
Amount: alloc,
Date: tx.Date,
Sender: tx.Sender,
Message: tx.Message,
Confidence: string(m.Confidence),
})
ledger[memberName][mw.month] = md
}
} else {
// Even-split fallback: prepayment before attendance recorded
perMonth := inWindowShare / float64(len(inWindow))
for _, mw := range inWindow {
md := ledger[memberName][mw.month]
md.Paid += perMonth
md.Transactions = append(md.Transactions, TxEntry{
Amount: perMonth,
Date: tx.Date,
Sender: tx.Sender,
Message: tx.Message,
Confidence: string(m.Confidence),
})
ledger[memberName][mw.month] = md
}
}
}
}
// Final total balances: window balance + out-of-window credits accumulated above
finalBalances := make(map[string]int, len(memberNames))
for _, name := range memberNames {
windowBalance := 0
for _, mdata := range ledger[name] {
windowBalance += int(mdata.Paid) - mdata.Expected
}
finalBalances[name] = windowBalance + credits[name]
}
membersResult := make(map[string]MemberResult, len(memberNames))
for _, name := range memberNames {
membersResult[name] = MemberResult{
Tier: memberTiers[name],
Months: ledger[name],
OtherTransactions: otherLedger[name],
TotalBalance: finalBalances[name],
}
}
if unmatched == nil {
unmatched = []Transaction{}
}
return Result{
Members: membersResult,
Unmatched: unmatched,
Credits: finalBalances,
}
}

View File

@@ -0,0 +1,376 @@
package reconcile
// Expected values verified against scripts/match_payments.py on 2026-05-06:
//
// PYTHONPATH=scripts:. python3 -m unittest tests.test_reconcile_exceptions tests.test_match_payments -v
//
// All Python test cases are ported below. Additional Go-only cases are marked with [Go].
import (
"math"
"testing"
)
const defaultYear = 2026
// tx builds a pre-matched Transaction (person+purpose already filled in).
// InferredAmount is left nil so Amount is used directly, matching the Python
// _tx helper where inferred_amount == amount.
func tx(person, purpose string, amount float64) Transaction {
return Transaction{
Date: "2026-01-01",
Amount: amount,
Person: person,
Purpose: purpose,
Sender: "Sender",
Message: "fee",
}
}
func TestReconcileExceptionOverride(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 4}}}}
exceptions := map[ExceptionKey]Exception{
{Name: "alice", Period: "2026-01"}: {Amount: 400, Note: "Test exception"},
}
txs := []Transaction{{
Date: "2026-01-05", Amount: 400,
Person: "Alice", Purpose: "2026-01", Sender: "Alice Sender", Message: "fee",
}}
result := Reconcile(members, []string{"2026-01"}, txs, exceptions, defaultYear)
jan := result.Members["Alice"].Months["2026-01"]
if jan.Expected != 400 {
t.Errorf("Expected override to 400, got %d", jan.Expected)
}
if jan.Paid != 400 {
t.Errorf("Paid want 400, got %f", jan.Paid)
}
if result.Members["Alice"].TotalBalance != 0 {
t.Errorf("TotalBalance want 0, got %d", result.Members["Alice"].TotalBalance)
}
}
func TestReconcileFallbackToAttendance(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 4}}}}
result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear)
if result.Members["Alice"].Months["2026-01"].Expected != 750 {
t.Errorf("Expected 750 when no exception, got %d", result.Members["Alice"].Months["2026-01"].Expected)
}
}
func TestReconcileGreedyExactMatch(t *testing.T) {
t.Parallel()
members := []Member{{
Name: "Alice", Tier: "A",
Fees: map[string]FeeData{
"2026-02": {750, 3},
"2026-03": {350, 3},
"2026-04": {150, 2},
},
}}
sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", 1250)}, nil, defaultYear)
months := result.Members["Alice"].Months
if int(months["2026-02"].Paid) != 750 {
t.Errorf("2026-02 paid want 750, got %f", months["2026-02"].Paid)
}
if int(months["2026-03"].Paid) != 350 {
t.Errorf("2026-03 paid want 350, got %f", months["2026-03"].Paid)
}
if int(months["2026-04"].Paid) != 150 {
t.Errorf("2026-04 paid want 150, got %f", months["2026-04"].Paid)
}
}
func TestReconcileGreedyOverpaymentGoesToCredit(t *testing.T) {
t.Parallel()
members := []Member{{
Name: "Alice", Tier: "A",
Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {750, 3}},
}}
sortedMonths := []string{"2026-01", "2026-02"}
result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-01, 2026-02", 2000)}, nil, defaultYear)
months := result.Members["Alice"].Months
if int(months["2026-01"].Paid) != 750 {
t.Errorf("2026-01 paid want 750, got %f", months["2026-01"].Paid)
}
if int(months["2026-02"].Paid) != 750 {
t.Errorf("2026-02 paid want 750, got %f", months["2026-02"].Paid)
}
if result.Credits["Alice"] != 500 {
t.Errorf("credits want 500, got %d", result.Credits["Alice"])
}
}
func TestReconcileProportionalUnderpayment(t *testing.T) {
t.Parallel()
members := []Member{{
Name: "Alice", Tier: "A",
Fees: map[string]FeeData{"2026-02": {750, 3}, "2026-03": {350, 3}, "2026-04": {750, 3}},
}}
sortedMonths := []string{"2026-02", "2026-03", "2026-04"}
amount := 1250.0
result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-02, 2026-03, 2026-04", amount)}, nil, defaultYear)
months := result.Members["Alice"].Months
paid02 := months["2026-02"].Paid
paid03 := months["2026-03"].Paid
paid04 := months["2026-04"].Paid
if paid02 >= 750 {
t.Errorf("2026-02 should be underpaid, got %f", paid02)
}
if paid03 >= 350 {
t.Errorf("2026-03 should be underpaid, got %f", paid03)
}
if paid04 >= 750 {
t.Errorf("2026-04 should be underpaid, got %f", paid04)
}
if math.Abs(paid02+paid03+paid04-amount) > 0.01 {
t.Errorf("sum of paid want %f, got %f", amount, paid02+paid03+paid04)
}
if math.Abs(paid02-paid04) > 0.01 {
t.Errorf("02 and 04 have equal expected, want equal paid: %f vs %f", paid02, paid04)
}
}
func TestReconcileSingleMonthUnchanged(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
result := Reconcile(members, []string{"2026-01"}, []Transaction{tx("Alice", "2026-01", 750)}, nil, defaultYear)
if math.Abs(result.Members["Alice"].Months["2026-01"].Paid-750) > 0.01 {
t.Errorf("single month want 750, got %f", result.Members["Alice"].Months["2026-01"].Paid)
}
}
func TestReconcileTwoMembersMultiMonth(t *testing.T) {
t.Parallel()
members := []Member{
{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 3}}},
{Name: "Bob", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}, "2026-02": {350, 3}}},
}
sortedMonths := []string{"2026-01", "2026-02"}
result := Reconcile(members, sortedMonths, []Transaction{tx("Alice, Bob", "2026-01, 2026-02", 2200)}, nil, defaultYear)
for _, name := range []string{"Alice", "Bob"} {
months := result.Members[name].Months
if math.Abs(months["2026-01"].Paid-750) > 0.01 {
t.Errorf("%s 2026-01 paid want 750, got %f", name, months["2026-01"].Paid)
}
if math.Abs(months["2026-02"].Paid-350) > 0.01 {
t.Errorf("%s 2026-02 paid want 350, got %f", name, months["2026-02"].Paid)
}
}
}
func TestReconcileEvenSplitFallbackWhenNoExpected(t *testing.T) {
t.Parallel()
members := []Member{{
Name: "Alice", Tier: "A",
Fees: map[string]FeeData{"2026-01": {0, 0}, "2026-02": {0, 0}},
}}
sortedMonths := []string{"2026-01", "2026-02"}
result := Reconcile(members, sortedMonths, []Transaction{tx("Alice", "2026-01, 2026-02", 300)}, nil, defaultYear)
months := result.Members["Alice"].Months
if math.Abs(months["2026-01"].Paid-150) > 0.01 {
t.Errorf("2026-01 paid want 150, got %f", months["2026-01"].Paid)
}
if math.Abs(months["2026-02"].Paid-150) > 0.01 {
t.Errorf("2026-02 paid want 150, got %f", months["2026-02"].Paid)
}
}
func TestReconcileDiacriticsTolerantPersonMatching(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 4}}}}
txFn := func(person string) Transaction {
return Transaction{
Date: "2026-04-15", Amount: 750, Person: person, Purpose: "2026-04",
Sender: "Maco Family", Message: "fee",
}
}
cases := []struct {
name string
person string
}{
{"without diacritics", "Maria Maco"},
{"extra whitespace", "Mária Maco"},
{"lowercase", "mária maco"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result := Reconcile(members, []string{"2026-04"}, []Transaction{txFn(tc.person)}, nil, defaultYear)
paid := result.Members["Mária Maco"].Months["2026-04"].Paid
if paid != 750 {
t.Errorf("%s: paid want 750, got %f", tc.name, paid)
}
if len(result.Unmatched) != 0 {
t.Errorf("%s: want no unmatched, got %v", tc.name, result.Unmatched)
}
})
}
}
func TestReconcileTrulyUnknownPersonIsUnmatched(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Mária Maco", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 4}}}}
txs := []Transaction{{
Date: "2026-04-15", Amount: 750,
Person: "Někdo Neznámý", Purpose: "2026-04",
Sender: "Neznámý", Message: "fee",
}}
result := Reconcile(members, []string{"2026-04"}, txs, nil, defaultYear)
if result.Members["Mária Maco"].Months["2026-04"].Paid != 0 {
t.Errorf("unknown person must not credit the member")
}
if len(result.Unmatched) != 1 {
t.Errorf("want 1 unmatched, got %d", len(result.Unmatched))
}
}
// [Go] Test that [?] markers are stripped from the Person field before lookup.
func TestReconcileQuestionMarkMarkerStripped(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
txs := []Transaction{{
Date: "2026-01-01", Amount: 750,
Person: "[?] Alice", Purpose: "2026-01",
Sender: "Bank", Message: "fee",
}}
result := Reconcile(members, []string{"2026-01"}, txs, nil, defaultYear)
if result.Members["Alice"].Months["2026-01"].Paid != 750 {
t.Errorf("[?] stripping: want 750 paid, got %f", result.Members["Alice"].Months["2026-01"].Paid)
}
}
// [Go] Purpose "other:shirt" puts payment in OtherTransactions, not in month ledger.
func TestReconcileOtherPurpose(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
txs := []Transaction{{
Date: "2026-01-01", Amount: 300,
Person: "Alice", Purpose: "other:shirt",
Sender: "Bank", Message: "shirt order",
}}
result := Reconcile(members, []string{"2026-01"}, txs, nil, defaultYear)
if result.Members["Alice"].Months["2026-01"].Paid != 0 {
t.Errorf("other: purpose must not touch month ledger")
}
others := result.Members["Alice"].OtherTransactions
if len(others) != 1 {
t.Fatalf("want 1 OtherTransaction, got %d", len(others))
}
if math.Abs(others[0].Amount-300) > 0.01 {
t.Errorf("OtherEntry.Amount want 300, got %f", others[0].Amount)
}
if others[0].Purpose != "other:shirt" {
t.Errorf("OtherEntry.Purpose want %q, got %q", "other:shirt", others[0].Purpose)
}
}
// [Go] Months outside sortedMonths go to credit, not to the window ledger.
func TestReconcileOutOfWindowGoesToCredit(t *testing.T) {
t.Parallel()
// Window shows only 2026-01. Transaction references 2026-01 (in) and 2026-02 (out).
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {600, 3}}}}
txs := []Transaction{{
Date: "2026-01-01", Amount: 1200,
Person: "Alice", Purpose: "2026-01, 2026-02",
Sender: "Bank", Message: "Q1",
}}
result := Reconcile(members, []string{"2026-01"}, txs, nil, defaultYear)
// member_share = 1200 (one member)
// out_credit = 1200 / 2 * 1 = 600
// in_window_share = 600
// in_window = [(2026-01, 600)], total_expected = 600 → greedy: paid = 600, no overflow
if math.Abs(result.Members["Alice"].Months["2026-01"].Paid-600) > 0.01 {
t.Errorf("in-window paid want 600, got %f", result.Members["Alice"].Months["2026-01"].Paid)
}
// total_balance = int(600) - 600 (window) + 600 (out credit) = 600
if result.Members["Alice"].TotalBalance != 600 {
t.Errorf("TotalBalance want 600, got %d", result.Members["Alice"].TotalBalance)
}
}
// [Go] No person/purpose → inference fallback resolves sender name and date month.
func TestReconcileInferenceFallback(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Tomáš Němeček", Tier: "A", Fees: map[string]FeeData{"2026-04": {750, 3}}}}
txs := []Transaction{{
Date: "2026-04-15", Amount: 750,
// Person and Purpose are empty → inference path
Sender: "Tomas Nemecek",
Message: "clenske 04/2026",
}}
result := Reconcile(members, []string{"2026-04"}, txs, nil, defaultYear)
if math.Abs(result.Members["Tomáš Němeček"].Months["2026-04"].Paid-750) > 0.01 {
t.Errorf("inference fallback: want 750 paid, got %f", result.Members["Tomáš Němeček"].Months["2026-04"].Paid)
}
}
// [Go] Transaction with no match at all ends up in Unmatched; ledger unchanged.
func TestReconcileNoMatchGoesToUnmatched(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
txs := []Transaction{{
Date: "2026-01-01", Amount: 500,
// empty person+purpose and sender name not matching any member
Sender: "Unknown Corp", Message: "invoice",
}}
result := Reconcile(members, []string{"2026-01"}, txs, nil, defaultYear)
if len(result.Unmatched) != 1 {
t.Errorf("want 1 unmatched, got %d", len(result.Unmatched))
}
if result.Members["Alice"].Months["2026-01"].Paid != 0 {
t.Errorf("unmatched tx must not touch ledger")
}
}
// [Go] Empty transaction list leaves every month at paid=0 and balance=expected.
func TestReconcileNoTransactionsAllUnpaid(t *testing.T) {
t.Parallel()
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {750, 3}}}}
result := Reconcile(members, []string{"2026-01"}, nil, nil, defaultYear)
if result.Members["Alice"].Months["2026-01"].Paid != 0 {
t.Errorf("no txs: want paid=0, got %f", result.Members["Alice"].Months["2026-01"].Paid)
}
if result.Members["Alice"].TotalBalance != -750 {
t.Errorf("no txs: want balance -750, got %d", result.Members["Alice"].TotalBalance)
}
if len(result.Unmatched) != 0 {
t.Errorf("no txs: want empty unmatched, got %v", result.Unmatched)
}
}

View File

@@ -0,0 +1,65 @@
// Package synch ports the bank-sync deduplication helper from
// scripts/sync_fio_to_sheets.py.
package synch
import (
"crypto/sha256"
"encoding/hex"
"math"
"strconv"
"strings"
)
// Transaction is the projection of a Fio transaction that participates
// in the Sync ID hash. Other fields (ks, ss, sender_account, …) are
// intentionally excluded — they are not part of the Python hash.
//
// Currency: leave "" to inherit the Python default of "CZK" (matches
// the HTML scraper path which omits the key entirely).
type Transaction struct {
Date string
Amount float64
Currency string
Sender string
VS string
Message string
BankID string
}
// GenerateSyncID returns the lowercase SHA-256 hex digest of
// "date|amount|currency|sender|vs|message|bank_id" (lower-cased), used
// as the dedup key in column K of the payments sheet.
//
// Byte-stable with scripts/sync_fio_to_sheets.py generate_sync_id.
func GenerateSyncID(tx Transaction) string {
currency := tx.Currency
if currency == "" {
currency = "CZK"
}
raw := strings.ToLower(strings.Join([]string{
tx.Date,
formatAmount(tx.Amount),
currency,
tx.Sender,
tx.VS,
tx.Message,
tx.BankID,
}, "|"))
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
// formatAmount mimics Python's str(float) for Fio transaction amounts.
// Python uses decimal notation for abs(f) in [1e-4, 1e16) and scientific
// notation outside that range, always adding ".0" to whole-valued decimals.
func formatAmount(f float64) string {
abs := math.Abs(f)
if abs != 0 && (abs < 1e-4 || abs >= 1e16) {
return strconv.FormatFloat(f, 'e', -1, 64)
}
s := strconv.FormatFloat(f, 'f', -1, 64)
if !strings.ContainsRune(s, '.') {
s += ".0"
}
return s
}

View File

@@ -0,0 +1,119 @@
package synch
import (
"testing"
)
// All expected digests verified against the live Python implementation on 2026-05-06:
//
// PYTHONPATH=scripts:. python -c '
// from sync_fio_to_sheets import generate_sync_id
// cases = [
// {"date":"2026-01-15","amount":500.0,"currency":"CZK","sender":"Jan Novak","vs":"123","message":"clenske 1/2026","bank_id":"abc123"},
// {"date":"2026-01-15","amount":500.0,"sender":"Jan Novak","vs":"123","message":"clenske 1/2026","bank_id":"abc123"},
// {"date":"2026-02-10","amount":1234.56,"currency":"CZK","sender":"ABC SRO","vs":"","message":"FAKTURA 42","bank_id":"xyz"},
// {"date":"2026-03-01","amount":-500.0,"currency":"CZK","sender":"refund","vs":"","message":"","bank_id":""},
// {"date":"2026-04-01","amount":0.0,"currency":"CZK","sender":"","vs":"","message":"","bank_id":""},
// {"date":"","amount":0.0,"currency":"CZK","sender":"","vs":"","message":"","bank_id":""},
// ]
// for c in cases: print(generate_sync_id(c))
// '
func TestGenerateSyncID(t *testing.T) {
t.Parallel()
cases := []struct {
name string
tx Transaction
want string
}{
{
name: "all fields set",
tx: Transaction{
Date: "2026-01-15", Amount: 500.0, Currency: "CZK",
Sender: "Jan Novak", VS: "123", Message: "clenske 1/2026", BankID: "abc123",
},
want: "4ac26598b6f23965380690172156a438a7e97a97dcedf222e5afe1afbe2c1bc4",
},
{
name: "currency empty defaults to CZK",
tx: Transaction{
Date: "2026-01-15", Amount: 500.0, Currency: "",
Sender: "Jan Novak", VS: "123", Message: "clenske 1/2026", BankID: "abc123",
},
want: "4ac26598b6f23965380690172156a438a7e97a97dcedf222e5afe1afbe2c1bc4",
},
{
name: "mixed-case fields lowercased before hashing",
tx: Transaction{
Date: "2026-02-10", Amount: 1234.56, Currency: "CZK",
Sender: "ABC SRO", VS: "", Message: "FAKTURA 42", BankID: "xyz",
},
want: "d40fa224d4fa572ffcd58e308e5c6508c4d5ca087b24ef6ff9284528fc128250",
},
{
name: "negative amount",
tx: Transaction{
Date: "2026-03-01", Amount: -500.0, Currency: "CZK",
Sender: "refund", VS: "", Message: "", BankID: "",
},
want: "0c630a407160367c396a2beec08efb94c319b4d84a8b90cc2be89e6ea10c391f",
},
{
name: "zero amount",
tx: Transaction{
Date: "2026-04-01", Amount: 0.0, Currency: "CZK",
Sender: "", VS: "", Message: "", BankID: "",
},
want: "6a23ce53717cd539064d550d2c2ec5de2e9bf81016d16852820ca9b8e259331f",
},
{
// Python equivalent: {"date":"","amount":0.0,"currency":"CZK","sender":"","vs":"","message":"","bank_id":""}
// Note: Python generate_sync_id({}) hashes "" for missing amount, not "0.0".
name: "zero-value Transaction",
tx: Transaction{},
want: "d33d7e391f5a43f0192bb5a34c0ec15715139125678ecef8e1324af7d943b21d",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := GenerateSyncID(tc.tx)
if got != tc.want {
t.Errorf("GenerateSyncID(%+v) = %q, want %q", tc.tx, got, tc.want)
}
})
}
}
// All expected strings verified against the live Python implementation on 2026-05-06:
//
// PYTHONPATH=scripts:. python -c '
// for v in [0.0, 500.0, -500.0, 0.1, 1234.56, 99999.99, 1500000.0, 1e16, 1e-5]:
// print(repr(v), "->", repr(str(v)))
// '
func TestFormatAmount(t *testing.T) {
t.Parallel()
cases := []struct {
in float64
want string
}{
{0.0, "0.0"},
{500.0, "500.0"},
{-500.0, "-500.0"},
{0.1, "0.1"},
{1234.56, "1234.56"},
{99999.99, "99999.99"},
{1500000.0, "1500000.0"},
{1e16, "1e+16"},
{1e-5, "1e-05"},
}
for _, tc := range cases {
got := formatAmount(tc.in)
if got != tc.want {
t.Errorf("formatAmount(%v) = %q, want %q", tc.in, got, tc.want)
}
}
}

View File

@@ -0,0 +1,24 @@
package logging
import (
"log/slog"
"os"
"strings"
)
// New returns a slog.Logger at the given level (DEBUG|INFO|WARN|ERROR).
// Pass config.Config.LogLevel as the argument. Defaults to INFO on unrecognised input.
func New(level string) *slog.Logger {
var l slog.Level
switch strings.ToUpper(level) {
case "DEBUG":
l = slog.LevelDebug
case "WARN", "WARNING":
l = slog.LevelWarn
case "ERROR":
l = slog.LevelError
default:
l = slog.LevelInfo
}
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: l}))
}

View File

@@ -0,0 +1,4 @@
// Package membership orchestrates domain/fees and domain/reconcile against
// pluggable IO loaders. Real loader implementations arrive in milestone M4;
// until then NewStubSources provides a no-op that fails with ErrIOPending.
package membership

View File

@@ -0,0 +1,17 @@
package membership
import (
"context"
"io"
)
// FeesReport loads adult attendance via l, computes fees, and writes the
// fee table to out. Returns ErrIOPending until a real loader is injected in M4.
func FeesReport(ctx context.Context, l AttendanceLoader, out io.Writer) error {
members, sortedMonths, err := l.LoadAdults(ctx)
if err != nil {
return err
}
printFeesTable(out, members, sortedMonths)
return nil
}

View File

@@ -0,0 +1,47 @@
package membership
import (
"bytes"
"context"
"fuj-management/go/internal/domain/reconcile"
"strings"
"testing"
)
type fakeAttendanceLoader struct {
members []reconcile.Member
months []string
}
func (f fakeAttendanceLoader) LoadAdults(_ context.Context) ([]reconcile.Member, []string, error) {
return f.members, f.months, nil
}
func TestFeesReport(t *testing.T) {
t.Parallel()
loader := fakeAttendanceLoader{
members: []reconcile.Member{
{Name: "Alice", Tier: "A", Fees: map[string]reconcile.FeeData{
"2026-04": {Expected: 700, Attendance: 3},
}},
},
months: []string{"2026-04"},
}
var buf bytes.Buffer
if err := FeesReport(context.Background(), loader, &buf); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(buf.String(), "700 CZK (3)") {
t.Errorf("expected '700 CZK (3)' in output, got:\n%s", buf.String())
}
}
func TestFeesReportStubErrors(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
err := FeesReport(context.Background(), NewStubSources(), &buf)
if err == nil {
t.Fatal("expected error from stub, got nil")
}
}

View File

@@ -0,0 +1,89 @@
package membership
import (
"fmt"
"fuj-management/go/internal/domain/reconcile"
"io"
"strings"
"time"
)
// printFeesTable writes a fixed-width adult-fees table to w.
// Mirrors scripts/calculate_fees.py main().
//
// Verify with:
//
// PYTHONPATH=scripts:. python scripts/calculate_fees.py
func printFeesTable(w io.Writer, members []reconcile.Member, sortedMonths []string) {
type row struct {
name string
fees map[string]reconcile.FeeData
}
var adults []row
for _, m := range members {
if m.Tier == "A" {
adults = append(adults, row{name: m.Name, fees: m.Fees})
}
}
if len(adults) == 0 {
fmt.Fprintln(w, "No data.")
return
}
monthLabel := func(m string) string {
t, err := time.Parse("2006-01", m)
if err != nil {
return m
}
return t.Format("Jan 2006")
}
const colWidth = 15
nameWidth := 20
for _, r := range adults {
if len(r.name) > nameWidth {
nameWidth = len(r.name)
}
}
// separator length: nameWidth + N*(colWidth+3) where +3 is " | "
sepLen := nameWidth + len(sortedMonths)*(colWidth+3)
// Header row
fmt.Fprintf(w, "%-*s", nameWidth, "Member")
for _, m := range sortedMonths {
fmt.Fprintf(w, " | %*s", colWidth, monthLabel(m))
}
fmt.Fprintln(w)
fmt.Fprintln(w, strings.Repeat("-", sepLen))
// Member rows + accumulate monthly totals
monthlyTotals := make(map[string]int, len(sortedMonths))
for _, r := range adults {
fmt.Fprintf(w, "%-*s", nameWidth, r.name)
for _, m := range sortedMonths {
fd := r.fees[m]
monthlyTotals[m] += fd.Expected
var cell string
if fd.Attendance > 0 {
cell = fmt.Sprintf("%d CZK (%d)", fd.Expected, fd.Attendance)
} else {
cell = "-"
}
fmt.Fprintf(w, " | %*s", colWidth, cell)
}
fmt.Fprintln(w)
}
// Totals row
fmt.Fprintln(w, strings.Repeat("-", sepLen))
fmt.Fprintf(w, "%-*s", nameWidth, "TOTAL")
for _, m := range sortedMonths {
cell := fmt.Sprintf("%d CZK", monthlyTotals[m])
fmt.Fprintf(w, " | %*s", colWidth, cell)
}
fmt.Fprintln(w)
}

View File

@@ -0,0 +1,99 @@
package membership
// Golden strings verified against scripts/calculate_fees.py on 2026-05-06:
//
// PYTHONPATH=scripts:. python scripts/calculate_fees.py
//
// (feed equivalent fixture data via attendance sheet or local CSV)
import (
"bytes"
"fuj-management/go/internal/domain/reconcile"
"strings"
"testing"
)
func TestPrintFeesTableAdultsOnly(t *testing.T) {
t.Parallel()
members := []reconcile.Member{
{Name: "Alice", Tier: "A", Fees: map[string]reconcile.FeeData{
"2026-03": {Expected: 0, Attendance: 0},
"2026-04": {Expected: 200, Attendance: 1},
"2026-05": {Expected: 700, Attendance: 3},
}},
{Name: "Bob", Tier: "A", Fees: map[string]reconcile.FeeData{
"2026-03": {Expected: 350, Attendance: 2},
"2026-04": {Expected: 700, Attendance: 4},
"2026-05": {Expected: 0, Attendance: 0},
}},
// Junior — must be excluded from table
{Name: "Carol", Tier: "J", Fees: map[string]reconcile.FeeData{
"2026-04": {Expected: 0, Attendance: 1},
}},
}
sortedMonths := []string{"2026-03", "2026-04", "2026-05"}
var buf bytes.Buffer
printFeesTable(&buf, members, sortedMonths)
got := buf.String()
// Verify structure
if !strings.Contains(got, "Member") {
t.Error("missing header 'Member'")
}
if !strings.Contains(got, "Mar 2026") || !strings.Contains(got, "Apr 2026") || !strings.Contains(got, "May 2026") {
t.Error("missing month labels")
}
if strings.Contains(got, "Carol") {
t.Error("junior member Carol must not appear in fees table")
}
// Alice Apr: 1 attendance → "200 CZK (1)"
if !strings.Contains(got, "200 CZK (1)") {
t.Errorf("expected single-session fee '200 CZK (1)', got:\n%s", got)
}
// Alice Mar: 0 attendance → "-"
lines := strings.Split(got, "\n")
aliceLine := ""
for _, l := range lines {
if strings.HasPrefix(strings.TrimSpace(l), "Alice") {
aliceLine = l
break
}
}
if aliceLine == "" {
t.Fatal("no Alice line found")
}
// Alice's first col (Mar 2026) should be "-"
if !strings.Contains(aliceLine, "-") {
t.Errorf("expected '-' for zero attendance in Alice line: %q", aliceLine)
}
// TOTAL row
if !strings.Contains(got, "TOTAL") {
t.Error("missing TOTAL row")
}
// Total for May 2026 = 700 CZK
if !strings.Contains(got, "700 CZK") {
t.Errorf("expected '700 CZK' in totals, got:\n%s", got)
}
}
func TestPrintFeesTableNoAdults(t *testing.T) {
t.Parallel()
members := []reconcile.Member{
{Name: "X", Tier: "J", Fees: map[string]reconcile.FeeData{}},
}
var buf bytes.Buffer
printFeesTable(&buf, members, []string{"2026-04"})
if buf.String() != "No data.\n" {
t.Errorf("want 'No data.', got %q", buf.String())
}
}
func TestPrintFeesTableEmpty(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
printFeesTable(&buf, nil, nil)
if buf.String() != "No data.\n" {
t.Errorf("want 'No data.', got %q", buf.String())
}
}

View File

@@ -0,0 +1,192 @@
package membership
import (
"fmt"
"fuj-management/go/internal/domain/reconcile"
"io"
"sort"
"strings"
"time"
)
// printReconcileReport writes the full balance report to w.
// Mirrors scripts/match_payments.py print_report().
//
// Verify with:
//
// PYTHONPATH=scripts:. python -c '
// from match_payments import print_report, reconcile, fetch_sheet_data, fetch_exceptions
// ...'
func printReconcileReport(w io.Writer, result reconcile.Result, sortedMonths []string) {
monthLabel := func(m string) string {
t, err := time.Parse("2006-01", m)
if err != nil {
return m
}
return t.Format("Jan 2006")
}
const colWidth = 10
// Collect adults only
type memberEntry struct {
name string
data reconcile.MemberResult
}
var adults []memberEntry
for name, data := range result.Members {
if data.Tier == "A" {
adults = append(adults, memberEntry{name: name, data: data})
}
}
sort.Slice(adults, func(i, j int) bool { return adults[i].name < adults[j].name })
// Header banner
fmt.Fprintln(w, strings.Repeat("=", 80))
fmt.Fprintln(w, "PAYMENT RECONCILIATION REPORT")
fmt.Fprintln(w, strings.Repeat("=", 80))
// Name column width
nameWidth := 20
for _, e := range adults {
if len(e.name) > nameWidth {
nameWidth = len(e.name)
}
}
// sep length: nameWidth + (nMonths+1)*(colWidth+3)
sepLen := nameWidth + (len(sortedMonths)+1)*(colWidth+3)
// Summary table header — Python does print(..., end="") then print(f" | {'Balance':>10}")
fmt.Fprintf(w, "\n%-*s", nameWidth, "Member")
for _, m := range sortedMonths {
fmt.Fprintf(w, " | %*s", colWidth, monthLabel(m))
}
fmt.Fprintf(w, " | %*s\n", colWidth, "Balance")
fmt.Fprintln(w, strings.Repeat("-", sepLen))
var totalExpected, totalPaid int
for _, e := range adults {
fmt.Fprintf(w, "%-*s", nameWidth, e.name)
memberBalance := 0
for _, m := range sortedMonths {
md := e.data.Months[m]
expected := md.Expected
paid := int(md.Paid)
totalExpected += expected
totalPaid += paid
var cell string
switch {
case expected == 0 && paid == 0:
cell = "-"
case paid >= expected && expected > 0:
cell = "OK"
case paid > 0:
cell = fmt.Sprintf("%d/%d", paid, expected)
default:
cell = fmt.Sprintf("UNPAID %d", expected)
}
memberBalance += paid - expected
fmt.Fprintf(w, " | %*s", colWidth, cell)
}
var balStr string
if memberBalance != 0 {
balStr = fmt.Sprintf("%+d", memberBalance)
} else {
balStr = "0"
}
fmt.Fprintf(w, " | %*s\n", colWidth, balStr)
}
// TOTAL footer
fmt.Fprintln(w, strings.Repeat("-", sepLen))
fmt.Fprintf(w, "%-*s", nameWidth, "TOTAL")
for range sortedMonths {
fmt.Fprintf(w, " | %*s", colWidth, "")
}
balance := totalPaid - totalExpected
fmt.Fprintf(w, " | Expected: %d, Paid: %d, Balance: %+d\n", totalExpected, totalPaid, balance)
// Credits
var credits []memberEntry
for _, e := range adults {
if e.data.TotalBalance > 0 {
credits = append(credits, e)
}
}
// also non-adult members with positive balance
for name, data := range result.Members {
if data.Tier != "A" && data.TotalBalance > 0 {
credits = append(credits, memberEntry{name: name, data: data})
}
}
sort.Slice(credits, func(i, j int) bool { return credits[i].name < credits[j].name })
if len(credits) > 0 {
fmt.Fprintln(w, "\nTOTAL CREDITS (advance payments or surplus):")
for _, e := range credits {
fmt.Fprintf(w, " %s: %d CZK\n", e.name, e.data.TotalBalance)
}
}
// Debts
var debts []memberEntry
for _, e := range adults {
if e.data.TotalBalance < 0 {
debts = append(debts, e)
}
}
for name, data := range result.Members {
if data.Tier != "A" && data.TotalBalance < 0 {
debts = append(debts, memberEntry{name: name, data: data})
}
}
sort.Slice(debts, func(i, j int) bool { return debts[i].name < debts[j].name })
if len(debts) > 0 {
fmt.Fprintln(w, "\nTOTAL DEBTS (missing payments):")
for _, e := range debts {
fmt.Fprintf(w, " %s: %d CZK\n", e.name, -e.data.TotalBalance)
}
}
// Unmatched transactions
if len(result.Unmatched) > 0 {
fmt.Fprintln(w, "\nUNMATCHED TRANSACTIONS (need manual review)")
fmt.Fprintf(w, " %-12s %10s %-30s %s\n", "Date", "Amount", "Sender", "Message")
fmt.Fprintf(w, " %-12s %10s %-30s %-30s\n",
strings.Repeat("-", 12), strings.Repeat("-", 10),
strings.Repeat("-", 30), strings.Repeat("-", 30))
for _, tx := range result.Unmatched {
fmt.Fprintf(w, " %-12s %10.0f %-30s %s\n",
tx.Date, tx.Amount, tx.Sender, tx.Message)
}
}
// Matched transaction details
fmt.Fprintln(w, "\nMATCHED TRANSACTION DETAILS")
for _, e := range adults {
hasPayments := false
for _, m := range sortedMonths {
if len(e.data.Months[m].Transactions) > 0 {
hasPayments = true
break
}
}
if !hasPayments {
continue
}
fmt.Fprintf(w, "\n %s:\n", e.name)
for _, m := range sortedMonths {
for _, tx := range e.data.Months[m].Transactions {
conf := ""
if tx.Confidence == "review" {
conf = " [REVIEW]"
}
fmt.Fprintf(w, " %s: %.0f CZK from %s — \"%s\"%s\n",
monthLabel(m), tx.Amount, tx.Sender, tx.Message, conf)
}
}
}
}

View File

@@ -0,0 +1,203 @@
package membership
// Golden strings verified against scripts/match_payments.py print_report() on 2026-05-06:
//
// PYTHONPATH=scripts:. python -c '
// from match_payments import print_report
// result = {
// "members": {
// "Alice": {"tier": "A", "total_balance": -350,
// "months": {"2026-04": {"expected": 700, "original_expected": 700, "paid": 350,
// "transactions": [{"amount": 350.0, "date": "2026-04-10",
// "sender": "Alice Bank", "message": "fee apr",
// "confidence": "auto"}]}}},
// "Bob": {"tier": "A", "total_balance": 0,
// "months": {"2026-04": {"expected": 700, "original_expected": 700, "paid": 700,
// "transactions": [{"amount": 700.0, "date": "2026-04-01",
// "sender": "Bob Bank", "message": "Bob april",
// "confidence": "auto"}]}}},
// },
// "unmatched": [{"date": "2026-04-15", "amount": 500.0, "sender": "Unknown", "message": "?"}],
// }
// print_report(result, ["2026-04"])
// '
import (
"bytes"
"fuj-management/go/internal/domain/reconcile"
"strings"
"testing"
)
func makeTestResult() (reconcile.Result, []string) {
sortedMonths := []string{"2026-04"}
aliceApr := reconcile.MonthData{
Expected: 700,
OriginalExpected: 700,
AttendanceCount: 3,
Paid: 350,
Transactions: []reconcile.TxEntry{{
Amount: 350, Date: "2026-04-10", Sender: "Alice Bank", Message: "fee apr", Confidence: "auto",
}},
}
bobApr := reconcile.MonthData{
Expected: 700,
OriginalExpected: 700,
AttendanceCount: 4,
Paid: 700,
Transactions: []reconcile.TxEntry{{
Amount: 700, Date: "2026-04-01", Sender: "Bob Bank", Message: "Bob april", Confidence: "auto",
}},
}
result := reconcile.Result{
Members: map[string]reconcile.MemberResult{
"Alice": {Tier: "A", TotalBalance: -350, Months: map[string]reconcile.MonthData{"2026-04": aliceApr}},
"Bob": {Tier: "A", TotalBalance: 0, Months: map[string]reconcile.MonthData{"2026-04": bobApr}},
},
Unmatched: []reconcile.Transaction{{
Date: "2026-04-15", Amount: 500, Sender: "Unknown", Message: "?",
}},
}
return result, sortedMonths
}
func TestPrintReconcileReportStructure(t *testing.T) {
t.Parallel()
result, sortedMonths := makeTestResult()
var buf bytes.Buffer
printReconcileReport(&buf, result, sortedMonths)
got := buf.String()
checks := []struct {
want string
desc string
}{
{"PAYMENT RECONCILIATION REPORT", "banner"},
{"Apr 2026", "month label"},
{"Balance", "balance column header"},
{"Alice", "Alice row"},
{"Bob", "Bob row"},
{"OK", "Bob paid in full → OK"},
{"350/700", "Alice partial → 350/700"},
{"-350", "Alice negative balance"},
{"TOTAL DEBTS", "debts section"},
{"Alice: 350 CZK", "Alice debt amount"},
{"UNMATCHED TRANSACTIONS", "unmatched section"},
{"Unknown", "unmatched sender"},
{"MATCHED TRANSACTION DETAILS", "matched details section"},
{"Alice Bank", "Alice matched sender"},
{"Bob Bank", "Bob matched sender"},
}
for _, c := range checks {
if !strings.Contains(got, c.want) {
t.Errorf("missing %s: want %q in output:\n%s", c.desc, c.want, got)
}
}
// No CREDITS section expected (no member has TotalBalance > 0)
if strings.Contains(got, "TOTAL CREDITS") {
t.Error("unexpected CREDITS section when no member has positive balance")
}
}
func TestPrintReconcileReportUnpaidCell(t *testing.T) {
t.Parallel()
result := reconcile.Result{
Members: map[string]reconcile.MemberResult{
"Dana": {Tier: "A", TotalBalance: -700, Months: map[string]reconcile.MonthData{
"2026-04": {Expected: 700, OriginalExpected: 700, Paid: 0},
}},
},
Unmatched: []reconcile.Transaction{},
}
var buf bytes.Buffer
printReconcileReport(&buf, result, []string{"2026-04"})
got := buf.String()
if !strings.Contains(got, "UNPAID 700") {
t.Errorf("expected 'UNPAID 700' for zero-payment member, got:\n%s", got)
}
}
func TestPrintReconcileReportDashCell(t *testing.T) {
t.Parallel()
result := reconcile.Result{
Members: map[string]reconcile.MemberResult{
"Eve": {Tier: "A", TotalBalance: 0, Months: map[string]reconcile.MonthData{
"2026-04": {Expected: 0, Paid: 0},
}},
},
Unmatched: []reconcile.Transaction{},
}
var buf bytes.Buffer
printReconcileReport(&buf, result, []string{"2026-04"})
got := buf.String()
eveLine := ""
for _, l := range strings.Split(got, "\n") {
if strings.HasPrefix(strings.TrimSpace(l), "Eve") {
eveLine = l
break
}
}
if eveLine == "" {
t.Fatal("no Eve line found")
}
if !strings.Contains(eveLine, "-") {
t.Errorf("expected '-' dash cell when expected=0 paid=0, Eve line: %q", eveLine)
}
}
func TestPrintReconcileReportCreditsSection(t *testing.T) {
t.Parallel()
result := reconcile.Result{
Members: map[string]reconcile.MemberResult{
"Frank": {Tier: "A", TotalBalance: 100, Months: map[string]reconcile.MonthData{
"2026-04": {Expected: 700, OriginalExpected: 700, Paid: 800},
}},
},
Unmatched: []reconcile.Transaction{},
}
var buf bytes.Buffer
printReconcileReport(&buf, result, []string{"2026-04"})
got := buf.String()
if !strings.Contains(got, "TOTAL CREDITS") {
t.Errorf("expected CREDITS section, got:\n%s", got)
}
if !strings.Contains(got, "Frank: 100 CZK") {
t.Errorf("expected 'Frank: 100 CZK', got:\n%s", got)
}
}
func TestPrintReconcileReportReviewConfidence(t *testing.T) {
t.Parallel()
result := reconcile.Result{
Members: map[string]reconcile.MemberResult{
"Grace": {Tier: "A", TotalBalance: 0, Months: map[string]reconcile.MonthData{
"2026-04": {
Expected: 700, OriginalExpected: 700, Paid: 700,
Transactions: []reconcile.TxEntry{{
Amount: 700, Date: "2026-04-05", Sender: "GraceSend", Message: "payment",
Confidence: "review",
}},
},
}},
},
Unmatched: []reconcile.Transaction{},
}
var buf bytes.Buffer
printReconcileReport(&buf, result, []string{"2026-04"})
got := buf.String()
if !strings.Contains(got, "[REVIEW]") {
t.Errorf("expected '[REVIEW]' annotation for review-confidence tx, got:\n%s", got)
}
}

View File

@@ -0,0 +1,50 @@
package membership
import (
"context"
"errors"
"fuj-management/go/internal/domain/reconcile"
)
// ErrIOPending is returned by stub loader methods until the M4 IO layer lands.
var ErrIOPending = errors.New("io layer not yet wired up; lands in milestone M4 (sheets/drive/fio)")
// AttendanceLoader loads processed adult attendance + computed fees from the
// attendance Google Sheet.
type AttendanceLoader interface {
LoadAdults(ctx context.Context) (members []reconcile.Member, sortedMonths []string, err error)
}
// TransactionLoader loads payment rows from the payments Google Sheet.
type TransactionLoader interface {
LoadTransactions(ctx context.Context) ([]reconcile.Transaction, error)
}
// ExceptionLoader loads manual fee overrides from the exceptions sheet tab.
type ExceptionLoader interface {
LoadExceptions(ctx context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error)
}
// Sources is the aggregate interface required by ReconcileReport.
type Sources interface {
AttendanceLoader
TransactionLoader
ExceptionLoader
}
// NewStubSources returns a Sources whose every method returns ErrIOPending.
func NewStubSources() Sources { return stubSources{} }
type stubSources struct{}
func (stubSources) LoadAdults(_ context.Context) ([]reconcile.Member, []string, error) {
return nil, nil, ErrIOPending
}
func (stubSources) LoadTransactions(_ context.Context) ([]reconcile.Transaction, error) {
return nil, ErrIOPending
}
func (stubSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
return nil, ErrIOPending
}

View File

@@ -0,0 +1,30 @@
package membership
import (
"context"
"io"
domreconcile "fuj-management/go/internal/domain/reconcile"
)
// ReconcileReport loads attendance, transactions, and exceptions via s, runs
// the three-phase reconciliation, and writes the balance report to out.
// Returns ErrIOPending until real loaders are injected in M4.
func ReconcileReport(ctx context.Context, s Sources, defaultYear int, out io.Writer) error {
members, sortedMonths, err := s.LoadAdults(ctx)
if err != nil {
return err
}
txns, err := s.LoadTransactions(ctx)
if err != nil {
return err
}
exceptions, err := s.LoadExceptions(ctx)
if err != nil {
return err
}
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, defaultYear)
printReconcileReport(out, result, sortedMonths)
return nil
}

View File

@@ -0,0 +1,68 @@
package membership
import (
"bytes"
"context"
"fuj-management/go/internal/domain/reconcile"
"strings"
"testing"
)
type fakeSources struct {
members []reconcile.Member
months []string
txns []reconcile.Transaction
exceptions map[reconcile.ExceptionKey]reconcile.Exception
}
func (f fakeSources) LoadAdults(_ context.Context) ([]reconcile.Member, []string, error) {
return f.members, f.months, nil
}
func (f fakeSources) LoadTransactions(_ context.Context) ([]reconcile.Transaction, error) {
return f.txns, nil
}
func (f fakeSources) LoadExceptions(_ context.Context) (map[reconcile.ExceptionKey]reconcile.Exception, error) {
return f.exceptions, nil
}
func TestReconcileReport(t *testing.T) {
t.Parallel()
s := fakeSources{
members: []reconcile.Member{
{Name: "Alice", Tier: "A", Fees: map[string]reconcile.FeeData{
"2026-04": {Expected: 700, Attendance: 3},
}},
},
months: []string{"2026-04"},
txns: []reconcile.Transaction{
{
Date: "2026-04-10", Amount: 700, Person: "Alice", Purpose: "2026-04",
Sender: "Alice Bank", Message: "fee",
},
},
exceptions: map[reconcile.ExceptionKey]reconcile.Exception{},
}
var buf bytes.Buffer
if err := ReconcileReport(context.Background(), s, 2026, &buf); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := buf.String()
if !strings.Contains(got, "PAYMENT RECONCILIATION REPORT") {
t.Error("missing report header")
}
if !strings.Contains(got, "OK") {
t.Errorf("expected 'OK' for fully-paid Alice, got:\n%s", got)
}
}
func TestReconcileReportStubErrors(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
err := ReconcileReport(context.Background(), NewStubSources(), 2026, &buf)
if err == nil {
t.Fatal("expected error from stub, got nil")
}
}

View File

@@ -0,0 +1,27 @@
package membership
import (
"context"
"errors"
"testing"
)
func TestStubLoaderReturnsErrIOPending(t *testing.T) {
t.Parallel()
s := NewStubSources()
_, _, err := s.LoadAdults(context.Background())
if !errors.Is(err, ErrIOPending) {
t.Errorf("LoadAdults: want ErrIOPending, got %v", err)
}
_, err = s.LoadTransactions(context.Background())
if !errors.Is(err, ErrIOPending) {
t.Errorf("LoadTransactions: want ErrIOPending, got %v", err)
}
_, err = s.LoadExceptions(context.Background())
if !errors.Is(err, ErrIOPending) {
t.Errorf("LoadExceptions: want ErrIOPending, got %v", err)
}
}

View File

@@ -0,0 +1,34 @@
package middleware
import (
"log/slog"
"net/http"
"time"
)
type statusWriter struct {
http.ResponseWriter
status int
}
func (sw *statusWriter) WriteHeader(code int) {
sw.status = code
sw.ResponseWriter.WriteHeader(code)
}
// RequestTimer logs method, path, status, and elapsed milliseconds for every
// request. Parity with Python's get_render_time — the elapsed value maps to
// render_time.total in the M5 JSON allowlist.
func RequestTimer(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
logger.Info("req",
"method", r.Method,
"path", r.URL.Path,
"status", sw.status,
"ms", time.Since(start).Milliseconds(),
)
})
}

32
go/internal/web/server.go Normal file
View File

@@ -0,0 +1,32 @@
package web
import (
"fmt"
"fuj-management/go/internal/web/middleware"
"log/slog"
"net/http"
)
// BuildInfo carries the linker-injected build metadata.
type BuildInfo struct {
Version string
Commit string
BuildDate string
}
// Run registers routes and starts the HTTP server on addr.
func Run(logger *slog.Logger, addr string, build BuildInfo) error {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", helloHandler(build))
logger.Info("starting server", "addr", addr)
return http.ListenAndServe(addr, middleware.RequestTimer(logger, mux))
}
func helloHandler(build BuildInfo) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "fuj-go ok\nversion: %s\ncommit: %s\nbuilt: %s\n",
build.Version, build.Commit, build.BuildDate)
}
}

128
go/tests/fixtures/README.md vendored Normal file
View File

@@ -0,0 +1,128 @@
# Parity Fixtures
Captured outputs from the live Python implementation used as ground truth for
the Go parity test suite. All 98 files are committed and PII-free.
## Directory layout
```
fixtures/
pure/
normalize/ # scripts.czech_utils.normalize
parse_month_references/ # scripts.czech_utils.parse_month_references
calculate_fee/ # scripts.attendance.calculate_fee
calculate_junior_fee/ # scripts.attendance.calculate_junior_fee
parse_czk_amount/ # scripts.infer_payments.parse_czk_amount
generate_sync_id/ # scripts.sync_fio_to_sheets.generate_sync_id
build_name_variants/ # scripts.match_payments._build_name_variants
match_members/ # scripts.match_payments.match_members
infer_transaction_details/ # scripts.match_payments.infer_transaction_details
format_date/ # scripts.match_payments.format_date
reconcile/ # scripts.match_payments.reconcile (10 branch-coverage cases)
```
## Fixture format
One JSON object per file:
```json
{
"case": "range_wrap_nov_to_jan",
"func": "scripts.czech_utils.parse_month_references",
"captured_at": "2026-05-06",
"input": { "text": "...", "default_year": 2026 },
"output": { "months": ["2025-11", "2025-12", "2026-01"] }
}
```
`captured_at` is date-only so same-day re-runs produce byte-identical files.
### Amount type envelope
Four fields carry a type envelope to distinguish Python `int` / `float` / `None`:
```json
{"type": "int", "value": 750}
{"type": "float", "value": 750.0}
{"type": "string", "value": "..."}
{"type": "none"}
```
Fields that use envelopes: `generate_sync_id.tx.amount`, `parse_czk_amount.val`,
`format_date.val`, `infer_transaction_details.tx.date`.
### Reconcile member format
Reconcile input members use a named dict to allow consistent PII scrubbing:
```json
{"name": "Member_d035d9f9", "tier": "A", "fees": {"2026-01": [750, 3]}}
```
## Running the parity tests
```bash
make go-parity # run all parity tests
make go-test-all # unit tests + parity tests
```
Or directly:
```bash
cd go && go test -tags=parity ./tests/parity/...
cd go && go test -tags=parity -v -run TestReconcileParity ./tests/parity/reconcile/
```
## Refresh workflow
Regenerate the entire corpus from the live Python implementation:
```bash
make capture-fixtures
git diff go/tests/fixtures/ # review changes before committing
```
To refresh a single function:
```bash
PYTHONPATH=scripts:. python3 scripts/capture_fixtures.py --func normalize --all \
| while IFS= read -r line; do
id=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin)['case'])")
echo "$line" | python3 scripts/scrub_fixtures.py \
> go/tests/fixtures/pure/normalize/${id}.json
done
```
## When to refresh
- A ported function is intentionally changed to match updated Python behaviour.
- A new Czech declension or fee tier is added to the Python implementation.
- A new reconcile code path needs fixture coverage.
**Do not refresh to silence a failing parity test** without first confirming that
the Python behaviour is the correct reference. A parity failure means either the
Go port diverges or the Python implementation changed — diagnose before regenerating.
## PII scrubbing audit
No real member names should appear in committed fixtures. Before committing any
regenerated fixtures, verify with:
```bash
# Replace with names from the real roster to check:
git ls-files go/tests/fixtures | xargs grep -l "Real Name Here" | head
```
The scrubber applies deterministic SHA-256 pseudonyms (`Member_<8hex>`) to all
PII fields. `match_members` and `infer_transaction_details` fixtures use a
synthetic roster of fictional names and are exempt from field-key scrubbing;
verify that no real roster names appear in their `member_names` arrays.
## Adding a new fixture
1. Add a seed to `scripts/_fixture_seeds.py` under `SEEDS[("func_name", "case_id")]`.
2. Add `In`/`Out` struct fields to `go/tests/parity/parityio.go` if the function
is new.
3. Run the single-file capture recipe above and review the diff.
4. The parity test picks up new fixtures automatically — no test code changes needed
(unless the function itself is new).

View File

@@ -0,0 +1,15 @@
{
"case": "common_diacritics",
"func": "scripts.match_payments._build_name_variants",
"captured_at": "2026-05-06",
"input": {
"full_name": "Alžběta Testovická"
},
"output": {
"variants": [
"alzbeta testovicka",
"testovicka",
"alzbeta"
]
}
}

View File

@@ -0,0 +1,15 @@
{
"case": "full_name_no_nick",
"func": "scripts.match_payments._build_name_variants",
"captured_at": "2026-05-06",
"input": {
"full_name": "Jan Novák"
},
"output": {
"variants": [
"jan novak",
"novak",
"jan"
]
}
}

View File

@@ -0,0 +1,11 @@
{
"case": "short_name_filtered",
"func": "scripts.match_payments._build_name_variants",
"captured_at": "2026-05-06",
"input": {
"full_name": "Jo"
},
"output": {
"variants": []
}
}

View File

@@ -0,0 +1,13 @@
{
"case": "single_word",
"func": "scripts.match_payments._build_name_variants",
"captured_at": "2026-05-06",
"input": {
"full_name": "Jáchym"
},
"output": {
"variants": [
"jachym"
]
}
}

View File

@@ -0,0 +1,16 @@
{
"case": "three_word_name",
"func": "scripts.match_payments._build_name_variants",
"captured_at": "2026-05-06",
"input": {
"full_name": "Jan Tomášek (Honza)"
},
"output": {
"variants": [
"jan tomasek",
"honza",
"tomasek",
"jan"
]
}
}

View File

@@ -0,0 +1,16 @@
{
"case": "with_nickname",
"func": "scripts.match_payments._build_name_variants",
"captured_at": "2026-05-06",
"input": {
"full_name": "František Vrbík (Štrúdl)"
},
"output": {
"variants": [
"frantisek vrbik",
"strudl",
"vrbik",
"frantisek"
]
}
}

View File

@@ -0,0 +1,12 @@
{
"case": "one_session",
"func": "scripts.attendance.calculate_fee",
"captured_at": "2026-05-06",
"input": {
"attendance_count": 1,
"month_key": "2026-01"
},
"output": {
"fee": 200
}
}

View File

@@ -0,0 +1,12 @@
{
"case": "three_sessions_known_rate",
"func": "scripts.attendance.calculate_fee",
"captured_at": "2026-05-06",
"input": {
"attendance_count": 3,
"month_key": "2026-02"
},
"output": {
"fee": 750
}
}

View File

@@ -0,0 +1,12 @@
{
"case": "two_sessions_default_fallback",
"func": "scripts.attendance.calculate_fee",
"captured_at": "2026-05-06",
"input": {
"attendance_count": 2,
"month_key": "2099-01"
},
"output": {
"fee": 700
}
}

View File

@@ -0,0 +1,12 @@
{
"case": "two_sessions_known_rate",
"func": "scripts.attendance.calculate_fee",
"captured_at": "2026-05-06",
"input": {
"attendance_count": 2,
"month_key": "2026-01"
},
"output": {
"fee": 750
}
}

View File

@@ -0,0 +1,12 @@
{
"case": "two_sessions_reduced_march",
"func": "scripts.attendance.calculate_fee",
"captured_at": "2026-05-06",
"input": {
"attendance_count": 2,
"month_key": "2026-03"
},
"output": {
"fee": 350
}
}

View File

@@ -0,0 +1,12 @@
{
"case": "zero_sessions",
"func": "scripts.attendance.calculate_fee",
"captured_at": "2026-05-06",
"input": {
"attendance_count": 0,
"month_key": "2026-01"
},
"output": {
"fee": 0
}
}

View File

@@ -0,0 +1,13 @@
{
"case": "one_session_unknown",
"func": "scripts.attendance.calculate_junior_fee",
"captured_at": "2026-05-06",
"input": {
"attendance_count": 1,
"month_key": "2026-01"
},
"output": {
"value": 0,
"unknown": true
}
}

View File

@@ -0,0 +1,13 @@
{
"case": "two_sessions_default",
"func": "scripts.attendance.calculate_junior_fee",
"captured_at": "2026-05-06",
"input": {
"attendance_count": 2,
"month_key": "2026-01"
},
"output": {
"value": 500,
"unknown": false
}
}

View File

@@ -0,0 +1,13 @@
{
"case": "two_sessions_default_fallback",
"func": "scripts.attendance.calculate_junior_fee",
"captured_at": "2026-05-06",
"input": {
"attendance_count": 2,
"month_key": "2099-06"
},
"output": {
"value": 500,
"unknown": false
}
}

View File

@@ -0,0 +1,13 @@
{
"case": "two_sessions_reduced_march",
"func": "scripts.attendance.calculate_junior_fee",
"captured_at": "2026-05-06",
"input": {
"attendance_count": 2,
"month_key": "2026-03"
},
"output": {
"value": 250,
"unknown": false
}
}

View File

@@ -0,0 +1,13 @@
{
"case": "two_sessions_reduced_sep",
"func": "scripts.attendance.calculate_junior_fee",
"captured_at": "2026-05-06",
"input": {
"attendance_count": 2,
"month_key": "2025-09"
},
"output": {
"value": 250,
"unknown": false
}
}

View File

@@ -0,0 +1,13 @@
{
"case": "zero_sessions",
"func": "scripts.attendance.calculate_junior_fee",
"captured_at": "2026-05-06",
"input": {
"attendance_count": 0,
"month_key": "2026-01"
},
"output": {
"value": 0,
"unknown": false
}
}

View File

@@ -0,0 +1,14 @@
{
"case": "empty_string",
"func": "scripts.match_payments.format_date",
"captured_at": "2026-05-06",
"input": {
"val": {
"type": "string",
"value": ""
}
},
"output": {
"date": ""
}
}

View File

@@ -0,0 +1,13 @@
{
"case": "none_value",
"func": "scripts.match_payments.format_date",
"captured_at": "2026-05-06",
"input": {
"val": {
"type": "none"
}
},
"output": {
"date": ""
}
}

View File

@@ -0,0 +1,14 @@
{
"case": "serial_float",
"func": "scripts.match_payments.format_date",
"captured_at": "2026-05-06",
"input": {
"val": {
"type": "float",
"value": 46027.5
}
},
"output": {
"date": "2026-01-05"
}
}

View File

@@ -0,0 +1,14 @@
{
"case": "serial_float_exact",
"func": "scripts.match_payments.format_date",
"captured_at": "2026-05-06",
"input": {
"val": {
"type": "float",
"value": 45957.0
}
},
"output": {
"date": "2025-10-27"
}
}

View File

@@ -0,0 +1,14 @@
{
"case": "serial_int",
"func": "scripts.match_payments.format_date",
"captured_at": "2026-05-06",
"input": {
"val": {
"type": "int",
"value": 46027
}
},
"output": {
"date": "2026-01-05"
}
}

View File

@@ -0,0 +1,14 @@
{
"case": "string_iso",
"func": "scripts.match_payments.format_date",
"captured_at": "2026-05-06",
"input": {
"val": {
"type": "string",
"value": "2026-01-15"
}
},
"output": {
"date": "2026-01-15"
}
}

View File

@@ -0,0 +1,14 @@
{
"case": "string_non_iso",
"func": "scripts.match_payments.format_date",
"captured_at": "2026-05-06",
"input": {
"val": {
"type": "string",
"value": "garbage"
}
},
"output": {
"date": "garbage"
}
}

View File

@@ -0,0 +1,22 @@
{
"case": "empty_fields",
"func": "scripts.sync_fio_to_sheets.generate_sync_id",
"captured_at": "2026-05-06",
"input": {
"tx": {
"date": "2026-03-01",
"amount": {
"type": "float",
"value": 0.0
},
"currency": "CZK",
"sender": "",
"vs": "",
"message": "",
"bank_id": ""
}
},
"output": {
"sync_id": "80d5f2762dbe807adde8dab64c3f3f00936ceafc75d4ceba232b08c09bb71c60"
}
}

View File

@@ -0,0 +1,22 @@
{
"case": "integer_amount",
"func": "scripts.sync_fio_to_sheets.generate_sync_id",
"captured_at": "2026-05-06",
"input": {
"tx": {
"date": "2026-01-15",
"amount": {
"type": "int",
"value": 750
},
"currency": "CZK",
"sender": "Member_9b16314c",
"vs": "864722",
"message": "pausal leden",
"bank_id": "983770300"
}
},
"output": {
"sync_id": "155e983a0a3a11210e19728c427395f6681ee5d2a0ef3b60438e6efeaf3775df"
}
}

View File

@@ -0,0 +1,22 @@
{
"case": "large_amount",
"func": "scripts.sync_fio_to_sheets.generate_sync_id",
"captured_at": "2026-05-06",
"input": {
"tx": {
"date": "2025-10-05",
"amount": {
"type": "float",
"value": 2100.0
},
"currency": "CZK",
"sender": "Member_bd5eb92a",
"vs": "110515",
"message": "FUJ treninky",
"bank_id": "609470745"
}
},
"output": {
"sync_id": "639d98f8ab8e6954b7e4d31508936cc4366ee0281eebc860338585cdeda43ae3"
}
}

View File

@@ -0,0 +1,21 @@
{
"case": "missing_currency",
"func": "scripts.sync_fio_to_sheets.generate_sync_id",
"captured_at": "2026-05-06",
"input": {
"tx": {
"date": "2026-02-01",
"amount": {
"type": "float",
"value": 500.0
},
"sender": "Member_32a79b03",
"vs": "720261",
"message": "trenink",
"bank_id": "072657565"
}
},
"output": {
"sync_id": "8bd2cc2c2e6b376ad2d2501f72ee5d987fdca37662c4be0b9bb5345dcb28553d"
}
}

View File

@@ -0,0 +1,22 @@
{
"case": "typical_float_amount",
"func": "scripts.sync_fio_to_sheets.generate_sync_id",
"captured_at": "2026-05-06",
"input": {
"tx": {
"date": "2026-01-15",
"amount": {
"type": "float",
"value": 750.0
},
"currency": "CZK",
"sender": "Member_9b16314c",
"vs": "864722",
"message": "pausal leden",
"bank_id": "983770300"
}
},
"output": {
"sync_id": "155e983a0a3a11210e19728c427395f6681ee5d2a0ef3b60438e6efeaf3775df"
}
}

View File

@@ -0,0 +1,36 @@
{
"case": "member_in_message",
"func": "scripts.match_payments.infer_transaction_details",
"captured_at": "2026-05-06",
"input": {
"tx": {
"sender": "Test Payer",
"message": "alzbeta testovicka leden 2026",
"user_id": "",
"date": {
"type": "string",
"value": "2026-01-15"
}
},
"member_names": [
"Alžběta Testovická",
"Tomáš Fiktivný (Tov)",
"Pavel Smutný (Štrúdl)",
"Jana Nováková",
"Adam Novák"
],
"default_year": 2026
},
"output": {
"matches": [
{
"name": "Alžběta Testovická",
"confidence": "auto"
}
],
"months": [
"2026-01"
],
"search_text": "Test Payer alzbeta testovicka leden 2026 "
}
}

Some files were not shown because too many files have changed in this diff Show More