Files
fuj-management/CLAUDE.md
T
kacerrandClaude Opus 5.5 bbad19d822 feat(infer): messages starting with "other:" are not monthly fees
When the bank message starts with "other:" (case-insensitive),
inference does not look for a month, neither in the text nor from the
payment date. The trimmed message is written to Purpose, so reconcile
books the payment as an "other" transaction for the person instead of
a monthly fee. Messages containing "other:" elsewhere are unchanged.

- matching.IsOtherPurpose / is_other_purpose shared by inference and
  reconcile
- reconcile applies the rule to rows not yet processed by inference
- no person found: Purpose is still written, row shows as unmatched
- implemented in Go and legacy Python; four new parity fixtures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 12:37:03 +02:00

14 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project Status

Financial management system for FUJ (Frisbee Ultimate Jablonec). Handles attendance-based fee calculation, Fio bank transaction sync, payment reconciliation, and a web dashboard.

  • Go is the primary implementation (go/). It is the version that is built, deployed and developed first.
  • Python is legacy (legacy/). The project was originally developed in Python (Flask); that version is kept runnable and in sync, but is considered legacy from now on.

Dual implementation rule

Until the Python version is retired, every new feature and every behaviour change must be implemented in both Go and Python:

  1. Implement and test in Go first (go/).
  2. Mirror the change in the legacy Python code (legacy/), including its tests.
  3. Keep the two in parity: shared constants (fee rates, merged months, bank accounts, sheet IDs) must match on both sides, and the JSON API contract (/api/adults, /api/juniors, /api/payments) must stay identical. The HTML frontends are allowed to diverge.
  4. If a pure function's behaviour changes, add or update seeds in legacy/scripts/_fixture_seeds.py, run make capture-fixtures, and make sure make go-test-all passes. With both servers running, make parity diffs the live JSON APIs.

Go-only or Python-only changes are fine for things that exist on one side only (build, tooling, HTML/CSS/JS, refactors with no behaviour change).

Deliberately Go-only features

These exist only in the Go app on purpose. Do not port them to the legacy Python app, which is being phased out:

  • Scheduled bank sync — the Go server runs sync → infer → flush-cache by itself at the times in SYNC_SCHEDULE. Python's multi-worker gunicorn setup would need a cross-process lock for this, which is not worth building in code that is going away. In Python the sync still runs only from /sync-bank or the CLI.

Key Constraints

  • PII separation: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
  • Configuration: External service IDs, credentials, and tunable parameters are centralized in go/internal/config/config.go (mirrored in legacy/scripts/config.py). Domain-specific constants (fees, merged months) stay in their respective modules.

Development Setup

Go (primary): a recent Go toolchain (see go/go.mod) and golangci-lint.

make go-build     # builds bin/fuj

Python (legacy) uses uv; the project file and virtualenv live in legacy/.

make venv         # uv sync --project legacy  →  legacy/.venv

The Makefile sets PYTHONPATH (legacy/scripts:legacy) and CREDENTIALS_PATH (.secret/fuj-management-bot-credentials.json at the repo root) for the Python targets. Set both yourself when running Python scripts directly.

Commands

Go (primary):

make web-go         # Build and start dashboard at http://localhost:8080
make go-build       # Build bin/fuj
make go-test        # Unit tests (go test -race ./...)
make go-parity      # Parity tests against the captured Python fixtures
make go-test-all    # Both of the above
make go-lint        # golangci-lint
make go-run ARGS="fees"        # Run a subcommand: server, fees, reconcile, sync, infer, version
make go-sync-debug [DAYS=N]    # Dry-run bank sync with debug logs

Python (legacy):

make web-py       # Start dashboard at http://localhost:5001 (make web is an alias)
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-year    # Sync Fio transactions for the current year plus Nov-Dec of the previous one (YEAR=N to override)
make infer        # Auto-fill Person/Purpose/Amount columns in payments sheet
make reconcile    # Print balance report from Google Sheets data
make image        # Build the legacy Python Docker image

Cross-implementation:

make capture-fixtures   # Regenerate go/tests/fixtures from the Python implementation
make parity             # Diff /api/* between web-py (:5001) and web-go (:8080); both must be running

Run a single test:

cd go && go test ./internal/domain/reconcile -run TestName
cd legacy && PYTHONPATH=scripts:. .venv/bin/python -m unittest tests.test_app.TestWebApp.test_adults_route

Architecture

Data flow

Google Sheets (attendance) ──► attendance loader ──► reconcile ──► HTTP handlers ──► templates
Google Sheets (payments)   ──► payments loader   ──┘
Fio Bank API               ──► bank sync ──► Google Sheets (payments)

Repository layout

  • go/ — primary implementation (single binary fuj, templates and static assets embedded).
  • legacy/ — legacy Python implementation (app.py, scripts/, templates/, tests/, pyproject.toml, build/Dockerfile).
  • docs/ — documentation; docs/plans/ holds plans and task lists.
  • .gitea/workflows/ — CI: image builds on tag push, gitops image-update PR.

Key modules — Go (primary)

  • go/cmd/fuj — CLI entry point; subcommands server, fees, reconcile, sync, infer, version.
  • go/cmd/parity — diffs the JSON API of the Go and Python servers.
  • go/internal/config — sheet IDs, bank accounts, env-driven settings (CREDENTIALS_PATH, CACHE_DIR, CACHE_TTL_SECONDS, CACHE_API_CHECK_TTL_SECONDS, MONTHS_TO_SHOW, SERVER_ADDR, LOG_LEVEL, FIO_API_TOKEN_NEW / FIO_API_TOKEN_OLD, SYNC_SCHEDULE, SYNC_TZ).
  • go/internal/domain — pure logic: fees (rates, CalculateFee, CalculateJuniorFee), reconcile, matching (name matching, inference), czech (normalize, month parsing), money, synch (sync IDs).
  • go/internal/io — external systems: attendance (CSV export), sheets, drive, fio (API + transparent-page scraper), cache (file cache). Each has a fake for tests.
  • go/internal/services — membership (loading, merged months, fee and reconcile reports) and banksync (Fio sync + inference; Runner serialises manual and scheduled runs).
  • go/internal/scheduler — runs a job at fixed times of day; used by fuj server for the scheduled bank sync.
  • go/internal/web — routes for /adults, /juniors, /payments, /sync-bank, /flush-cache, /qr, /version, /api/*; templates/ and static/ are embedded.
  • go/tests — parity tests and the scrubbed fixture corpus captured from Python.

Key modules — Python (legacy)

  • legacy/app.py — Flask app; routes for /adults, /juniors, /payments, /sync-bank, /qr, /flush-cache, /api/*
  • legacy/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.
  • legacy/scripts/match_payments.py — reconcile() matches transactions to members/months. fetch_sheet_data() reads the payments sheet. fetch_exceptions() reads the exceptions tab.
  • legacy/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 legacy/tmp/.
  • legacy/scripts/sync_fio_to_sheets.py — Pulls Fio bank transactions and appends them to the payments Google Sheet.
  • legacy/scripts/infer_payments.py — Fills in Person/Purpose/Inferred Amount columns using name-matching heuristics.
  • legacy/scripts/views.py — View models shared by the HTML routes and the JSON API.
  • legacy/scripts/config.py — All external IDs, paths, and tunable TTLs. Override via env vars (CREDENTIALS_PATH, CACHE_TTL_SECONDS, MONTHS_TO_SHOW).
  • legacy/scripts/capture_fixtures.py, scrub_fixtures.py, _fixture_seeds.py — produce the scrubbed parity fixtures for Go.

Member tiers

Tiers are set in column B of the attendance sheet:

  • A — Adult, pays fees (per-month rate from AdultFeeMonthlyRate / ADULT_FEE_MONTHLY_RATE, fallback 600 CZK for 2+ sessions; 200 CZK for exactly 1, capped at the month's rate)
  • 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 (never more than the month's rate, so a 0-rate month is free), 2+ sessions → monthly rate (default 600 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

AdultMergedMonths / JuniorMergedMonths in go/internal/services/membership/sources.go (ADULT_MERGED_MONTHS / JUNIOR_MERGED_MONTHS in legacy/scripts/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

Both implementations check the Drive API modifiedTime and serve a cached JSON file when the sheet hasn't changed, falling back to 5-minute TTL buckets when the Drive API is unavailable. Go: go/internal/io/cache, files in tmp/go/ (CACHE_DIR). Python: get_cached_data() in legacy/app.py, files in legacy/tmp/, warmed up at startup (warmup_cache()). Flush via /flush-cache (POST).

Bank sync

  • Manual: open /sync-bank (both apps) or run fuj sync + fuj infer / make sync-year + make infer.
  • Scheduled (Go server only): at each time in SYNC_SCHEDULE (comma-separated HH:MM, default 04:00; off disables) read in SYNC_TZ (default Europe/Prague). Nothing runs at startup. Manual and scheduled runs share one lock and never overlap. The page footer shows the last run since start and the next one.
  • Both use the same window: 1 November of the previous year to 31 December of the current year; rows are de-duplicated by Sync ID.
  • The schedule runs per process: keep the deployment at one replica, and note that a local make web-go with real credentials also syncs at the scheduled time unless SYNC_SCHEDULE=off.

Payments sheet columns

Date | Amount | manual fix | Person | Purpose | Inferred Amount | Sender | VS | Message | Bank ID | Sync ID

Person and Purpose are written by the inference step (fuj infer / 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.

Non-fee payments ("other"): a Purpose that starts with other: (case-insensitive) is attached to the person's "other transactions" and is not counted against any month. Inference produces this by itself when the bank message starts with other:: it copies the trimmed message into Purpose and does not look for a month (neither in the text nor from the date). A message that only contains other: later in the text is inferred the normal way. The shared check is matching.IsOtherPurpose / is_other_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:

    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:

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