Compare commits

...

43 Commits

Author SHA1 Message Date
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
3377092a3f feat: Add Adults and Juniors dashboards with concise layout, totals, tooltips and unified navigation
All checks were successful
Build and Push / build (push) Successful in 8s
Deploy to K8s / deploy (push) Successful in 8s
Co-authored-by: Antigravity <antigravity@google.com>
2026-03-11 13:01:18 +01:00
dca0c6c933 feat: warm up cache on app startup for fast first page load
All checks were successful
Build and Push / build (push) Successful in 8s
Pre-fetches all 4 cached datasets (attendance, juniors, payments,
exceptions) at module load time so the first request doesn't block
on Google API calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 11:59:53 +01:00
9b99f6d33b docs: experiment with generated documentation, let's keep it in git for
All checks were successful
Deploy to K8s / deploy (push) Successful in 8s
now
2026-03-11 11:57:30 +01:00
e83d6af1f5 prompts: trying to record discussions with agents, it probably won't
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
work for me anyway
2026-03-11 11:56:21 +01:00
7d51f9ca77 Merge pull request 'refactor: code quality improvements across the backend' (#3) from claude-suggested-fixes into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Reviewed-on: #3
2026-03-11 10:55:52 +00:00
033349cafa refactor: code quality improvements across the backend
All checks were successful
Deploy to K8s / deploy (push) Successful in 13s
Build and Push / build (push) Successful in 32s
- Remove insecure SSL verification bypass in attendance.py
- Add gunicorn as production WSGI server (Dockerfile + entrypoint)
- Fix silent data loss in reconciliation (log + surface unmatched members)
- Add required column validation in payment sheet parsing
- Add input validation on /qr route (account format, amount bounds, SPD injection)
- Centralize configuration into scripts/config.py
- Extract credentials path to env-configurable constant
- Hide unmatched transactions from reconcile-juniors page
- Fix test mocks to bypass cache layer (all 8 tests now pass reliably)
- Add pytest + pytest-cov dev dependencies
- Fix typo "Inffering" in infer_payments.py
- Update CLAUDE.md to reflect current project state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 11:40:32 +01:00
0d0c2af778 Merge pull request 'google-documents-read-caching' (#2) from google-documents-read-caching into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Reviewed-on: #2
2026-03-11 10:13:18 +00:00
7170cd4d27 refactor: unify get_cached_exceptions into get_cached_data
All checks were successful
Deploy to K8s / deploy (push) Successful in 12s
Build and Push / build (push) Successful in 8s
Add optional serialize/deserialize hooks to get_cached_data() so it
can handle the exceptions dict (tuple keys → JSON-safe lists) without
needing a separate function.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 11:10:16 +01:00
251d7ba6b5 fix: properly debounce Drive API metadata checks in cache
Remove the file mtime check from the API debounce tier in
get_sheet_modified_time(). Previously, the debounce was defeated when
CACHE_TTL_SECONDS differed from CACHE_API_CHECK_TTL_SECONDS because
the file age check would fail even though the API was checked recently.

Also fix cache key mappings (attendance_juniors sheet ID,
payments_transactions rename) and add tmp/ to .gitignore.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 11:01:41 +01:00
76cdcba424 docs: add caching outcomes summary to prompts directory 2026-03-11 01:18:00 +01:00
8662cb4592 feat: implement caching for google sheets data
- Add cache_utils.py with JSON caching for Google Sheets
- Authenticate and cache Drive/Sheets API services globally to reuse tokens
- Use CACHE_SHEET_MAP dict to resolve cache names securely to Sheet IDs
- Change app.py data fetching to skip downloads if modifiedTime matches cache
- Replace global socket timeout with httplib2 to fix Werkzeug timeouts
- Add VS Code attach debugpy configurations to launch.json and Makefile
2026-03-11 01:16:00 +01:00
c8c145486f Merge pull request 'calculate-finance-for-juniors' (#1) from calculate-finance-for-juniors into main
All checks were successful
Deploy to K8s / deploy (push) Successful in 12s
Reviewed-on: #1
2026-03-10 22:12:32 +00:00
Jan Novak
27ad66ff79 style: Rename navigation links to distinguish Adult and Junior sections
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Build and Push / build (push) Successful in 7s
Co-authored-by: Antigravity <antigravity@google.com>
2026-03-09 23:18:12 +01:00
Jan Novak
1257f0d644 Feat: separate merged months configs and add 'other' payments to member popups
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Build and Push / build (push) Successful in 8s
2026-03-09 23:07:22 +01:00
Jan Novak
75a36eb49b feat: Implement junior fees dashboard and reconciliation
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
Build and Push / build (push) Successful in 9s
- Add dual-sheet architecture to pull attendance from both adult and junior spreadsheets.
- Introduce parsing rules to isolate juniors (e.g. above '# Treneri', tier 'J').
- Add new endpoints `/fees-juniors` and `/reconcile-juniors` to track junior attendances and match bank payments.
- Display granular attendance components showing adult vs. junior practices.
- Add fee rule configuration supporting custom pricing exceptions for specific months (e.g. Sep 2025) and merging billing periods.
- Add `make sync-2025` target to the Makefile for convenience.
- Document junior fees implementation logic and rules in prompts/outcomes.

Co-authored-by: Antigravity <antigravity@google.com>
2026-03-09 17:35:26 +01:00
Jan Novak
f40015a2ef fix: mark docs target as .PHONY in Makefile
Some checks failed
Deploy to K8s / deploy (push) Failing after 9s
2026-03-03 14:24:28 +01:00
Jan Novak
5bdc7a4566 feat: add keyboard navigation to member details and fix attendance count
Some checks failed
Deploy to K8s / deploy (push) Failing after 7s
Build and Push / build (push) Successful in 9s
- Users can now navigate between members in the details popup using Up/Down arrows.
- Fixed 0 attendance count in member popup by preserving count in reconciliation.
- Updated uv.lock following dependency changes.

Co-authored-by: Antigravity <antigravity@google.com>
2026-03-03 11:04:50 +01:00
Jan Novak
9ee2dd782d fix: add missing qrcode and pillow dependencies to Dockerfile and pyproject.toml
All checks were successful
Deploy to K8s / deploy (push) Successful in 10s
Build and Push / build (push) Successful in 30s
This fixes the 'ModuleNotFoundError: No module named qrcode' error in the container.
Updated pyproject.toml version to 0.10.

Co-authored-by: Antigravity <antigravity@google.com>
2026-03-02 22:57:15 +01:00
Jan Novak
4bb8c7420c feat: implement local payment QR codes and update AI co-authoring rules
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
Build and Push / build (push) Successful in 8s
QR codes are now generated locally using the 'qrcode' library for better privacy and reliability.
Updated .agent/rules.md with co-author details and Conventional Commits preference.

Co-authored-by: Antigravity <antigravity@google.com>
2026-03-02 22:54:48 +01:00
Jan Novak
b0276f68b3 feat: add detailed performance profiling with interactive toggle
All checks were successful
Deploy to K8s / deploy (push) Successful in 11s
Build and Push / build (push) Successful in 9s
2026-03-02 22:34:06 +01:00
Jan Novak
7d05e3812c fix: correctly extract exception amount on fees page
All checks were successful
Deploy to K8s / deploy (push) Successful in 7s
Build and Push / build (push) Successful in 9s
2026-03-02 22:23:13 +01:00
Jan Novak
815b962dd7 feat: add member details popup with attendance and fee exceptions
All checks were successful
Deploy to K8s / deploy (push) Successful in 12s
Build and Push / build (push) Successful in 8s
2026-03-02 21:41:36 +01:00
Jan Novak
99b23199b1 feat: improve attendance parsing logic and fix payment date formatting
All checks were successful
Deploy to K8s / deploy (push) Successful in 12s
Build and Push / build (push) Successful in 8s
2026-03-02 15:06:28 +01:00
69 changed files with 8573 additions and 743 deletions

View File

@@ -1,5 +1,7 @@
# Antigravity Agent Configuration
# This file provides global rules for the Antigravity agent when working on this repository.
- **Git Commits**: When making git commits, always append the following co-author trailer to the end of the commit message to indicate AI assistance:
`Co-authored-by: Antigravity <antigravity@deepmind.com>`
- **Identity**: Antigravity AI (Assistant)
- **Git Commits**: Always follow [Conventional Commits](https://www.conventionalcommits.org/) and append the co-author trailer:
`Co-authored-by: Antigravity <antigravity@google.com>`
- **Workflow**: Prefer updating `task.md` and `walkthrough.md` in the `.gemini/antigravity/brain/` directory to track progress and document changes.

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

6
.gitignore vendored
View File

@@ -1,3 +1,9 @@
# python cache
**/*.pyc
.secret
# local tmp folder
tmp/
# go build output
bin/

33
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,33 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Flask",
"type": "debugpy",
"request": "launch",
"module": "flask",
"python": "${workspaceFolder}/.venv/bin/python",
"env": {
"FLASK_APP": "app.py",
"FLASK_DEBUG": "1"
},
"args": [
"run",
"--no-debugger",
"--no-reload",
"--host", "0.0.0.0",
"--port", "5001"
],
"jinja": true
},
{
"name": "Python Debugger: Attach",
"type": "debugpy",
"request": "attach",
"connect": {
"host": "localhost",
"port": 5678
}
}
]
}

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"makefile.configureOnOpen": false
}

38
CHANGELOG.md Normal file
View File

@@ -0,0 +1,38 @@
# Changelog
## 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).

121
CLAUDE.md
View File

@@ -4,44 +4,117 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Status
This is a greenfield project in early discovery/design phase. No source code exists yet. The project aims to automate financial and operational management for a small sports club.
See `docs/project-notes.md` for the current brainstorming state, domain model, and open questions that need answering before implementation begins.
Flask-based financial management system for FUJ (Frisbee Ultimate Jablonec). Handles attendance-based fee calculation, Fio bank transaction sync, payment reconciliation, and a web dashboard.
## Key Constraints
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
- **Incremental approach**: Start with highest-ROI automation (likely fee billing & payment tracking), not a full platform.
## Development Workflow
This project uses a hybrid workflow:
- Claude.ai chat for brainstorming and design exploration
- Claude Code for implementation
## When Code Exists
- **Configuration**: External service IDs, credentials, and tunable parameters are centralized in `scripts/config.py`. Domain-specific constants (fees, merged months) stay in their respective modules.
## Development Setup
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 `credentials.json` in the root for Google Sheets API access.
## 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 (750 CZK/month 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 750 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).
## Git Commits
When making git commits, always append yourself as co-author trailer to the end of the commit message to indicate AI assistance
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 image run sync sync-2026 test test-v
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-run go-lint image run sync sync-2026 test test-v docs
export PYTHONPATH := scripts:$(PYTHONPATH)
VENV := .venv
PYTHON := $(VENV)/bin/python3
CREDENTIALS := .secret/fuj-management-bot-credentials.json
GO_SRC := go
GO_BIN := bin/fuj
$(PYTHON): .venv/.last_sync
.venv/.last_sync: pyproject.toml
@@ -13,18 +16,26 @@ $(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 image - Build an OCI container image"
@echo " make run - Run the built Docker image locally"
@echo " make fees - Calculate monthly fees from the attendance sheet"
@echo " make match - Match Fio bank payments against expected attendance fees"
@echo " make web - Start Python dashboard (alias for web-py, until M8)"
@echo " make web-py - Start Python dashboard on :5001"
@echo " make web-go - Build and start Go dashboard on :8080"
@echo " make web-debug - Start Python dashboard in debug mode"
@echo " make go-build - Build Go binary to bin/fuj"
@echo " make go-test - Run Go tests"
@echo " make go-lint - Run golangci-lint on Go code"
@echo " make image - Build Python OCI container image"
@echo " make run - Run the built Python Docker image locally"
@echo " make sync - Sync Fio transactions to Google Sheets"
@echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)"
@echo " make sync-2026 - Sync Fio transactions for the whole year of 2026"
@echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet"
@echo " make reconcile - Show balance report using Google Sheets data"
@echo " make venv - Sync virtual environment with pyproject.toml"
@echo " make test - Run web application infrastructure tests"
@echo " make test-v - Run tests with verbose output"
@echo " make test - Run Python web application infrastructure tests"
@echo " make test-v - Run Python tests with verbose output"
@echo " make docs - Serve documentation in a browser"
venv:
uv sync
@@ -35,18 +46,49 @@ fees: $(PYTHON)
match: $(PYTHON)
$(PYTHON) scripts/match_payments.py
web: $(PYTHON)
web: web-py
web-py: $(PYTHON)
$(PYTHON) app.py
web-debug: $(PYTHON)
FLASK_DEBUG=1 $(PYTHON) app.py
go-build:
cd $(GO_SRC) && go build -trimpath \
-ldflags "-X main.version=$$(git describe --tags --always 2>/dev/null || echo dev) \
-X main.commit=$$(git rev-parse --short HEAD) \
-X main.buildDate=$$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-o ../$(GO_BIN) ./cmd/fuj
go-test:
cd $(GO_SRC) && go test -race ./...
go-run: go-build
./$(GO_BIN) $(ARGS)
go-lint:
cd $(GO_SRC) && golangci-lint run ./...
web-go: go-build
./$(GO_BIN) server
image:
docker build -t fuj-management:latest -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 fuj-management:latest
docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest
sync: $(PYTHON)
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json
sync-2025: $(PYTHON)
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2025-10-01 --to 2025-12-31 --sort-by-date
sync-2026: $(PYTHON)
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --from 2026-01-01 --to 2026-12-31 --sort-by-date
@@ -61,3 +103,8 @@ test: $(PYTHON) ## Run web application tests
test-v: $(PYTHON) ## Run tests with verbose output
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover -v tests
docs: ## Serve documentation locally
@echo "Starting documentation server at http://localhost:8000"
@echo "Press Ctrl+C to stop."
$(PYTHON) -m http.server 8000 --directory docs

677
app.py
View File

@@ -2,155 +2,581 @@ import sys
from pathlib import Path
from datetime import datetime
import re
from flask import Flask, render_template
import time
import os
import io
import qrcode
import logging
from flask import Flask, render_template, g, send_file, request
# Configure logging, allowing override via LOG_LEVEL environment variable
log_level = os.environ.get("LOG_LEVEL", "INFO").upper()
logging.basicConfig(level=getattr(logging, log_level, logging.INFO), format='%(asctime)s - %(name)s:%(filename)s:%(lineno)d [%(funcName)s] - %(levelname)s - %(message)s')
# Add scripts directory to path to allow importing from it
scripts_dir = Path(__file__).parent / "scripts"
sys.path.append(str(scripts_dir))
from attendance import get_members_with_fees, SHEET_ID as ATTENDANCE_SHEET_ID
from match_payments import reconcile, fetch_sheet_data, DEFAULT_SPREADSHEET_ID as PAYMENTS_SHEET_ID
from config import (
ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID,
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, 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)
if mod_time:
cached = read_cache(cache_key, mod_time)
if cached is not None:
return deserialize(cached) if deserialize else cached
data = fetch_func(*args, **kwargs)
if mod_time:
write_cache(cache_key, mod_time, serialize(data) if serialize else data)
return data
def get_month_labels(sorted_months, merged_months):
labels = {}
for m in sorted_months:
dt = datetime.strptime(m, "%Y-%m")
# Find which months were merged into m (e.g. 2026-01 is merged into 2026-02)
merged_in = sorted([k for k, v in merged_months.items() if v == m])
if merged_in:
all_dts = [datetime.strptime(x, "%Y-%m") for x in sorted(merged_in + [m])]
years = {d.year for d in all_dts}
if len(years) > 1:
parts = [d.strftime("%b %Y") for d in all_dts]
labels[m] = "+".join(parts)
else:
parts = [d.strftime("%b") for d in all_dts]
labels[m] = f"{'+'.join(parts)} {dt.strftime('%Y')}"
else:
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__)
logger.info("Warming up cache...")
credentials_path = CREDENTIALS_PATH
get_cached_data("attendance_regular", ATTENDANCE_SHEET_ID, get_members_with_fees)
get_cached_data("attendance_juniors", ATTENDANCE_SHEET_ID, get_junior_members_with_fees)
get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
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},
)
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
def start_timer():
g.start_time = time.perf_counter()
g.steps = []
def record_step(name):
g.steps.append((name, time.perf_counter()))
@app.context_processor
def inject_render_time():
def get_render_time():
total = time.perf_counter() - g.start_time
breakdown = []
last_time = g.start_time
for name, timestamp in g.steps:
duration = timestamp - last_time
breakdown.append(f"{name}:{duration:.3f}s")
last_time = timestamp
# Add remaining time as 'render'
render_duration = time.perf_counter() - last_time
breakdown.append(f"render:{render_duration:.3f}s")
return {
"total": f"{total:.3f}",
"breakdown": " | ".join(breakdown)
}
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():
@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)
@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)
@app.route("/version")
def version():
return BUILD_META
@app.route("/adults")
def adults_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"
credentials_path = CREDENTIALS_PATH
members, sorted_months = get_members_with_fees()
if not members:
return "No data."
# Filter to adults only for display
results = [(name, fees) for name, tier, fees in members if tier == "A"]
# Format month labels
month_labels = {
m: datetime.strptime(m, "%Y-%m").strftime("%b %Y") for m in sorted_months
}
monthly_totals = {m: 0 for m in sorted_months}
formatted_results = []
for name, month_fees in results:
row = {"name": name, "months": []}
for m in sorted_months:
fee, count = month_fees.get(m, (0, 0))
monthly_totals[m] += fee
cell = f"{fee} CZK ({count})" if count > 0 else "-"
row["months"].append(cell)
formatted_results.append(row)
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("/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 = ".secret/fuj-management-bot-credentials.json"
members, sorted_months = get_members_with_fees()
if not members:
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 = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
result = reconcile(members, sorted_months, transactions)
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 = {
m: datetime.strptime(m, "%Y-%m").strftime("%b %Y") for m in sorted_months
}
# Filter to adults for the main table
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"]}
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, "paid": 0})
expected = mdata["expected"]
paid = int(mdata["paid"])
cell_status = ""
if expected == 0 and paid == 0:
cell = "-"
elif paid >= expected and expected > 0:
cell = "OK"
elif paid > 0:
cell = f"{paid}/{expected}"
mdata = data["months"].get(m, {"expected": 0, "original_expected": 0, "attendance_count": 0, "paid": 0, "exception": None})
expected = mdata.get("expected", 0)
original_expected = mdata.get("original_expected", 0)
count = mdata.get("attendance_count", 0)
paid = int(mdata.get("paid", 0))
exception_info = mdata.get("exception", None)
monthly_totals[m]["expected"] += expected
monthly_totals[m]["paid"] += paid
override_amount = exception_info["amount"] if exception_info else None
if override_amount is not None and override_amount != original_expected:
is_overridden = True
fee_display = f"{override_amount} ({original_expected}) CZK ({count})" if count > 0 else f"{override_amount} ({original_expected}) CZK"
else:
cell = f"UNPAID {expected}"
row["months"].append(cell)
row["balance"] = data["total_balance"] # Updated to use total_balance
is_overridden = False
fee_display = f"{expected} CZK ({count})" if count > 0 else f"{expected} CZK"
status = "empty"
cell_text = "-"
amount_to_pay = 0
if expected > 0:
amount_to_pay = max(0, expected - paid)
if paid >= expected:
status = "ok"
cell_text = f"{paid}/{fee_display}"
elif paid > 0:
status = "partial"
cell_text = f"{paid}/{fee_display}"
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}"
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}"
else:
cell_text = "-"
amount_to_pay = 0
if expected > 0 or paid > 0:
tooltip = f"Received: {paid}, Expected: {expected}"
else:
tooltip = ""
row["months"].append({
"text": cell_text,
"overridden": is_overridden,
"status": status,
"amount": amount_to_pay,
"month": month_labels[m],
"raw_month": m,
"tooltip": tooltip
})
# 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)
# 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"])
# Format unmatched
formatted_totals = []
for m in sorted_months:
t = monthly_totals[m]
status = "empty"
if t["expected"] > 0 or t["paid"] > 0:
if t["paid"] == t["expected"]:
status = "ok"
elif t["paid"] < t["expected"]:
status = "unpaid"
else:
status = "surplus"
formatted_totals.append({
"text": f"{t['paid']} / {t['expected']} CZK",
"status": status
})
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(
"reconcile.html",
"adults.html",
months=[month_labels[m] for m in sorted_months],
raw_months=sorted_months,
results=formatted_results,
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
payments_url=payments_url,
bank_account=BANK_ACCOUNT,
current_month=current_month
)
@app.route("/juniors")
def 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)
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": "", "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)
original_expected = mdata.get("original_expected", 0)
count = mdata.get("attendance_count", 0)
paid = int(mdata.get("paid", 0))
exception_info = mdata.get("exception", None)
if expected != "?" and isinstance(expected, int):
monthly_totals[m]["expected"] += expected
monthly_totals[m]["paid"] += paid
orig_fee_data = junior_members_dict.get(name, {}).get(m)
adult_count = 0
junior_count = 0
if orig_fee_data and len(orig_fee_data) == 4:
_, _, adult_count, junior_count = orig_fee_data
breakdown = ""
if adult_count > 0 and junior_count > 0:
breakdown = f":{junior_count}J,{adult_count}A"
elif junior_count > 0:
breakdown = f":{junior_count}J"
elif adult_count > 0:
breakdown = f":{adult_count}A"
count_str = f" ({count}{breakdown})" if count > 0 else ""
override_amount = exception_info["amount"] if exception_info else None
if override_amount is not None and override_amount != original_expected:
is_overridden = True
fee_display = f"{override_amount} ({original_expected}) CZK{count_str}"
else:
is_overridden = False
fee_display = f"{expected} CZK{count_str}"
status = "empty"
cell_text = "-"
amount_to_pay = 0
if expected == "?" or (isinstance(expected, int) and expected > 0):
if expected == "?":
status = "empty"
cell_text = f"?{count_str}"
elif paid >= expected:
status = "ok"
cell_text = f"{paid}/{fee_display}"
elif paid > 0:
status = "partial"
cell_text = f"{paid}/{fee_display}"
amount_to_pay = expected - paid
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
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}"
if (isinstance(expected, int) and expected > 0) or paid > 0:
tooltip = f"Received: {paid}, Expected: {expected}"
else:
tooltip = ""
row["months"].append({
"text": cell_text,
"overridden": is_overridden,
"status": status,
"amount": amount_to_pay,
"month": month_labels[m],
"raw_month": m,
"tooltip": tooltip
})
# 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 = []
for m in sorted_months:
t = monthly_totals[m]
status = "empty"
if t["expected"] > 0 or t["paid"] > 0:
if t["paid"] == t["expected"]:
status = "ok"
elif t["paid"] < t["expected"]:
status = "unpaid"
else:
status = "surplus"
formatted_totals.append({
"text": f"{t['paid']} / {t['expected']} CZK",
"status": status
})
# Format credits and debts
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")
return render_template(
"juniors.html",
months=[month_labels[m] for m in sorted_months],
raw_months=sorted_months,
results=formatted_results,
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,
current_month=current_month
)
@app.route("/payments")
def payments():
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"
credentials_path = ".secret/fuj-management-bot-credentials.json"
credentials_path = CREDENTIALS_PATH
transactions = fetch_sheet_data(PAYMENTS_SHEET_ID, credentials_path)
# Group transactions by person
grouped = {}
transactions = get_cached_data("payments_transactions", PAYMENTS_SHEET_ID, fetch_sheet_data, PAYMENTS_SHEET_ID, credentials_path)
record_step("fetch_payments")
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(
"payments.html",
grouped_payments=grouped,
@@ -159,5 +585,42 @@ def payments():
payments_url=payments_url
)
@app.route("/qr")
def qr_code():
account = request.args.get("account", BANK_ACCOUNT)
amount = request.args.get("amount", "0")
message = request.args.get("message", "")
# Validate account: allow IBAN (letters+digits) or Czech format (digits/digits)
if not re.match(r'^[A-Z]{2}\d{2,34}$|^\d{1,16}/\d{4}$', account):
account = BANK_ACCOUNT
# QR Platba standard: SPD*1.0*ACC:accountNumber*BC:bankCode*AM:amount*CC:CZK*MSG:message
acc_parts = account.split('/')
if len(acc_parts) == 2:
acc_str = f"{acc_parts[0]}*BC:{acc_parts[1]}"
else:
acc_str = account
try:
amt_val = float(amount)
if amt_val < 0 or amt_val > 10_000_000:
amt_val = 0
amt_str = f"{amt_val:.2f}"
except ValueError:
amt_str = "0.00"
# Message max 60 characters, strip SPD delimiters to prevent injection
msg_str = message[:60].replace("*", "")
qr_data = f"SPD*1.0*ACC:{acc_str}*AM:{amt_str}*CC:CZK*MSG:{msg_str}"
img = qrcode.make(qr_data)
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return send_file(buf, mimetype='image/png')
if __name__ == "__main__":
app.run(debug=True, host='0.0.0.0', port=5001)

View File

@@ -12,7 +12,10 @@ RUN pip install --no-cache-dir \
flask \
google-api-python-client \
google-auth-httplib2 \
google-auth-oauthlib
google-auth-oauthlib \
qrcode \
pillow \
gunicorn
COPY app.py Makefile ./
COPY scripts/ ./scripts/
@@ -21,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 \

View File

@@ -1,8 +1,11 @@
#!/bin/bash
set -euo pipefail
echo "[entrypoint] Starting Flask app on port 5001..."
echo "[entrypoint] Starting gunicorn on port 5001..."
# Running the app directly via python
# For a production setup, we would ideally use gunicorn/waitress, but sticking to what's in app.py for now.
exec python3 /app/app.py
exec gunicorn \
--bind 0.0.0.0:5001 \
--workers "${GUNICORN_WORKERS:-2}" \
--timeout "${GUNICORN_TIMEOUT:-120}" \
--access-logfile - \
app:app

15
docs/README.md Normal file
View File

@@ -0,0 +1,15 @@
# FUJ Management Documentation
Welcome to the documentation for the FUJ Management application.
This project automates financial and operational management for the FUJ (Frisbee Ultimate Jablonec) club.
## Navigation
Use the sidebar to explore the documentation:
* **[Project Notes](project-notes.md)**: Main brainstorming and domain model.
* **[Scripts](scripts.md)**: Details about available CLI tools.
* **[Fee Specification](fee-calculation-spec.md)**: Rules for fee calculation.
For more technical details, check out the guides by Claude and Gemini in the sidebar.

25
docs/_sidebar.md Normal file
View File

@@ -0,0 +1,25 @@
* [Home](README.md)
* [Project Notes](project-notes.md)
* [Scripts](scripts.md)
* [Fee Spec](fee-calculation-spec.md)
* **By Claude Opus**
* [README](by-claude-opus/README.md)
* [User Guide](by-claude-opus/user-guide.md)
* [Web App](by-claude-opus/web-app.md)
* [Deployment](by-claude-opus/deployment.md)
* [Architecture](by-claude-opus/architecture.md)
* [Data Model](by-claude-opus/data-model.md)
* [Development](by-claude-opus/development.md)
* [Scripts](by-claude-opus/scripts.md)
* [Testing](by-claude-opus/testing.md)
* **By Gemini**
* [README](by-gemini/README.md)
* [User Guide](by-gemini/user-guide.md)
* [Architecture](by-gemini/architecture.md)
* [Deployment](by-gemini/deployment.md)
* [Scripts](by-gemini/scripts.md)
* **Specs**
* [Fio Sync](spec/fio_to_sheets_sync.md)

View File

@@ -0,0 +1,214 @@
# FUJ Management — Comprehensive Documentation
> **FUJ = Frisbee Ultimate Jablonec** — a small sports club in the Czech Republic.
## What Is This Project?
FUJ Management is a purpose-built financial management system for a small ultimate frisbee club. It automates the tedious process of tracking **who attended practice**, **how much they owe**, **who has paid**, and **who still owes money** — a workflow that would otherwise require manual cross-referencing between attendance spreadsheets and bank statements.
The system is built around two Google Sheets (one for attendance, one for payments) and a Fio bank transparent account. A set of Python scripts sync and process the data, while a Flask-based web dashboard provides real-time visibility into fees, payments, and reconciliation status.
### The Problem It Solves
Before this system, the club treasurer had to:
1. **Manually count** attendance marks for each member each month
2. **Calculate** whether each person owes 0, 200, or 750 CZK based on how many times they showed up
3. **Cross-reference** bank statements to figure out who paid and for which month
4. **Chase** members who hadn't paid, often losing track of partial payments and advance payments
5. **Handle edge cases** like members paying for multiple months at once, using nicknames in payment messages, or paying via a family member's account
This system automates steps 14 entirely, and provides tooling for step 5.
## System Overview
```
┌──────────────────────────┐ ┌──────────────────────────┐
│ Attendance Sheet │ │ Fio Bank Account │
│ (Google Sheets) │ │ (transparent account) │
│ │ │ │
│ Members × Dates × ✓/✗ │ │ Incoming payments with │
│ Tier (A/J/X) │ │ sender, amount, message │
└──────────┬───────────────┘ └──────────┬───────────────┘
│ │
│ CSV export │ API / HTML scraping
│ │
▼ ▼
┌─────────────────┐ ┌───────────────────────┐
│ attendance.py │ │ sync_fio_to_sheets.py │
│ │ │ │
│ Fetches sheet, │ │ Syncs bank txns to │
│ computes fees │ │ Payments Google Sheet │
└────────┬────────┘ └───────────┬────────────┘
│ │
│ ▼
│ ┌───────────────────────┐
│ │ Payments Sheet │
│ │ (Google Sheets) │
│ │ │
│ │ Date|Amount|Person| │
│ │ Purpose|Sender|etc. │
│ └───────────┬────────────┘
│ │
│ ┌─────────────────────────┤
│ │ │
│ ▼ ▼
│ ┌──────────────┐ ┌──────────────────┐
│ │infer_payments│ │ match_payments.py │
│ │ .py │ │ │
│ │ │ │ Reconciliation │
│ │ Auto-fills │ │ engine: matches │
│ │ Person, │ │ payments against │
│ │ Purpose, │ │ expected fees │
│ │ Amount │ └────────┬──────────┘
│ └──────────────┘ │
│ │
└────────────────┬───────────────┘
┌──────────────────────┐
│ Flask Web App │
│ (app.py) │
│ │
│ /fees fee │
│ table │
│ /reconcile balance │
│ matrix │
│ /payments ledger │
│ /qr QR code │
└───────────────────────┘
```
## Quick Start
### Prerequisites
- **Python 3.13+**
- **[uv](https://docs.astral.sh/uv/)** — fast Python package manager
- **Google Sheets API credentials** — a service account JSON file placed at `.secret/fuj-management-bot-credentials.json`
- *Optional*: `FIO_API_TOKEN` environment variable for Fio REST API access (falls back to transparent page scraping)
### Setup
```bash
# Clone and install dependencies
git clone <repo-url>
cd fuj-management
uv sync # Installs all dependencies from pyproject.toml
# Place your Google API credentials
mkdir -p .secret
cp /path/to/your/credentials.json .secret/fuj-management-bot-credentials.json
```
### Common Operations
| Command | Purpose |
|---------|---------|
| `make web` | Start the web dashboard at `http://localhost:5001` |
| `make sync` | Pull new bank transactions into the Google Sheet |
| `make infer` | Auto-fill Person/Purpose/Amount for new transactions |
| `make reconcile` | Print a CLI balance report |
| `make fees` | Print fee calculation table from attendance |
| `make test` | Run the test suite |
| `make image` | Build the Docker container image |
### Typical Workflow
```
make sync → make infer → (manual review in Google Sheets) → make web
↓ ↓ ↓ ↓
Pull new bank Auto-match Fix any [?] View live
transactions payments to flagged rows dashboard
into sheet members/months in the sheet
```
## Documentation Index
| Document | Contents |
|----------|----------|
| [Architecture](architecture.md) | System design, data flow diagrams, module dependency graph |
| [Web Application](web-app.md) | Flask app architecture, routes, templates, interactive features |
| [User Guide](user-guide.md) | End-user guide for the web dashboard — what each page shows |
| [Scripts Reference](scripts.md) | Detailed reference for all CLI scripts and shared modules |
| [Data Model](data-model.md) | Google Sheets schemas, fee calculation rules, bank integration |
| [Deployment](deployment.md) | Docker containerization, Gitea CI/CD, Kubernetes deployment |
| [Testing](testing.md) | Test infrastructure, coverage, how to write new tests |
| [Development Guide](development.md) | Local setup, coding conventions, tooling, project history |
## Technology Stack
| Layer | Technology |
|-------|-----------|
| Language | Python 3.13+ |
| Web framework | Flask 3.1 |
| Package management | uv + pyproject.toml |
| Data sources | Google Sheets API, Fio Bank API / HTML scraping |
| QR codes | `qrcode` library (PIL backend) |
| Containerization | Docker (Alpine-based) |
| CI/CD | Gitea Actions |
| Deployment target | Self-hosted Kubernetes |
| Frontend | Server-rendered HTML/CSS/JS (terminal-aesthetic theme) |
## Project Structure
```
fuj-management/
├── app.py # Flask web application (4 routes)
├── Makefile # Build automation (13 targets)
├── pyproject.toml # Python dependencies and metadata
├── scripts/
│ ├── attendance.py # Shared: attendance data + fee calculation
│ ├── calculate_fees.py # CLI: print fee table
│ ├── match_payments.py # Core: reconciliation engine + CLI report
│ ├── infer_payments.py # Auto-fill Person/Purpose in Google Sheet
│ ├── sync_fio_to_sheets.py # Sync Fio bank → Google Sheet
│ ├── fio_utils.py # Shared: Fio bank data fetching
│ └── czech_utils.py # Shared: diacritics normalization + Czech month parsing
├── templates/
│ ├── fees.html # Attendance/fees dashboard
│ ├── reconcile.html # Payment reconciliation with modals + QR
│ └── payments.html # Payments ledger grouped by member
├── tests/
│ ├── test_app.py # Flask route tests (mocked data)
│ └── test_reconcile_exceptions.py # Reconciliation with fee exceptions
├── build/
│ ├── Dockerfile # Alpine-based container image
│ └── entrypoint.sh # Container entry point
├── .gitea/workflows/
│ ├── build.yaml # CI: build + push Docker image
│ └── kubernetes-deploy.yaml # CD: deploy to K8s cluster
├── .secret/ # (gitignored) API credentials
├── docs/ # Project documentation
│ ├── project-notes.md # Original brainstorming and design notes
│ ├── fee-calculation-spec.md # Fee rules and payment matching spec
│ ├── scripts.md # Legacy scripts documentation
│ └── spec/
│ └── fio_to_sheets_sync.md # Fio-to-Sheets sync specification
└── CLAUDE.md # AI assistant context file
```
## Key Design Decisions
1. **No database** — Google Sheets serves as both the data store and the manual editing interface. This keeps the system simple and accessible to non-technical club members who can review and edit data directly in the spreadsheet.
2. **PII separation** — No member names or personal data are stored in the git repository. All data is fetched at runtime from Google Sheets and the bank account.
3. **Idempotent sync** — The Fio-to-Sheets sync uses SHA-256 hashes as deduplication keys, making re-runs safe and append-only.
4. **Graceful fallbacks** — Bank data can be fetched via the REST API (if a token is available) or by scraping the public transparent account page. The system doesn't break if the API token is missing.
5. **Czech language support** — Payment messages are in Czech and use diacritics. The system normalizes text (strips diacritics) and understands Czech month names in all grammatical declensions.
6. **Terminal aesthetic** — The web dashboard uses a monospace, dark-themed, terminal-inspired design that matches the project's pragmatic, CLI-first philosophy.
---
*This documentation was generated on 2026-03-03 by Claude Opus, based on a comprehensive analysis of the complete codebase.*

View File

@@ -0,0 +1,268 @@
# System Architecture
## Overview
FUJ Management follows a **pipeline architecture** where data flows from external sources (Google Sheets, Fio Bank) through processing scripts into a web dashboard. There is no central database — Google Sheets serves as the persistent data store, and the Flask app renders views by fetching and processing data on every request.
## Component Architecture
```
┌─────────────────────────────────────────────────┐
│ EXTERNAL DATA SOURCES │
│ │
│ ┌──────────────────┐ ┌──────────────────────┐ │
│ │ Attendance Sheet │ │ Fio Bank Account │ │
│ │ (Google Sheets) │ │ │ │
│ │ │ │ ┌────────────────┐ │ │
│ │ ID: 1E2e_gT... │ │ │ REST API │ │ │
│ │ │ │ │ (JSON, w/token)│ │ │
│ │ CSV export (pub) │ │ ├────────────────┤ │ │
│ │ │ │ │ Transparent │ │ │
│ └────────┬─────────┘ │ │ page (HTML) │ │ │
│ │ │ └───────┬────────┘ │ │
│ │ └──────────┼──────────┘ │
└───────────┼───────────────────────┼────────────┘
│ │
─ ─ ─ ─ ─ ─ ┼ ─ ─ DATA INGESTION ─ ┼ ─ ─ ─ ─ ─
│ │
┌───────────▼──────┐ ┌───────────▼──────────┐
│ attendance.py │ │ fio_utils.py │
│ │ │ │
│ fetch_csv() │ │ fetch_transactions() │
│ parse_dates() │ │ FioTableParser │
│ group_by_month() │ │ parse_czech_amount() │
│ calculate_fee() │ │ parse_czech_date() │
│ get_members() │ │ │
│ get_members_ │ │ API + HTML fallback │
│ with_fees() │ │ │
└───────────┬──────┘ └───────────┬──────────┘
│ │
─ ─ ─ ─ ─ ─ ┼ ─ ─ PROCESSING ─ ─ ─ ┼ ─ ─ ─ ─ ─
│ │
│ ┌─────────────▼──────────┐
│ │ sync_fio_to_sheets.py │ ──▶ Payments Sheet
│ │ │ (Google Sheets)
│ │ generate_sync_id() │
│ │ sort_sheet_by_date() │
│ │ get_sheets_service() │
│ └────────────────────────┘
│ │
│ ┌─────────────▼──────────┐
│ │ infer_payments.py │ ──▶ Writes back to
│ │ │ Payments Sheet
│ │ infer Person/Purpose/ │
│ │ Amount for empty rows │
│ └────────────────────────┘
│ │
│ ┌──────────────────▼──────────┐
│ │ czech_utils.py │
│ │ │
│ │ normalize() — strip │
│ │ diacritics │
│ │ parse_month_references() │
│ │ CZECH_MONTHS dict │
│ └─────────────────────────────┘
│ │
─ ─ ─ ─ ─ ─ ┼ ─ RECONCILIATION ─ ─┼ ─ ─ ─ ─ ─
│ │
┌─────────▼───────────────────────▼───────────┐
│ match_payments.py │
│ │
│ _build_name_variants() — name matching │
│ match_members() — fuzzy match │
│ infer_transaction_details() │
│ fetch_sheet_data() — read payments │
│ fetch_exceptions() — fee overrides │
│ reconcile() — CORE ENGINE │
│ print_report() — CLI output │
└──────────────────────┬──────────────────────┘
─ ─ ─ ─ ─ ─ ─ PRESENTATION ┼ ─ ─ ─ ─ ─ ─ ─ ─ ─
┌──────────────────────▼──────────────────────┐
│ app.py (Flask) │
│ │
│ GET / → redirect to /fees │
│ GET /fees → fees.html │
│ GET /reconcile → reconcile.html │
│ GET /payments → payments.html │
│ GET /qr → PNG QR code (SPD format) │
└─────────────────────────────────────────────┘
```
## Module Dependency Graph
```
app.py
├── attendance.py
│ └── (stdlib: csv, urllib, datetime)
└── match_payments.py
├── attendance.py
├── czech_utils.py
│ └── (stdlib: re, unicodedata)
└── sync_fio_to_sheets.py (for get_sheets_service, DEFAULT_SPREADSHEET_ID)
└── fio_utils.py
└── (stdlib: json, urllib, html.parser, datetime)
infer_payments.py
├── sync_fio_to_sheets.py
├── match_payments.py
└── attendance.py
calculate_fees.py
└── attendance.py
```
### Import Relationships
| Module | Imports from |
|--------|-------------|
| `app.py` | `attendance` (`get_members_with_fees`, `SHEET_ID`), `match_payments` (`reconcile`, `fetch_sheet_data`, `fetch_exceptions`, `normalize`, `DEFAULT_SPREADSHEET_ID`) |
| `match_payments.py` | `attendance` (`get_members_with_fees`), `czech_utils` (`normalize`, `parse_month_references`), `sync_fio_to_sheets` (`get_sheets_service`, `DEFAULT_SPREADSHEET_ID`) |
| `infer_payments.py` | `sync_fio_to_sheets` (`get_sheets_service`, `DEFAULT_SPREADSHEET_ID`), `match_payments` (`infer_transaction_details`), `attendance` (`get_members_with_fees`) |
| `sync_fio_to_sheets.py` | `fio_utils` (`fetch_transactions`) |
| `calculate_fees.py` | `attendance` (`get_members_with_fees`) |
## Data Flow Patterns
### Pattern 1: Sync & Enrich (Batch Pipeline)
This is the primary workflow for keeping the payments ledger up to date:
```
1. make sync 2. make infer
┌──────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Fio │───▶│ Payments │ │ Payments │───▶│ Payments │
│ Bank │ │ Sheet │ │ Sheet │ │ Sheet │
└──────┘ │ (append) │ │ (read) │ │ (update) │
└──────────┘ └──────────┘ └──────────┘
- Fetches last 30 days - Reads empty Person/Purpose rows
- SHA-256 dedup prevents - Uses name matching + Czech month
duplicate entries parsing to auto-fill
- Marks uncertain matches with [?]
```
### Pattern 2: Real-Time Rendering (Web Dashboard)
Every web request triggers a fresh data fetch — no caching layer exists:
```
Browser Request → Flask Route → Fetch (Google Sheets API/CSV) → Process → Render HTML
│ │
│ attendance.py │ reconcile()
│ fetch_sheet_data() │ or direct
│ fetch_exceptions() │ formatting
▼ ▼
~1-3 seconds Template with
(network I/O) inline CSS + JS
```
### Pattern 3: QR Code Generation (On-Demand)
```
Browser clicks "Pay" → GET /qr?account=...&amount=...&message=... → SPD QR PNG
qrcode lib
generates
in-memory PNG
```
## Key Design Patterns
### 1. Google Sheets as Database
Instead of a traditional database, the system uses two Google Sheets:
| Sheet | Purpose | Access Method |
|-------|---------|---------------|
| Attendance Sheet (`1E2e_gT...`) | Member names, tiers, practice dates, attendance marks | Public CSV export (no auth needed) |
| Payments Sheet (`1Om0YPo...`) | Bank transactions with Person/Purpose annotations | Google Sheets API (service account auth) |
**Trade-offs**:
- ✅ Non-technical users can view and edit data directly
- ✅ No database setup or maintenance
- ✅ Built-in audit trail (Google Sheets version history)
- ❌ Every page load incurs 1-3s of API latency
- ❌ No complex queries or indexing
- ❌ Rate limits on Google Sheets API
### 2. Dual-Mode Bank Access
`fio_utils.py` implements a transparent fallback pattern:
```python
def fetch_transactions(date_from, date_to):
token = os.environ.get("FIO_API_TOKEN", "").strip()
if token:
return fetch_transactions_api(token, date_from, date_to) # Structured JSON
return fetch_transactions_transparent(...) # HTML scraping
```
The API provides richer data (sender account numbers, stable bank IDs) but requires a token. The transparent page is always available but lacks some fields.
### 3. Name Matching with Confidence Levels
The reconciliation engine uses a multi-tier matching strategy:
| Priority | Method | Confidence | Example |
|----------|--------|-----------|---------|
| 1 | Full name match | `auto` | "František Vrbík" in message |
| 2 | Both first + last name (any order) | `auto` | "Vrbík František" |
| 3 | Nickname match | `auto` | "(Štrúdl)" from member list |
| 4 | Last name only (≥4 chars, not common) | `review` | "Vrbík" alone |
| 5 | First name only (≥3 chars) | `review` | "František" alone |
When both `auto` and `review` matches exist, `review` matches are discarded. This prevents false positives from generic first names.
### 4. Exception System
Fee overrides are managed through an `exceptions` sheet tab in the Payments Google Sheet:
| Column | Content |
|--------|---------|
| Name | Member name |
| Period | Month (YYYY-MM) |
| Amount | Overridden fee in CZK |
| Note | Reason for the exception |
Exceptions are applied during reconciliation, replacing the attendance-calculated fee with the manually specified amount.
### 5. Render-Time Performance Tracking
Every page includes a performance breakdown:
```python
@app.before_request
def start_timer():
g.start_time = time.perf_counter()
g.steps = []
def record_step(name):
g.steps.append((name, time.perf_counter()))
```
The footer displays total render time and, on click, reveals a detailed breakdown (e.g., `fetch_members:0.892s | fetch_payments:1.205s | reconcile:0.003s | render:0.015s`).
## Security Considerations
| Concern | Mitigation |
|---------|-----------|
| PII in git | `.secret/` is gitignored; all data fetched at runtime |
| Google API credentials | Service account JSON stored in `.secret/`, mounted as Docker secret |
| Bank API token | Passed via `FIO_API_TOKEN` environment variable, never committed |
| Web app authentication | **None currently** — the app has no auth layer |
| CSRF protection | **None currently** — Flask default (no POST routes exist) |
## Scalability Notes
This system is purpose-built for a small club (~20-40 members). It makes deliberate trade-offs favoring simplicity over scale:
- **No caching**: Every page load fetches live data from Google Sheets (1-3s latency). For a single-user admin dashboard, this is acceptable.
- **No background workers**: Sync and inference are manual `make` commands, not scheduled jobs.
- **No database**: Google Sheets handles 10s of members and 100s of transactions with ease.
- **Single-process Flask**: The built-in development server runs directly in production (via Docker). For this use case, this is intentional — it's a personal tool, not a public service.
---
*Architecture documentation generated from comprehensive code analysis on 2026-03-03.*

View File

@@ -0,0 +1,201 @@
# Data Model
## Overview
FUJ Management operates on two Google Sheets and an external bank account. There is no local database — all persistent data lives in Google Sheets, and all member data is fetched at runtime (never committed to git).
## External Data Sources
### 1. Attendance Google Sheet
| Property | Value |
|----------|-------|
| **Sheet ID** | `1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA` |
| **Access** | Public CSV export (no authentication required) |
| **Purpose** | Member roster, weekly practice attendance marks |
| **Scope** | Tuesday practices (20:3022:00) |
#### Schema
```
Row 1: [Title] [blank] [blank] [10/1/2025] [10/8/2025] [10/15/2025] ...
Row 2: Venue per date (ignored by the system)
Row 3: Subtotals per date (ignored by the system)
Row 4+: [Name] [Tier] [Total] [TRUE/FALSE] [TRUE/FALSE] ...
...
Row N: # last line (sentinel — stops parsing)
```
| Column | Index | Content | Example |
|--------|-------|---------|---------|
| A | 0 | Member name | `Jan Novák` |
| B | 1 | Tier code | `A`, `J`, or `X` |
| C | 2 | Total attendance (auto-calculated, ignored by the system) | `12` |
| D+ | 3+ | Attendance per date | `TRUE` or `FALSE` |
#### Tier Codes
| Code | Meaning | Pays fees? |
|------|---------|-----------|
| `A` | Adult | Yes — calculated from this sheet |
| `J` | Junior | No — managed via a separate sheet |
| `X` | Exempt | No |
#### Sentinel Row
The system stops parsing member rows when it encounters a row whose first column contains `# last line` (case-insensitive). Rows starting with `#` are also skipped as comments.
### 2. Payments Google Sheet
| Property | Value |
|----------|-------|
| **Sheet ID** | `1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y` |
| **Access** | Google Sheets API (service account authentication) |
| **Purpose** | Intermediary ledger for bank transactions + manual annotations |
| **Managed by** | `sync_fio_to_sheets.py` (append), `infer_payments.py` (update) |
#### Main Sheet Schema (Columns AK)
| Column | Label | Populated by | Description |
|--------|-------|-------------|-------------|
| A | Date | `sync` | Transaction date (`YYYY-MM-DD`) |
| B | Amount | `sync` | Bank transaction amount in CZK |
| C | manual fix | Human | If non-empty, `infer` will skip this row |
| D | Person | `infer` or human | Member name(s), comma-separated for multi-person payments |
| E | Purpose | `infer` or human | Month(s) covered, e.g. `2026-01` or `2026-01, 2026-02` |
| F | Inferred Amount | `infer` or human | Amount to use for reconciliation (may differ from bank amount) |
| G | Sender | `sync` | Bank sender name/account |
| H | VS | `sync` | Variable symbol |
| I | Message | `sync` | Payment message for recipient |
| J | Bank ID | `sync` | Fio transaction ID (API only) |
| K | Sync ID | `sync` | SHA-256 deduplication hash |
#### Exceptions Sheet Tab
A separate tab named `exceptions` in the same spreadsheet, used for manual fee overrides:
| Column | Label | Content |
|--------|-------|---------|
| A | Name | Member name (plain text) |
| B | Period | Month (`YYYY-MM`) |
| C | Amount | Overridden fee in CZK |
| D | Note | Reason for override (optional) |
The first row is assumed to be a header and is skipped. Name and period values are normalized (diacritics stripped, lowercased) for matching.
### 3. Fio Bank Account
| Property | Value |
|----------|-------|
| **Account number** | `2800359168/2010` |
| **IBAN** | `CZ8520100000002800359168` |
| **Type** | Transparent account |
| **Owner** | Nathan Heilmann |
| **Public URL** | `https://ib.fio.cz/ib/transparent?a=2800359168` |
#### Access Methods
| Method | Trigger | Data richness |
|--------|---------|--------------|
| REST API | `FIO_API_TOKEN` env var set | Full data: sender account, bank ID, user identification, currency |
| HTML scraping | `FIO_API_TOKEN` not set | Partial: date, amount, sender name, message, VS/KS/SS |
#### API Rate Limit
The Fio REST API allows 1 request per 30 seconds per token.
## Fee Calculation Rules
Fees apply only to **tier A (Adult)** members. They are calculated per calendar month based on Tuesday practice attendance:
| Practices attended | Monthly fee |
|-------------------|-------------|
| 0 | 0 CZK |
| 1 | 200 CZK |
| 2+ | 750 CZK |
### Exception Overrides
The fee can be manually overridden per member per month via the `exceptions` tab. When an exception exists:
- The `expected` amount in reconciliation uses the exception amount
- The `original_expected` amount preserves the attendance-based calculation
- The override is displayed in amber/orange in the web UI
### Advance Payments
If a payment references a month not yet covered by attendance data:
- It is tracked as **credit** on the member's account
- Credits are added to the total balance
- When attendance data becomes available for that month, the credit effectively offsets the expected fee
## Reconciliation Data Model
The `reconcile()` function returns this structure:
```python
{
"members": {
"Jan Novák": {
"tier": "A",
"months": {
"2026-01": {
"expected": 750, # Fee after exception application
"original_expected": 750, # Attendance-based fee
"attendance_count": 4, # How many times they came
"exception": None, # or {"amount": 400, "note": "..."}
"paid": 750.0, # Total matched payments
"transactions": [ # Individual payment records
{
"amount": 750.0,
"date": "2026-01-15",
"sender": "Jan Novák",
"message": "leden",
"confidence": "auto"
}
]
}
},
"total_balance": 0 # sum(paid - expected) across all months + off-window credits
}
},
"unmatched": [ # Transactions that couldn't be assigned
{
"date": "2026-01-20",
"amount": 500,
"sender": "Unknown",
"message": "dar"
}
],
"credits": { # Alias for positive total_balance entries
"Jan Novák": 200
}
}
```
## Sync ID Generation
The deduplication key for bank transactions is a SHA-256 hash of:
```
sha256("date|amount|currency|sender|vs|message|bank_id")
```
All values are lowercased before hashing. This ensures:
- Same transaction fetched twice produces the same ID
- Two payments on the same day with different amounts/senders produce different IDs
- The hash is stable across API and HTML scraping modes (shared fields)
## Date Handling
| Source | Format | Normalization |
|--------|--------|--------------|
| Attendance Sheet header | `M/D/YYYY` (US format) | `datetime.strptime(raw, "%m/%d/%Y")` |
| Fio API | `YYYY-MM-DD+HHMM` | Take first 10 characters |
| Fio transparent page | `DD.MM.YYYY` | `datetime.strptime(raw, "%d.%m.%Y")` |
| Google Sheets (unformatted) | Serial number (days since 1899-12-30) | `datetime(1899, 12, 30) + timedelta(days=val)` |
All internal date representation uses `YYYY-MM-DD` format. Month keys use `YYYY-MM`.
---
*Data model documentation generated from comprehensive code analysis on 2026-03-03.*

View File

@@ -0,0 +1,198 @@
# Deployment Guide
## Local Development
### Prerequisites
- **Python 3.13+** (required by `pyproject.toml`)
- **[uv](https://docs.astral.sh/uv/)** — Fast Python package manager
- Google Sheets API credentials (service account JSON)
### Setup
```bash
git clone <repo-url>
cd fuj-management
# Install dependencies
uv sync
# Configure credentials
mkdir -p .secret
cp /path/to/credentials.json .secret/fuj-management-bot-credentials.json
# Optional: Set Fio API token for richer bank data
export FIO_API_TOKEN=your_token_here
# Start the web dashboard
make web
# → Flask server at http://localhost:5001
```
### Makefile Targets
| Target | Command | Description |
|--------|---------|-------------|
| `help` | `make help` | List all available targets |
| `venv` | `make venv` | Sync virtual environment with pyproject.toml |
| `fees` | `make fees` | Print fee calculation table |
| `match` | `make match` | (Legacy) Direct bank matching |
| `web` | `make web` | Start Flask dashboard on port 5001 |
| `sync` | `make sync` | Sync last 30 days of bank transactions |
| `sync-2026` | `make sync-2026` | Sync full year 2026 transactions |
| `infer` | `make infer` | Auto-fill Person/Purpose in the sheet |
| `reconcile` | `make reconcile` | Print CLI balance report |
| `test` | `make test` | Run test suite |
| `test-v` | `make test-v` | Run tests with verbose output |
| `image` | `make image` | Build Docker image |
| `run` | `make run` | Run Docker container locally |
The Makefile includes **automatic venv management**: targets that need Python depend on `.venv/.last_sync`, which triggers `uv sync` when `pyproject.toml` changes.
---
## Docker Container
### Building
```bash
make image
# → docker build -t fuj-management:latest -f build/Dockerfile .
```
### Dockerfile Details
**Base image**: `python:3.13-alpine`
**Build stages**:
1. Install system packages (`bash`, `tzdata`)
2. Set timezone to `Europe/Prague`
3. Install Python dependencies via pip
4. Copy application files (`app.py`, `scripts/`, `templates/`, `Makefile`)
5. Copy entrypoint script
**Exposed port**: 5001
**Health check**: `wget -q -O /dev/null http://localhost:5001/` every 60s
### Running Locally via Docker
```bash
make run
# → docker run -it --rm -p 5001:5001 fuj-management:latest
# With credentials and environment:
docker run -it --rm \
-p 5001:5001 \
-v $(pwd)/.secret:/app/.secret:ro \
-e FIO_API_TOKEN=your_token \
-e BANK_ACCOUNT=CZ8520100000002800359168 \
fuj-management:latest
```
### Entrypoint
The `build/entrypoint.sh` script simply runs:
```bash
exec python3 /app/app.py
```
This uses Flask's built-in server directly. For a production deployment, consider adding gunicorn or waitress (noted as a TODO in the entrypoint).
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `BANK_ACCOUNT` | `CZ8520100000002800359168` | IBAN for QR code generation |
| `FIO_API_TOKEN` | *(none)* | Fio REST API token |
| `PYTHONUNBUFFERED` | `1` (set in Dockerfile) | Ensures real-time log output |
---
## CI/CD Pipeline
### Gitea Actions
The project uses two Gitea Actions workflows:
#### 1. Build and Push (`build.yaml`)
**Triggers**:
- Push of any tag
- Manual dispatch (with custom tag input)
**Steps**:
1. Checkout code
2. Login to Gitea container registry (`gitea.home.hrajfrisbee.cz`)
3. Build Docker image using `build/Dockerfile`
4. Push to `gitea.home.hrajfrisbee.cz/<owner>/<repo>:<tag>`
**Tag resolution**: Uses the git tag name. For manual dispatch, uses the provided input.
#### 2. Deploy to Kubernetes (`kubernetes-deploy.yaml`)
**Triggers**:
- Push to any branch
- Manual dispatch
**Steps**:
1. Checkout code
2. Install kubectl
3. Retrieve Kanidm token from HashiCorp Vault:
- Authenticate to Vault via AppRole (`VAULT_ROLE_ID` / `VAULT_SECRET_ID`)
- Fetch API token from `secret/data/gitea/gitea-ci`
4. Exchange API token for K8s OIDC token via Kanidm:
- POST to `https://idm.home.hrajfrisbee.cz/oauth2/token`
- Token exchange using `urn:ietf:params:oauth:grant-type:token-exchange`
5. Configure kubectl with the OIDC token
6. Run `kubectl auth whoami` and `kubectl get ns` (deploy commands are commented out — WIP)
**Required secrets**:
| Secret | Purpose |
|--------|---------|
| `REGISTRY_TOKEN` | Docker registry authentication |
| `VAULT_ROLE_ID` | HashiCorp Vault AppRole role ID |
| `VAULT_SECRET_ID` | HashiCorp Vault AppRole secret ID |
| `K8S_CA_CERT` | Kubernetes cluster CA certificate |
### Infrastructure Topology
```
Gitea (git push / tag)
├── build.yaml → Docker Build → Gitea Container Registry
│ (gitea.home.hrajfrisbee.cz)
└── kubernetes-deploy.yaml → Vault → Kanidm → K8s Cluster
(192.168.0.31:6443)
```
This is a self-hosted infrastructure stack:
- **Gitea** for git hosting and CI/CD
- **HashiCorp Vault** for secret management
- **Kanidm** for identity/OIDC
- **Kubernetes** for container orchestration
---
## Credentials Management
### Google Sheets API
The system uses a **Google Cloud service account** for accessing the Payments Google Sheet. The credentials file must be:
- Stored at `.secret/fuj-management-bot-credentials.json`
- In Google Cloud service account JSON format
- The service account must be shared (as editor) on the target Google Sheet
For local development with OAuth2 (personal Google account), the system also supports the OAuth2 installed app flow — it will generate a `token.pickle` file on first use.
### Fio Bank API
Optional. Set the `FIO_API_TOKEN` environment variable. The token is generated in Fio internetbanking under Settings → API.
**Rate limit**: 1 request per 30 seconds per token.
---
*Deployment documentation generated from comprehensive code analysis on 2026-03-03.*

View File

@@ -0,0 +1,228 @@
# Development Guide
## Development Environment
### Required Tools
| Tool | Version | Purpose |
|------|---------|---------|
| Python | 3.13+ | Runtime |
| uv | Latest | Dependency management |
| Docker | Latest | Container builds |
| Git | Any | Version control |
| Make | Any | Build automation |
### Initial Setup
```bash
# 1. Clone the repository
git clone <repo-url>
cd fuj-management
# 2. Install dependencies (creates .venv automatically)
uv sync
# 3. Activate the virtual environment
source .venv/bin/activate
# 4. Set up credentials
mkdir -p .secret
# Copy your Google service account JSON here:
cp ~/Downloads/fuj-management-bot-credentials.json .secret/
# 5. (Optional) Set Fio API token
export FIO_API_TOKEN=your_token_here
```
### IDE Configuration
The `.vscode/` directory contains workspace settings. If using VS Code, the Python interpreter should automatically detect the `.venv` directory.
**PYTHONPATH note**: When running scripts from the project root, the Makefile sets `PYTHONPATH=scripts:$PYTHONPATH`. If your IDE doesn't do this, you may see import errors in `match_payments.py` and other scripts that import sibling modules.
## Project Dependencies
Defined in `pyproject.toml`:
| Dependency | Version | Purpose |
|------------|---------|---------|
| `flask` | ≥3.1.3 | Web framework |
| `google-api-python-client` | ≥2.162.0 | Google Sheets API |
| `google-auth-httplib2` | ≥0.2.0 | Google auth transport |
| `google-auth-oauthlib` | ≥1.2.1 | OAuth2 support |
| `qrcode[pil]` | ≥8.0 | QR code generation (with PIL/Pillow backend) |
The project uses `uv` with `package = false` in `[tool.uv]`, meaning it's not an installable package — dependencies are synced directly to the virtual environment.
## Coding Conventions
### Python Style
- No linter or formatter is configured — the codebase uses a pragmatic, readable style
- Type hints are used for function signatures but not exhaustively
- Docstrings follow Google-style format on key functions
- Scripts use `if __name__ == "__main__": main()` pattern
### Import Pattern
Scripts in the `scripts/` directory import from each other as top-level modules:
```python
# In match_payments.py:
from attendance import get_members_with_fees
from czech_utils import normalize, parse_month_references
from sync_fio_to_sheets import get_sheets_service, DEFAULT_SPREADSHEET_ID
```
This works because `scripts/` is added to `sys.path` at runtime (by `app.py` on startup, by Makefile via `PYTHONPATH`, or by scripts adding their own directory to `sys.path`).
### Template Style
- All CSS is inline (no external stylesheets)
- No CSS preprocessors or frameworks
- No JavaScript frameworks — plain DOM manipulation
- Terminal-inspired aesthetic: monospace fonts, green-on-black, dashed borders
### Commit Conventions
The project uses [Conventional Commits](https://www.conventionalcommits.org/):
```
feat: add keyboard navigation to member popup
fix: correct diacritic-insensitive search filter
chore: update dependencies
```
AI commits include a co-author trailer:
```
Co-authored-by: Antigravity <antigravity@google.com>
```
## Architecture Decisions
### Why No Database?
Google Sheets serves as the database because:
1. Club members can view and correct data without special tools
2. No database server to manage or back up
3. Built-in version history and collaborative editing
4. Good enough for ~40 members and ~hundreds of transactions
### Why No Template Inheritance?
Each HTML template is self-contained. While this means CSS duplication, it keeps each page fully independent and easy to understand. For a 3-page app, the duplication cost is minimal.
### Why Flask Development Server in Production?
The Docker container runs Flask's built-in server (`python3 app.py`) rather than gunicorn or waitress. This is intentional — the dashboard is an internal tool accessed by one person at a time. The simplicity outweighs the performance cost.
### Why Scrape HTML When There's an API?
The Fio transparent page scraping exists as a **zero-configuration fallback**. Not everyone has an API token, and the transparent page is always publicly accessible. The API is preferred when available (richer data, stable IDs).
## Common Development Tasks
### Adding a New Web Route
1. Add the route function in `app.py`:
```python
@app.route("/new-page")
def new_page():
# Fetch data
record_step("fetch_data")
# Process
record_step("process_data")
return render_template("new_page.html", ...)
```
2. Create `templates/new_page.html` (copy structure from `fees.html`)
3. Add a link in the nav bar across all templates:
```html
<a href="/new-page">[New Page]</a>
```
4. Add a test in `tests/test_app.py`
### Adding a New Script
1. Create `scripts/new_script.py`
2. Add a Makefile target:
```makefile
new-target: $(PYTHON)
$(PYTHON) scripts/new_script.py
```
3. Update `make help` output
4. Add the `.PHONY` declaration
### Modifying Fee Rules
Fee rules are defined as constants in `scripts/attendance.py`:
```python
FEE_FULL = 750 # 2+ practices
FEE_SINGLE = 200 # 1 practice
```
The calculation logic is in `calculate_fee()`:
```python
def calculate_fee(attendance_count: int) -> int:
if attendance_count == 0: return 0
if attendance_count == 1: return FEE_SINGLE
return FEE_FULL
```
### Adding a New Czech Month Form
If you encounter a Czech month declension not yet supported, add it to `CZECH_MONTHS` in `scripts/czech_utils.py`:
```python
CZECH_MONTHS = {
"leden": 1, "ledna": 1, "lednu": 1,
"lednem": 1, # New instrumental case
...
}
```
## Project History
The project evolved through distinct phases:
1. **Design phase** — Initial brainstorming captured in `docs/project-notes.md`
2. **CLI tools**`calculate_fees.py` and `match_payments.py` for command-line workflows
3. **Bank integration**`fio_utils.py` for transparent page scraping, later API support
4. **Google Sheets sync**`sync_fio_to_sheets.py` + `infer_payments.py` for the ledger pipeline
5. **Web dashboard**`app.py` with the `/fees`, `/reconcile`, and `/payments` pages
6. **Interactive features** — Modal popups, QR payments, keyboard navigation, search filter
7. **Fee exceptions** — Manual override system via the `exceptions` sheet tab
8. **CI/CD** — Gitea Actions for Docker builds and Kubernetes deployment
## Troubleshooting
### "No data." on the web dashboard
The attendance Google Sheet couldn't be fetched, or it returned empty data. Check:
- Internet connectivity
- The sheet ID in `attendance.py` is still valid
- The sheet's public sharing settings haven't changed
### Slow page loads
Each page fetches data from Google Sheets on every request (no caching). Typical load times are 1-3 seconds. If significantly slower:
- Check the performance breakdown (click the render time in the footer)
- Google Sheets API rate limiting may be the cause
### Import errors in scripts
Ensure `PYTHONPATH` includes the `scripts/` directory:
```bash
export PYTHONPATH=scripts:$PYTHONPATH
```
Or use the Makefile, which sets this automatically.
### "Could not fetch exceptions" warning
The `exceptions` tab doesn't exist in the Payments Google Sheet. This is non-fatal — reconciliation proceeds without fee overrides.
---
*Development guide generated from comprehensive code analysis on 2026-03-03.*

View File

@@ -0,0 +1,325 @@
# Scripts Reference
All scripts live in the `scripts/` directory and are invoked via `make` targets or directly with Python.
## Pipeline Scripts
These scripts form the core data processing pipeline. They are typically run in sequence:
### `sync_fio_to_sheets.py` — Bank → Google Sheet
Syncs incoming Fio bank transactions to the Payments Google Sheet. Implements an append-only, deduplicated sync — re-running is always safe.
**Usage**:
```bash
make sync # Last 30 days
make sync-2026 # Full year 2026 (Jan 1 Dec 31, sorted)
# Direct invocation with options:
python scripts/sync_fio_to_sheets.py \
--credentials .secret/fuj-management-bot-credentials.json \
--from 2026-01-01 --to 2026-03-01 \
--sort-by-date
```
**Arguments**:
| Argument | Default | Description |
|----------|---------|-------------|
| `--days` | `30` | Days to look back (ignored if `--from`/`--to` set) |
| `--sheet-id` | Built-in ID | Target Google Sheet |
| `--credentials` | `credentials.json` | Path to Google API credentials |
| `--from` | *(auto)* | Start date (YYYY-MM-DD) |
| `--to` | *(auto)* | End date (YYYY-MM-DD) |
| `--sort-by-date` | `false` | Sort the entire sheet by date after sync |
**How it works**:
1. Reads existing Sync IDs (column K) from the Google Sheet
2. Fetches transactions from Fio bank (API or transparent page scraping)
3. For each transaction, generates a SHA-256 hash: `sha256(date|amount|currency|sender|vs|message|bank_id)`
4. Appends only transactions whose hash doesn't exist in the sheet
5. Optionally sorts the sheet by date
**Key functions**:
| Function | Signature | Description |
|----------|-----------|-------------|
| `get_sheets_service` | `(credentials_path: str) → Resource` | Authenticates with Google Sheets API. Supports both service accounts and OAuth2 flows. |
| `generate_sync_id` | `(tx: dict) → str` | Creates the SHA-256 deduplication hash for a transaction. |
| `sort_sheet_by_date` | `(service, spreadsheet_id)` | Sorts all rows (excluding header) by the Date column. |
| `sync_to_sheets` | `(spreadsheet_id, credentials_path, ...)` | Main sync logic — read existing, fetch new, deduplicate, append. |
**Output example**:
```
Connecting to Google Sheets using .secret/fuj-management-bot-credentials.json...
Reading existing sync IDs from sheet...
Fetching Fio transactions from 2026-02-01 to 2026-03-03...
Found 15 transactions.
Appending 3 new transactions to the sheet...
Sync completed successfully.
Sheet sorted by date.
```
---
### `infer_payments.py` — Auto-Fill Person/Purpose
Scans the Payments Google Sheet for rows with empty Person/Purpose columns and uses name matching and Czech month parsing to fill them automatically.
**Usage**:
```bash
make infer
# Dry run (preview without writing):
python scripts/infer_payments.py \
--credentials .secret/fuj-management-bot-credentials.json \
--dry-run
```
**Arguments**:
| Argument | Default | Description |
|----------|---------|-------------|
| `--sheet-id` | Built-in ID | Target Google Sheet |
| `--credentials` | `credentials.json` | Path to Google API credentials |
| `--dry-run` | `false` | Print inferences without writing to the sheet |
**How it works**:
1. Reads all rows from the Payments Google Sheet
2. Fetches the member list from the Attendance Sheet
3. For each row where Person AND Purpose are empty AND there's no "manual fix":
- Combines sender name + message text
- Attempts to match against member names (using name variants and diacritics normalization)
- Parses Czech month references from the message
- Writes inferred Person, Purpose, and Amount back to the sheet
4. Low-confidence matches are prefixed with `[?]` for manual review
**Skipping rules**:
- If `manual fix` column has any value → skip
- If `Person` column already has a value → skip
- If `Purpose` column already has a value → skip
**Output example**:
```
Connecting to Google Sheets...
Reading sheet data...
Fetching member list for matching...
Inffering details for empty rows...
Row 45: Inferred Jan Novák for 2026-02 (750 CZK)
Row 46: Inferred [?] František Vrbík for 2026-01, 2026-02 (1500 CZK)
Applying 2 updates to the sheet...
Update completed successfully.
```
---
### `match_payments.py` — Reconciliation Engine + CLI Report
The core reconciliation engine. Matches payment transactions against expected fees and generates a detailed report. Also used as a library by `app.py` and `infer_payments.py`.
**Usage**:
```bash
make reconcile
# Direct invocation:
python scripts/match_payments.py \
--credentials .secret/fuj-management-bot-credentials.json \
--sheet-id YOUR_SHEET_ID
```
**Arguments**:
| Argument | Default | Description |
|----------|---------|-------------|
| `--sheet-id` | Built-in ID | Payments Google Sheet |
| `--credentials` | `.secret/fuj-management-bot-credentials.json` | Google API credentials |
| `--bank` | `false` | Fetch directly from Fio bank instead of the Google Sheet |
**Key functions**:
| Function | Description |
|----------|-------------|
| `_build_name_variants(name)` | Generates searchable name variants from a member name. E.g., "František Vrbík (Štrúdl)" → `["frantisek vrbik", "strudl", "vrbik", "frantisek"]` |
| `match_members(text, member_names)` | Finds members mentioned in text. Returns `(name, confidence)` tuples where confidence is `auto` or `review`. |
| `infer_transaction_details(tx, member_names)` | Infers member(s) and month(s) for a single transaction. |
| `format_date(val)` | Normalizes dates from Google Sheets (handles serial numbers and strings). |
| `fetch_sheet_data(spreadsheet_id, credentials_path)` | Reads all rows from the Payments sheet as a list of dicts. |
| `fetch_exceptions(spreadsheet_id, credentials_path)` | Reads fee overrides from the `exceptions` sheet tab. |
| `reconcile(members, sorted_months, transactions, exceptions)` | **Core engine**: matches transactions to members/months, calculates balances. |
| `print_report(result, sorted_months)` | Prints the CLI reconciliation report. |
**Name matching strategy**:
The matching algorithm uses multiple tiers, in order of confidence:
| Priority | What it checks | Confidence |
|----------|---------------|-----------|
| 1 | Full name (normalized) found in text | `auto` |
| 2 | Both first and last name present (any order) | `auto` |
| 3 | Nickname from parentheses matches | `auto` |
| 4 | Last name only (≥4 chars, not in common surname list) | `review` |
| 5 | First name only (≥3 chars) | `review` |
**Common surnames excluded from last-name-only matching**: `novak`, `novakova`, `prach`
If any `auto`-confidence match exists, all `review` matches are discarded.
**Payment allocation**:
When a transaction matches multiple members and/or multiple months, the amount is split **evenly** across all allocations:
```
per_allocation = amount / (num_members × num_months)
```
**CLI report sections**:
1. **Summary table** — Per-member, per-month grid: `OK`, `UNPAID {amount}`, `{paid}/{expected}`, balance
2. **Credits** — Members with positive total balance
3. **Debts** — Members with negative total balance
4. **Unmatched transactions** — Payments that couldn't be assigned
5. **Matched transaction details** — Full breakdown with `[REVIEW]` flags
---
### `calculate_fees.py` — Fee Calculation
Calculates and prints monthly fees in a simple table format.
**Usage**:
```bash
make fees
```
**Output example**:
```
Member | Jan 2026 | Feb 2026
-------------------------------------------------------
Jan Novák | 750 CZK (4) | 200 CZK (1)
Alice Testová | - | 750 CZK (3)
-------------------------------------------------------
TOTAL | 750 CZK | 950 CZK
```
This is a simpler CLI version of the `/fees` web page. It only shows adults (tier A).
---
## Shared Modules
### `attendance.py` — Attendance Data & Fee Logic
Shared module that fetches attendance data from the Google Sheet and computes fees.
**Constants**:
| Constant | Value | Description |
|----------|-------|-------------|
| `SHEET_ID` | `1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA` | Attendance Google Sheet ID |
| `FEE_FULL` | `750` | Monthly fee for 2+ practices |
| `FEE_SINGLE` | `200` | Monthly fee for exactly 1 practice |
| `COL_NAME` | `0` | Column index for member name |
| `COL_TIER` | `1` | Column index for member tier |
| `FIRST_DATE_COL` | `3` | First column with date headers |
**Functions**:
| Function | Signature | Description |
|----------|-----------|-------------|
| `fetch_csv` | `() → list[list[str]]` | Downloads the attendance sheet as CSV via its public export URL. No authentication needed. |
| `parse_dates` | `(header_row) → list[tuple[int, datetime]]` | Parses `M/D/YYYY` dates from the header row and returns `(column_index, date)` pairs. |
| `group_by_month` | `(dates) → dict[str, list[int]]` | Groups column indices by `YYYY-MM` month key. |
| `calculate_fee` | `(count: int) → int` | Applies fee rules: 0→0, 1→200, 2+→750 CZK. |
| `get_members` | `(rows) → list[tuple[str, str, list[str]]]` | Parses member rows. Stops at `# last line` sentinel. Skips comment rows (starting with `#`). |
| `get_members_with_fees` | `() → tuple[list, list[str]]` | Full pipeline: fetch → parse → compute. Returns `(members, sorted_months)` where each member is `(name, tier, {month: (fee, count)})`. |
**Member tier codes**:
| Tier | Meaning | Fees? |
|------|---------|-------|
| `A` | Adult | Yes (200 or 750 CZK) |
| `J` | Junior | No (separate sheet) |
| `X` | Exempt | No |
---
### `fio_utils.py` — Fio Bank Integration
Handles fetching transactions from Fio bank, supporting both API and HTML scraping modes.
**Functions**:
| Function | Description |
|----------|-------------|
| `fetch_transactions(date_from, date_to)` | Main entry point. Uses API if `FIO_API_TOKEN` is set, falls back to transparent page scraping. |
| `fetch_transactions_api(token, date_from, date_to)` | Fetches via Fio REST API (JSON). Returns richer data including sender account and stable bank IDs. |
| `fetch_transactions_transparent(date_from, date_to, account_id)` | Scrapes the public Fio transparent account HTML page. |
| `parse_czech_amount(s)` | Parses Czech currency strings like `"1 500,00 CZK"` to float. |
| `parse_czech_date(s)` | Parses `DD.MM.YYYY` or `DD/MM/YYYY` to `YYYY-MM-DD`. |
**FioTableParser** — A custom `HTMLParser` subclass that extracts transaction rows from the second `<table class="table">` on the Fio transparent page. Column mapping:
| Index | Column |
|-------|--------|
| 0 | Date (Datum) |
| 1 | Amount (Částka) |
| 2 | Type (Typ) |
| 3 | Sender name (Název protiúčtu) |
| 4 | Message (Zpráva pro příjemce) |
| 5 | KS (constant symbol) |
| 6 | VS (variable symbol) |
| 7 | SS (specific symbol) |
| 8 | Note (Poznámka) |
**Transaction dict format** (returned by all fetch functions):
```python
{
"date": "2026-01-15", # YYYY-MM-DD
"amount": 750.0, # Float, always positive (outgoing filtered)
"sender": "Jan Novák", # Sender name
"message": "příspěvek", # Message for recipient
"vs": "12345", # Variable symbol
"ks": "", # Constant symbol
"ss": "", # Specific symbol
"bank_id": "abc123", # Bank operation ID (API only)
"user_id": "...", # User identification (API only)
"sender_account": "...", # Sender account number (API only)
"currency": "CZK" # Currency (API only)
}
```
---
### `czech_utils.py` — Czech Language Utilities
Text processing utilities for Czech language content, critical for matching payment messages.
**`normalize(text: str) → str`**
Strips diacritics and lowercases text using Unicode NFKD normalization:
- `"Štrúdl"``"strudl"`
- `"František Vrbík"``"frantisek vrbik"`
- `"LEDEN 2026"``"leden 2026"`
**`parse_month_references(text: str, default_year=2026) → list[str]`**
Extracts YYYY-MM month references from Czech free text. Handles a remarkable variety of formats:
| Input | Output | Pattern |
|-------|--------|---------|
| `"leden"` | `["2026-01"]` | Czech month name |
| `"ledna"` | `["2026-01"]` | Czech month declension |
| `"01/26"` | `["2026-01"]` | Numeric short year |
| `"1/2026"` | `["2026-01"]` | Numeric full year |
| `"11+12/2025"` | `["2025-11", "2025-12"]` | Multiple slash-separated |
| `"12.2025"` | `["2025-12"]` | Dot notation |
| `"listopad-leden"` | `["2025-11", "2025-12", "2026-01"]` | Range with year wrap |
| `"říjen"` | `["2025-10"]` | Months ≥ October assumed previous year |
**`CZECH_MONTHS`** — Dictionary mapping all Czech month name forms (nominative, genitive, locative) to month numbers. 35 entries covering all 12 months in multiple declensions.
---
*Scripts reference generated from comprehensive code analysis on 2026-03-03.*

View File

@@ -0,0 +1,145 @@
# Testing Guide
## Overview
The project uses Python's built-in `unittest` framework with `unittest.mock` for mocking external dependencies (Google Sheets API, attendance data). Tests live in the `tests/` directory.
## Running Tests
```bash
make test # Run all tests
make test-v # Run with verbose output
```
Under the hood:
```bash
PYTHONPATH=scripts:. python3 -m unittest discover tests
```
The `PYTHONPATH` includes both `scripts/` and the project root so that test files can import from both `app.py` and `scripts/*.py`.
## Test Files
### `test_app.py` — Flask Route Tests
Tests the Flask web application routes using Flask's built-in test client. All external data fetching is mocked.
| Test | What it verifies |
|------|-----------------|
| `test_index_page` | `GET /` returns 200 and contains a redirect to `/fees` |
| `test_fees_route` | `GET /fees` renders the fees dashboard with correct member names |
| `test_reconcile_route` | `GET /reconcile` renders the reconciliation page with payment matching |
| `test_payments_route` | `GET /payments` renders the ledger with grouped transactions |
**Mocking strategy**:
```python
@patch('app.get_members_with_fees')
def test_fees_route(self, mock_get_members):
mock_get_members.return_value = (
[('Test Member', 'A', {'2026-01': (750, 4)})],
['2026-01']
)
response = self.client.get('/fees')
self.assertEqual(response.status_code, 200)
self.assertIn(b'Test Member', response.data)
```
Each test patches the data-fetching functions (`get_members_with_fees`, `fetch_sheet_data`) to return controlled test data, avoiding any network calls.
**Notable**: The reconcile route test also mocks `fetch_sheet_data` and verifies that the reconciliation engine correctly matches a payment against an expected fee (checking for "OK" in the response).
### `test_reconcile_exceptions.py` — Reconciliation Logic Tests
Tests the `reconcile()` function directly (unit tests for the core business logic):
| Test | What it verifies |
|------|-----------------|
| `test_reconcile_applies_exceptions` | When a fee exception exists (400 CZK instead of 750), the expected amount is overridden and balance is calculated correctly |
| `test_reconcile_fallback_to_attendance` | When no exception exists, the attendance-based fee is used |
**Why these tests matter**: The exception system is critical for correctness — an incorrect override could cause members to be shown incorrect amounts owed. These tests verify that:
- Exceptions properly override the attendance-based fee
- The absence of an exception correctly falls back to the standard calculation
- Balances are computed correctly against overridden amounts
## Test Data Patterns
The tests use minimal but representative data:
```python
# A member with attendance-based fee
members = [('Alice', 'A', {'2026-01': (750, 4)})]
# An exception reducing the fee
exceptions = {('alice', '2026-01'): {'amount': 400, 'note': 'Test exception'}}
# A matching payment
transactions = [{
'date': '2026-01-05',
'amount': 400,
'person': 'Alice',
'purpose': '2026-01',
'inferred_amount': 400,
'sender': 'Alice Sender',
'message': 'fee'
}]
```
## What's Not Tested
| Area | Status | Notes |
|------|--------|-------|
| Name matching logic | ❌ Not tested | `match_members()`, `_build_name_variants()` |
| Czech month parsing | ❌ Not tested | `parse_month_references()` |
| Fio bank data fetching | ❌ Not tested | Both API and HTML scraping |
| Sync deduplication | ❌ Not tested | `generate_sync_id()` |
| QR code generation | ❌ Not tested | `/qr` route |
| Payment inference | ❌ Not tested | `infer_payments.py` logic |
| Multi-person payment splitting | ❌ Not tested | Even split across members/months |
| Edge cases | ❌ Not tested | Empty sheets, malformed dates, etc. |
## Writing New Tests
### Adding a Flask route test
```python
# In tests/test_app.py
@patch('app.some_function')
def test_new_route(self, mock_fn):
mock_fn.return_value = expected_data
response = self.client.get('/new-route')
self.assertEqual(response.status_code, 200)
self.assertIn(b'expected content', response.data)
```
### Adding a reconciliation logic test
```python
# In tests/test_reconcile_exceptions.py (or a new test file)
def test_multi_month_payment(self):
members = [('Bob', 'A', {
'2026-01': (750, 3),
'2026-02': (750, 4)
})]
transactions = [{
'date': '2026-02-01',
'amount': 1500,
'person': 'Bob',
'purpose': '2026-01, 2026-02',
'inferred_amount': 1500,
'sender': 'Bob',
'message': 'leden+unor'
}]
result = reconcile(members, ['2026-01', '2026-02'], transactions)
bob = result['members']['Bob']
self.assertEqual(bob['months']['2026-01']['paid'], 750)
self.assertEqual(bob['months']['2026-02']['paid'], 750)
self.assertEqual(bob['total_balance'], 0)
```
---
*Testing documentation generated from comprehensive code analysis on 2026-03-03.*

View File

@@ -0,0 +1,166 @@
# User Guide — FUJ Web Dashboard
## Getting Started
Start the dashboard with:
```bash
make web
```
The dashboard is available at **http://localhost:5001** and provides three pages accessible via the green navigation bar at the top.
## Page 1: Attendance & Fees (`/fees`)
This page answers the question: **"How much does each member owe this month?"**
### What You See
A table with one row per adult member and one column per month. Each cell shows:
| Cell | Meaning |
|------|---------|
| `750 CZK (4)` | Member owes 750 CZK (attended 4 practices that month) |
| `200 CZK (1)` | Member owes 200 CZK (attended 1 practice) |
| `-` | Member didn't attend — no fee |
| `400 (750) CZK (3)` | Fee **overridden** from 750 to 400 CZK (shown in orange) |
The bottom row shows **monthly totals** — the total amount expected from all adult members.
### Fee Rules
| Practices in a month | Monthly fee |
|----------------------|-------------|
| 0 | 0 CZK (no charge) |
| 1 | 200 CZK |
| 2 or more | 750 CZK |
### Source Links
At the top, you'll find direct links to:
- **Attendance Sheet** — the Google Sheet with raw attendance data
- **Payments Ledger** — the Google Sheet with bank transactions
---
## Page 2: Payment Reconciliation (`/reconcile`)
This page answers: **"Who has paid, who hasn't, and who owes extra?"**
### Main Table
Each cell in the matrix shows the payment status for a member × month combination:
| Cell | Color | Meaning |
|------|-------|---------|
| `OK` | 🟢 Green | Fully paid |
| `UNPAID 750` | 🔴 Red | Haven't paid at all |
| `300/750` | 🔴 Red | Partially paid (300 out of 750) |
| `-` | Gray | No fee expected |
| `PAID 200` | — | Payment received but no fee expected |
The rightmost column shows each member's **total balance**:
- **Positive** (green): Member has overpaid / has credit
- **Negative** (red): Member still owes money
- **Zero**: Fully settled
### Search Filter
Type in the search box at the top to filter members by name. The search is **diacritic-insensitive** — typing "novak" will match "Novák".
### Member Details
Click the **`[i]`** icon next to any member's name to open a detailed popup:
1. **Status Summary** — Month-by-month breakdown with attendance count, expected fee, paid amount, and status. Overridden fees are marked with an amber asterisk.
2. **Fee Exceptions** — If any months have manual fee overrides, they're listed here with the override amount and reason.
3. **Payment History** — Every bank transaction matched to this member, showing the date, amount, sender, and payment message.
**Keyboard shortcuts** (when the popup is open):
- `↑` / `↓` — Navigate to the previous/next member
- `Escape` — Close the popup
### QR Code Payments
When you hover over an unpaid or partially paid cell, a red **"Pay"** button appears. Clicking it opens a QR code that can be scanned with any Czech banking app. The QR code is pre-filled with:
- The club's bank account number
- The exact amount owed
- A payment message identifying the member and month
This makes it trivial to send a payment link to a member who owes money.
### Summary Sections
Below the main table, three additional sections may appear:
| Section | Shows |
|---------|-------|
| **Credits** | Members with positive balances (advance payments or overpayments) |
| **Debts** | Members with negative balances (outstanding fees) |
| **Unmatched Transactions** | Bank transactions that couldn't be automatically matched to any member |
---
## Page 3: Payments Ledger (`/payments`)
This page answers: **"What payments has each member made?"**
### What You See
Transactions grouped by member name, each showing:
- **Date** — When the payment was received
- **Amount** — How much was paid (in CZK)
- **Purpose** — Which month(s) the payment covers
- **Bank Message** — The original message from the bank transfer
Transactions are sorted newest-first within each member's section.
### Unmatched Payments
Transactions that couldn't be assigned to a member appear under **"Unmatched / Unknown"** — these typically need manual review in the Google Sheet.
---
## Performance Footer
Every page shows a **render time** in the bottom-right corner (very small, gray text). This tells you how long the page took to generate.
Click on it to reveal a detailed breakdown showing how much time was spent on each step (fetching members, fetching payments, reconciliation, template rendering, etc.). This is mostly useful for debugging slow page loads.
---
## Common Workflows
### "A member asks how much they owe"
1. Open `/reconcile`
2. Search for the member's name
3. Their row shows the exact status per month
4. Click `[i]` for detailed payment history
### "A member wants to pay"
1. Open `/reconcile`
2. Find the unpaid cell
3. Hover and click the red **Pay** button
4. Share the QR code with the member (screenshot or show on screen)
### "I want to see all payments from one person"
1. Open `/payments`
2. Scroll to the member's section (alphabetically sorted)
### "A transaction wasn't matched correctly"
1. Open the **Payments Ledger** Google Sheet (link at the top of any page)
2. Find the row
3. Manually correct the **Person** and/or **Purpose** columns
4. Put any marker in the **manual fix** column to prevent the inference script from overwriting your edit
5. Refresh the web dashboard
---
*User guide generated from comprehensive code analysis on 2026-03-03.*

View File

@@ -0,0 +1,256 @@
# Web Application Documentation
## Overview
The FUJ Management web application is a Flask-based dashboard that provides real-time visibility into club finances. It renders server-side HTML with embedded CSS and JavaScript — no build tools, no npm, no framework. The UI follows a distinctive **terminal-inspired aesthetic** with monospace fonts, green-on-black colors, and dashed borders.
## Routes
### `GET /` — Index (Redirect)
Redirects to `/fees` via an HTML meta refresh tag. This exists so the root URL always leads somewhere useful.
### `GET /fees` — Attendance & Fees Dashboard
**Template**: `templates/fees.html`
Displays a table of all adult members with their calculated monthly fees based on attendance. Each cell shows the fee amount (in CZK), the number of practices attended, or a dash for months with zero attendance.
**Data pipeline**:
```
attendance.py::get_members_with_fees() → Filter to tier "A" (adults)
match_payments.py::fetch_exceptions() → Check for fee overrides
→ Format cells with override indicators
→ Render fees.html with totals row
```
**Visual features**:
- Fee overrides shown in **orange** with the original amount in parentheses
- Empty months shown in muted gray
- Monthly totals row at the bottom
- Performance timing in the footer (click to expand breakdown)
**Template variables**:
| Variable | Type | Content |
|----------|------|---------|
| `months` | `list[str]` | Month labels like "Jan 2026" |
| `results` | `list[dict]` | `{name, months: [{cell, overridden}]}` |
| `totals` | `list[str]` | Monthly total strings like "3750 CZK" |
| `attendance_url` | `str` | Link to the attendance Google Sheet |
| `payments_url` | `str` | Link to the payments Google Sheet |
### `GET /reconcile` — Payment Reconciliation
**Template**: `templates/reconcile.html` (802 lines — the most complex template)
The centerpiece of the application. Shows a matrix of members × months with payment status, plus summary sections for credits, debts, and unmatched transactions.
**Data pipeline**:
```
attendance.py::get_members_with_fees() → All members + fees
match_payments.py::fetch_sheet_data() → All payment transactions
match_payments.py::fetch_exceptions() → Fee overrides
match_payments.py::reconcile() → Match payments ↔ fees
→ Render reconcile.html
```
**Cell statuses**:
| Status | CSS Class | Display | Meaning |
|--------|-----------|---------|---------|
| `empty` | `cell-empty` | `-` | No fee expected, no payment |
| `ok` | `cell-ok` | `OK` | Paid in full (green) |
| `partial` | `cell-unpaid` | `300/750` | Partially paid (red) |
| `unpaid` | `cell-unpaid` | `UNPAID 750` | Nothing paid (red) |
| `surplus` | — | `PAID 200` | Payment received but no fee expected |
**Interactive features**:
1. **Member detail modal** — Click the `[i]` icon next to any member name to see:
- Status summary table (month, attendance count, expected, paid, status)
- Fee exceptions (if any, shown in amber)
- Full payment history with dates, amounts, senders, and messages
2. **Keyboard navigation** — When a member modal is open:
- `↑` / `↓` arrows navigate between members (respecting search filter)
- `Escape` closes the modal
3. **Name search filter** — Type in the search box to filter members. Uses diacritic-insensitive matching (e.g., typing "novak" matches "Novák").
4. **QR Payment** — Hover over an unpaid/partial cell to reveal a "Pay" button. Clicking it opens a QR code modal with:
- A Czech SPD-format QR code (scannable by Czech banking apps)
- Pre-filled account number, amount, and payment message
- The QR image is generated server-side via `GET /qr`
**Client-side data**:
The template receives a full JSON dump of member data (`member_data`) embedded in a `<script>` tag. This powers the modal without additional API calls:
```javascript
const memberData = {{ member_data | safe }};
const sortedMonths = {{ raw_months | tojson }};
```
**Summary sections** (rendered below the main table):
| Section | Shown when | Content |
|---------|-----------|---------|
| Credits | Any member has positive balance | Names with surplus amounts |
| Debts | Any member has negative balance | Names with outstanding amounts (red) |
| Unmatched Transactions | Any transaction couldn't be matched | Date, amount, sender, message |
### `GET /payments` — Payments Ledger
**Template**: `templates/payments.html`
Displays all bank transactions grouped by member name. Each member section shows their transactions in reverse chronological order.
**Data pipeline**:
```
match_payments.py::fetch_sheet_data() → All transactions
→ Group by Person column
→ Strip [?] markers
→ Handle comma-separated people
→ Sort by date descending
→ Render payments.html
```
**Multi-person handling**: If a transaction's "Person" field contains comma-separated names (e.g., "Alice, Bob"), the transaction appears under both Alice's and Bob's sections.
### `GET /qr` — QR Code Generator
Returns a PNG image containing a Czech SPD (Short Payment Descriptor) QR code.
**Query parameters**:
| Parameter | Default | Description |
|-----------|---------|-------------|
| `account` | `BANK_ACCOUNT` env var | IBAN or Czech account number |
| `amount` | `0` | Payment amount |
| `message` | *(empty)* | Payment message (max 60 chars) |
**SPD format**: `SPD*1.0*ACC:{account}*AM:{amount}*CC:CZK*MSG:{message}`
This format is recognized by all Czech banking apps and generates a pre-filled payment order when scanned.
## UI Design System
### Color Palette
| Element | Color | Hex |
|---------|-------|-----|
| Background | Near-black | `#0c0c0c` |
| Base text | Medium gray | `#cccccc` |
| Headings, accents, "OK" | Terminal green | `#00ff00` |
| Unpaid, debts | Alert red | `#ff3333` |
| Fee overrides | Amber/orange | `#ffa500` / `#ffaa00` |
| Empty/muted | Dark gray | `#444444` |
| Borders | Subtle gray | `#333` (dashed), `#555` (solid) |
### Typography
All text uses the system monospace font stack:
```css
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
```
Base font size is 11px with 1.2 line-height — intentionally dense for a data-heavy dashboard.
### Navigation
A persistent nav bar appears at the top of every page:
```
[Attendance/Fees] [Payment Reconciliation] [Payments Ledger]
```
The active page's link is highlighted with inverted colors (black text on green background).
### Shared Footer
Every page includes a click-to-expand performance timer showing total render time and a per-step breakdown.
## Flask Application Architecture
### Request Lifecycle
```
Request → @app.before_request (start timer) → Route handler → Template → Response
│ │
▼ ▼
g.start_time record_step("fetch_members")
g.steps = [] record_step("fetch_payments")
record_step("process_data")
@app.context_processor
inject_render_time()
{{ get_render_time() }}
in template footer
```
### Module Loading
The Flask app adds the `scripts/` directory to `sys.path` at startup, allowing direct imports from scripts:
```python
scripts_dir = Path(__file__).parent / "scripts"
sys.path.append(str(scripts_dir))
from attendance import get_members_with_fees, SHEET_ID
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions, normalize
```
### Environment Variables
| Variable | Default | Purpose |
|----------|---------|---------|
| `BANK_ACCOUNT` | `CZ8520100000002800359168` | Bank account for QR code generation |
| `FIO_API_TOKEN` | *(none)* | Fio API token (used by `fio_utils.py`) |
### Error Handling
The application has minimal error handling:
- If Google Sheets returns no data, routes return a simple "No data." text response
- No custom error pages for 404/500
- Exceptions propagate to Flask's default error handler (debug mode in development, 500 in production)
## Template Architecture
All three page templates share a common structure:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FUJ [Page Name]</title>
<style>
/* ALL CSS is inline — no external stylesheets */
/* ~150-400 lines of CSS per template */
</style>
</head>
<body>
<div class="nav"><!-- 3-link navigation --></div>
<h1>Page Title</h1>
<div class="description"><!-- Source links --></div>
<!-- Page-specific content -->
<div class="footer"><!-- Render time --></div>
<script>
/* Page-specific JavaScript (only in reconcile.html) */
</script>
</body>
</html>
```
There is no shared base template (no Jinja2 template inheritance). CSS is duplicated across templates with small variations.
---
*Web application documentation generated from comprehensive code analysis on 2026-03-03.*

36
docs/by-gemini/README.md Normal file
View File

@@ -0,0 +1,36 @@
# FUJ Management System
Welcome to the **FUJ Management System**, a streamlined solution for managing Ultimate Frisbee club finances, attendance, and member payments. This system automates the tedious parts of club management, keeping your ledger clean and your reconciliation painless.
## 🚀 Mission
The project's goal is to minimize manual entry and potential human error in club management by:
1. **Automating Bank Synchronization**: Periodically fetching transactions from Fio bank.
2. **Smart Inference**: Using heuristics to match bank transactions to members and payment periods.
3. **Visual Reconciliation**: Providing a clear, real-time web dashboard for managers to track who has paid and who is in debt.
## ✨ Key Features
- **Seamless Bank Integration**: Synchronize transactions directly from the Fio bank API into a Google Spreadsheet.
- **Intelligent Matching**: Automatic detection of member names and payment periods from transaction messages using diacritic-insensitive Czech text processing.
- **Dynamic Dashboard**: A Flask-powered web interface displaying monthly fees, payment status (OK, Partial, Unpaid), and total balances.
- **Manual Overrides**: Support for fee exceptions and manual payment matching when automation needs a human touch.
- **QR Payment Generation**: Integrated QR code generation to make paying outstanding fees trivial for members.
## 🛠 Tech Stack
- **Backend**: Python 3.12+ (managed with `uv`)
- **Web Framework**: Flask with Jinja2 templates
- **Data Storage**: Google Sheets (used as a collaborative database)
- **APIs**: Fio Bank API, Google Sheets API v4
- **Containerization**: Docker / OCI Images
- **Automation**: `Makefile` based workflow
---
## 📂 Documentation Guide
- [Architecture](architecture.md): High-level system design and data flow.
- [User Guide](user-guide.md): How to operate the system as a club manager.
- [Support Scripts](scripts.md): Detailed reference for CLI tools.
- [Deployment](deployment.md): Technical setup and infrastructure instructions.

View File

@@ -0,0 +1,48 @@
# System Architecture
The FUJ Management system is designed around a **"Sheet-as-a-Database"** architecture. This allows for easy manual editing and transparency while enabling powerful automation.
## 🔄 High-Level Data Flow
```mermaid
graph TD
Fio[Fio Bank API] -->|Sync Script| GS(Google Spreadsheet)
Att[Attendance Sheet] -->|CSV Export| App(Flask Web App)
GS -->|API Fetch| App
App -->|Display| UI[Manager Dashboard]
GS -.->|Manual Edits| GS
App -->|Generate| QR[QR Codes for Members]
```
### 1. Data Ingestion (Bank to Sheet)
The synchronization pipeline moves raw bank data into a structured format:
- `sync_fio_to_sheets.py` fetches transactions and appends them to the "Transactions" sheet.
- Each transaction is assigned a unique `Sync ID` to prevent duplicates.
- `infer_payments.py` processes new rows, attempting to fill the `Person`, `Purpose`, and `Inferred Amount` columns based on the message and sender.
### 2. Logic & Reconciliation
The core logic resides in shared Python scripts:
- **Attendance**: `attendance.py` pulls the latest practice data from a separate attendance sheet and calculates expected fees (e.g., 0/200/750 CZK rules).
- **Matching**: `match_payments.py` performs the "heavy lifting" by correlating members, months, and payments. It handles partial payments, overpayments (credits), and manual exceptions.
### 3. Presentation Layer
The Flask application (`app.py`) serves as the primary interface:
- **Fees View**: Shows attendance-based charges.
- **Reconciliation View**: The main "truth" dashboard showing balance per member.
- **Payments View**: Historical list of transactions grouped by member.
## 🛡 Security & Authentication
- **Fio Bank**: Authorized via a private API token (kept in `.secret/`).
- **Google Sheets**: Authenticated via a **Service Account** or **OAuth2** (using `.secret/fuj-management-bot-credentials.json`).
- **Environment**: Secrets are never committed; the `.secret/` directory is git-ignored.
## 🧩 Key Components
| Component | Responsibility |
| :--- | :--- |
| **Google Spreadsheet** | Unified source of truth for transactions and manual overrides. |
| **scripts/** | A suite of CLI utilities for batch processing and data maintenance. |
| **Flask App** | Read-only views for state visualization and QR code generation. |
| **czech_utils.py** | Diacritic-normalization and NLP for Czech month/name parsing. |
```

View File

@@ -0,0 +1,72 @@
# Deployment & Technical Setup
This document provides instructions for developers and devops engineers to set up and deploy the FUJ Management system.
## 🛠 Prerequisites
- **Python 3.12+**: The project uses modern type hinting and syntax features.
- **uv**: High-performance Python package installer and resolver.
- Install via brew: `brew install uv`
- **Docker** (Optional): For containerized deployments.
## ⚙️ Initial Setup
1. **Clone the repository**:
```bash
git clone <repo-url>
cd fuj-management
```
2. **Install dependencies**:
Using `uv`, everything is handled automatically:
```bash
make venv
```
3. **Secrets Management**:
Create a `.secret/` directory. You will need two main credentials:
- `fuj-management-bot-credentials.json`: A Google Cloud Service Account key with access to the Sheets API.
- `fio-token.txt`: (Implicitly used by `fio_utils.py`) Your Fio bank API token.
Ensure these are never committed! They are ignored by `.gitignore`.
## 🐳 Containerization
The project can be built and run as an OCI image.
1. **Build the image**:
```bash
make image
```
This uses the `build/Dockerfile`, which is optimized for small size and security.
2. **Run the container**:
```bash
make run
```
The app exposes port `5001`.
## 🧪 Testing & Validation
The project includes a suite of infrastructure and logic tests.
- **Run all tests**:
```bash
make test
```
- **Verbose output**:
```bash
make test-v
```
Tests are located in the `tests/` directory and use the standard Python `unittest` framework. They cover:
- CSV parsing logic.
- Fee calculation rules.
- Name matching and normalization.
## 🚀 Future Roadmap
- **Automated Backups**: Regular snapshots of the Google Sheet.
- **Authentication Layer**: Login for the web dashboard (currently assumes internal VPN or trusted environment).
- **Gitea Actions**: Continuous Integration for building and testing images.
```

66
docs/by-gemini/scripts.md Normal file
View File

@@ -0,0 +1,66 @@
# Support Scripts Reference
The project includes several CLI utilities located in the `scripts/` directory. Most are accessible via `make` targets.
## 🚀 Primary Scripts
### `sync_fio_to_sheets.py`
**Target**: `make sync` | `make sync-2026`
- **Purpose**: Downloads transactions from Fio bank via API and appends new ones to the Google Sheet.
- **Key Logic**: Uses a `Sync ID` (SHA-256 hash of transaction details) to ensure that even if the sync is run multiple times, no duplicate rows are created.
- **Arguments**:
- `--days`: How many days back to look (default 30).
- `--from/--to`: Specific date range.
- `--sort-by-date`: Re-sorts the spreadsheet after appending.
### `infer_payments.py`
**Target**: `make infer`
- **Purpose**: Processes the "Transactions" sheet to fill in `Person`, `Purpose`, and `Inferred Amount`.
- **Logic**:
- Analyzes the `Sender` and `Message` fields.
- Uses `match_payments.py` heuristics to find members.
- If confidence is low, prefixes the name with `[?]` to flag it for manual review.
- Won't overwrite cells that already have data (respecting your manual fixes).
### `match_payments.py`
**Target**: `make match` | `make reconcile`
- **Purpose**: The core "Reconciliation Engine".
- **Logic**:
- Fetches attendance fees (from `attendance.py`).
- Fetches transaction data.
- Correlates them based on inferred `Person` and `Purpose`.
- Handles "rollover" balances—extra money from one month is tracked as a credit for the next.
---
## 🛠 Utility Modules
### `attendance.py`
- Handles the connection to the Google Attendance sheet (exported as CSV).
- Implements the club's fee rules:
- 0 practices = 0 CZK
- 1 practice = 200 CZK
- 2+ practices = 750 CZK
- *Note*: Fee calculation only applies to members in Tier "A" (Adults).
### `czech_utils.py`
- **Normalization**: Strips diacritics and lowercases text (e.g., `František` -> `frantisek`).
- **Month Parsing**: Advanced regex to detect month references in Czech (e.g., "leden-brezen", "11+12/25", "na únor").
### `fio_utils.py`
- Low-level wrapper for the Fio Bank API.
- Handles HTTP requests and JSON parsing for transaction lists.
---
## ⚙️ Makefile Summary
| Command | Action |
| :--- | :--- |
| `make fees` | Preview calculated fees based on attendance. |
| `make sync` | Sync last 30 days of bank data. |
| `make infer` | Run smart tagging on the sheet. |
| `make reconcile` | Run a text-based reconciliation report in terminal. |
| `make web` | Start the Flask dashboard. |
| `make test` | Run the test suite. |
```

View File

@@ -0,0 +1,61 @@
# User Guide
This guide is intended for club managers who use the FUJ Management system for day-to-day operations.
## 🛠 Operational Workflow
To keep the club finances up-to-date, follow these steps periodically (e.g., once a week):
1. **Sync Bank Transactions**:
Run the sync script to pull the latest payments from Fio.
```bash
make sync
```
2. **Infer Payments**:
Let the system automatically tag who paid for what.
```bash
make infer
```
3. **Manual Review (Google Sheets)**:
Open the Google Spreadsheet. Check rows with the `[?]` prefix in the `Person` column—these require human confirmation.
- If correct: Remove the `[?]` prefix.
- If incorrect: Manually fix the `Person` and `Purpose`.
- If a payment covers a special case: Use the **exceptions** sheet to override expected fees.
4. **Check Reconciliation Dashboard**:
Start the web app to see the final balance report.
```bash
make web
```
Navigate to `http://localhost:5001/reconcile`.
---
## 📊 Understanding the Dashboard
### Reconciliation Page
- **Green (OK)**: Member has paid exactly what was expected (or more).
- **Orange (Partial)**: Some payment was received, but there's still a debt.
- **Red (UNPAID)**: No payment recorded for this month.
- **Blue (SURPLUS)**: Payment received for a month where no fee was expected.
### Handling Debts
If a member is in debt, you can click on the unpaid/partial cell to get a **QR Platba** link. You can send this link or screenshot to the member to facilitate quick payment.
---
## ❓ FAQ & Troubleshooting
### Why is a payment "Unmatched"?
A payment stays unmatched if neither the sender name nor the message contains recognizable member names or nicknames.
- **Fix**: Manually enter the member's name in the `Person` column in the Google Sheet.
### How do I handle a "Family Discount" or "Prepaid Year"?
Use the `exceptions` sheet in the Google Spreadsheet.
1. Add the member's name (exactly as it appears in attendance).
2. Enter the month (e.g., `2026-03`).
3. Enter the new `Amount` (use `0` for prepaid).
4. Add a `Note` for clarity.
### The web app is slow to load.
The app fetches data from Google Sheets API on every request. This ensures real-time data but can take a few seconds. The "Performance Breakdown" footer shows exactly where the time was spent.
```

43
docs/index.html Normal file
View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>FUJ Management - Documentation</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="description" content="Documentation for FUJ Management Application">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/docsify@4/lib/themes/vue.css">
<style>
:root {
--theme-color: #42b983;
}
.sidebar {
background-color: #f8f9fa;
}
</style>
</head>
<body>
<div id="app">Loading documentation...</div>
<script>
window.$docsify = {
name: 'FUJ Management',
repo: '',
loadSidebar: true,
subMaxLevel: 2,
search: 'auto',
auto2top: true,
alias: {
'/.*/_sidebar.md': '/_sidebar.md'
}
}
</script>
<!-- Docsify v4 -->
<script src="//cdn.jsdelivr.net/npm/docsify@4"></script>
<script src="//cdn.jsdelivr.net/npm/docsify/lib/plugins/search.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/docsify-copy-code"></script>
</body>
</html>

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:** M2 — Pure-domain helpers
**Started:** 2026-05-04
**Last updated:** 2026-05-04
## How to use
- Tick a checkbox when the task's PR/commit lands. Append the SHA in the same
line: `[x] **M1.1** ... — `abc1234``.
- One task = one focused commit or PR. If a task balloons, split it and add
sub-tasks below the parent.
- Note decisions, surprises, or blockers under "Notes & decisions" at the
bottom — that's where future-you (or a contributor) will look first.
- Don't reorder milestones. Within a milestone, tasks can be done in any
order unless explicitly noted.
---
## M1 — Skeleton + tooling
Goal: `make web-go` serves a hello page on :8080 in parallel with `make web-py` on :5001. Lint clean.
- [x] **M1.1** Create `go/` tree skeleton + `go.mod` initialized to latest stable Go
- [x] **M1.2** Add `cmd/fuj/main.go` with subcommand dispatcher — stdlib `flag` + `os.Args[1]` switch
- [x] **M1.3** Wire `fuj server` subcommand: `net/http` ServeMux on `:8080`, plaintext hello page
- [x] **M1.4** Add Makefile targets: `go-build`, `go-test`, `go-run`, `go-lint`
- [x] **M1.5** Rename existing `make web` → `make web-py`; added `make web-go`; kept `make web` as alias
- [x] **M1.6** Add `go/.golangci.yml` (govet, staticcheck, errcheck, gofumpt, unused) + `make go-lint` clean
- [x] **M1.7** Write `go/build/Dockerfile` (multi-stage `golang:1.26` → `alpine:3`); parallel `build-go` job in Gitea CI
- [x] **M1.8** Add `internal/config` package mirroring `scripts/config.py` (same env var names + defaults)
- [x] **M1.9** Add `internal/logging` (slog, level from config) + `middleware/timer.go` (method/path/status/ms)
- [x] **M1.10** Gate passed: `make go-build`, `make go-lint`, `make go-test`, `curl :8080` all green; CHANGELOG entry added
**Gate:** ✅ `make go-build` succeeds, `curl localhost:8080` returns hello page, `make go-lint` clean.
---
## M2 — Pure-domain helpers (port leaf-first)
Goal: every pure function from the Python backend exists in Go with a parity test against captured fixtures (M3 produces fixtures in parallel — order is M2.1 → M3.1/M3.2 → M3.3+ alongside M2.2+).
Each task: port the function, write Go unit tests for fresh cases, hook into the Tier-1 parity runner.
- [ ] **M2.1** `domain/czech.Normalize` — port [czech_utils.py](scripts/czech_utils.py) `normalize` (NFKD + combining-mark strip + lowercase)
- [ ] **M2.2** `domain/czech.ParseMonthReferences` — port `parse_month_references` (45 month declensions, range wrap, year inference)
- [ ] **M2.3** `domain/fees.CalculateFee` — port [attendance.py](scripts/attendance.py) `calculate_fee` (constants table)
- [ ] **M2.4** `domain/fees.CalculateJuniorFee` — port `calculate_junior_fee` with `Expected{Value int; Unknown bool}` for the `"?"` sentinel
- [ ] **M2.5** `domain/money.ParseCZK` — port [infer_payments.py](scripts/infer_payments.py) `parse_czk_amount` (Czech locale: comma decimal, dot/space thousand separators)
- [ ] **M2.6** `domain/synch.GenerateSyncID` — port [sync_fio_to_sheets.py](scripts/sync_fio_to_sheets.py) `generate_sync_id` (SHA-256, byte-stable hash; verify float string format against real sheet rows)
- [ ] **M2.7** `domain/matching.BuildNameVariants` + `MatchMembers` — port `_build_name_variants` and `match_members` from [match_payments.py](scripts/match_payments.py) (auto vs review confidence, common-surname filter)
- [ ] **M2.8** `domain/matching.InferTransactionDetails` — port `infer_transaction_details` (composes name + month parsing)
- [ ] **M2.9** `domain/matching.FormatDate` — port `format_date` (handles Google Sheets serial-day numbers since 1899-12-30)
- [ ] **M2.10** `domain/reconcile.Reconcile` — port `reconcile` (three-phase allocation: greedy / proportional with float-remainder absorption / even-split fallback). The single most load-bearing function; budget extra time.
- [ ] **M2.11** `fuj fees` subcommand wired up via `domain/fees` + (M4-stub) attendance loader — fail gracefully on missing IO until M4 lands
- [ ] **M2.12** `fuj reconcile` subcommand similarly stubbed
**Gate:** `cd go && go test -tags=parity ./tests/parity/pure/...` green for every fixture in `tests/fixtures/pure/`.
---
## M3 — Fixture capture + characterization framework
Goal: deterministic, PII-free fixture corpus that drives parity tests. Runs in parallel with M2 (M3.1/M3.2 unblocks M2.1).
- [ ] **M3.1** `scripts/capture_fixtures.py` — pure-function output dumper. Reads inputs from stdin / argv, prints `{"input":..., "output":...}` JSON
- [ ] **M3.2** `scripts/scrub_fixtures.py` — replaces names with `Member_<8hex>` (deterministic per name); scrambles sender/account/VS/bank_id with stable bijection; preserves dates, amounts, exception keys
- [ ] **M3.3** Capture pure-fn fixtures for M2.1M2.9 (run helper + scrubber, commit to `tests/fixtures/pure/<func>/<case>.json`)
- [ ] **M3.4** Capture ~10 reconcile fixtures spanning every code path: greedy, proportional (float remainder), even-split, out-of-window credit, exception override, `other:` purpose, junior `"?"`, multi-person comma-split, multi-month range, unmatched. Commit to `tests/fixtures/reconcile/`
- [ ] **M3.5** Hook fixtures into Tier-1 test runner with `-tags=parity` build constraint
- [ ] **M3.6** Document fixture-refresh workflow in `tests/fixtures/README.md` (what to do when sheet schema changes)
**Gate:** `tests/fixtures/` populated; M2 parity tests green; raw `tmp/*.json` confirmed gitignored.
---
## M4 — IO layer behind interfaces
Goal: every external IO (Sheets, Drive, Fio, file cache) accessed through a narrow Go interface with both a real and a fake implementation.
- [ ] **M4.1** Design IO interfaces (`SheetsClient`, `DriveClient`, `FioClient`, `FileCache`) + in-memory fakes seeded from M3 fixtures
- [ ] **M4.2** `internal/io/sheets` — Google client (read + append + batchUpdate); integration test against a separate test sheet (NOT prod)
- [ ] **M4.3** `internal/io/drive` — Drive `modifiedTime` client + integration test
- [ ] **M4.4** `internal/io/fio` — API JSON impl (token-based); parses by hardcoded `column0..column22` indices matching [fio_utils.py](scripts/fio_utils.py)
- [ ] **M4.5** `internal/io/fio` — transparent-page HTML scraper using `golang.org/x/net/html` token visitor; targets the **second** `<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.

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"]

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

@@ -0,0 +1,84 @@
package main
import (
"flag"
"fmt"
"fuj-management/go/internal/config"
"fuj-management/go/internal/logging"
"fuj-management/go/internal/web"
"os"
)
// 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", "reconcile", "sync", "infer":
fmt.Fprintf(os.Stderr, "fuj %s: not implemented yet (lands in M2/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 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 [M2]
reconcile Show balance report [M2]
sync Sync Fio transactions [M4]
infer Infer payment details [M4]`)
}

3
go/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module fuj-management/go
go 1.26.1

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,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,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)
}
}

View File

@@ -0,0 +1 @@
Now on both reconiciliation pages in the balance column i want to have a button "Pay All" which will create a new row in the transactions table with amount equal to the balance and with a note same as for payment for single period but stating all periods debt consist of

View File

@@ -0,0 +1,21 @@
---
i have new attendance sheet specifically for juniors over here: https://docs.google.com/spreadsheets/d/1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA/edit?gid=1213318614#gid=1213318614
I would like you to treat as junior anyone in that sheet
- who does not have tier: X
- is above line that says in column A: # Treneri
i want to create similar page as we have in /fees, but for juniors - let's give it path /fees-juniors
i want you to merge monthly attendance from both sheets in the document, but from the first sheet collect only attendance for members in tier: J
Rules for monthly payments will be:
- attended only once - put ? mark as output
- 2 and more: 500 czk per month
Also i want to have an option to merge multiple subsequent months to act as one for the payment, for now give me an option to specify it in some datastructure in the code, later we might read it from google sheet. Immediatelly prepare merge of january and february 2026
Also even though now the monthly payment is one value, i would like to have it configurable per month, for now prepare exception for september 2025 with 250
---
cool, now i need an improvement: if the member name in both sheets is exactly the same i want to treat it as one person. Also i want you to keep attendances from "adult practice" (first sheet) and juniors practices (other one) in a datastructure separately, so that you can display primarily sum of those, but also each of them (in brackets after sum) so that we have better visibility

View File

@@ -0,0 +1,7 @@
i would like to implement caching of data that we load from the google documents. For all of them. I do not need persistence across application restarts, so file of whatever format in tmp directory would be good enough. I think it would be good idea to read metadata about documents we access - last modified time? and reload these files only when document is newer than cached data.
Suggest solution, suggest file format for caching.
------------
i do not need caching for scripts, caching is relevant for web app only

View File

@@ -0,0 +1,34 @@
# Junior Fees Implementation Summary
Based on the recent updates, we have introduced a dedicated system for tracking, displaying, and reconciling junior team attendances and payments.
## 1. Implemented Features
- **Dual-Sheet Architecture:** The system now pulls attendance from two separate Google Sheet tabs—one for adult practices and another for junior practices.
- **New Views:**
- `/fees-juniors`: A dedicated dashboard showing junior attendances and calculated fees.
- `/reconcile-juniors`: A dedicated page matching Fio bank transactions against expected junior fees.
- **Granular Attendance Display:** The UI clearly separates and tallies adult (`A`) and junior (`J`) practice counts for each member (e.g., `4 (2A+2J)` or `2 (J)`).
## 2. Membership Rules
- **Identification:** A member is processed as a junior if they appear in the *Junior Sheet*, UNLESS:
- They are listed below the separator line `# Treneri` (or `# Trenéři`).
- Their tier is explicitly marked as `X`.
- **Adult Sheet Fallback:** Members from the Adult Sheet whose tier is marked as `J` are also tracked as juniors.
- **Merging Identities:** If a member has the identical name in both the Adult Sheet and the Junior Sheet, their attendance records are merged together into a single profile.
## 3. Fee Calculation Rules
The base fee calculation for juniors relies on the total combined attendance across both adult and junior practices for a given month:
- **0 attendances:** 0 CZK
- **Exactly 1 attendance:** `?` (Flags the month for manual review/decision)
- **2 or more attendances:** 500 CZK (Default base rate)
## 4. Exceptions & Overrides
We have hardcoded specific timeline and pricing exceptions directly into the logic:
- **Modified Monthly Rates:**
- **September 2025** (`2025-09`) is explicitly configured to have a fee of **250 CZK** for 2+ attendances instead of the default 500 CZK.
- **Merged Billing Months:**
To handle holidays and off-seasons, certain subsequent months are merged and billed as a single period. Their attendances are summed up before the fee rule is applied. The current active merges are:
- **December 2025** is merged into **January 2026**
- **September 2025** is merged into **October 2025**

View File

@@ -0,0 +1,29 @@
# Google Sheets Data Caching Implementation
**Date:** 2026-03-11
**Objective:** Optimize Flask application performance by heavily caching expensive Google Sheets data processing, avoiding redundant HTTP roundtrips to Google APIs, and ensuring rate limits are not exhausted during simple web app reloads.
## Implemented Features
### 1. File-Based JSON Caching (`cache_utils.py`)
- **Mechanism:** Implemented a new generic caching system that saves API responses and heavily calculated datasets as `.json` files directly to the local `/tmp/` directory.
- **Drive Metadata Checks:** The cache is validated by asking the Google Drive API (`drive.files().get`) for the remote `modifiedTime` of the target Sheet.
- **Cache Hit logic:** If the cached version on disk matches the remote `modifiedTime`, the application skips downloading the full CSV payload and computing tuples—instead serving the instant static cache via `json.load`.
### 2. Global API Auth Object Reuse
- **The Problem:** The `_get_drive_service()` and `get_sheets_service()` implementations were completely rebuilding `googleapiclient.discovery` objects for *every single file check*—re-seeking and exchanging Google Service Account tokens constantly.
- **The Fix:** Service objects (`_DRIVE_SERVICE`, `_SHEETS_SERVICE`) are now globally cached in application memory. The server authenticates exactly *once* when it wakes up, dramatically saving milliseconds and network resources across every web request. The underlying `httplib2` and `google-auth` intelligently handle silent token refreshes natively.
### 3. Graceful Configurable Rate Limiting
- **In-Memory Debouncing:** Implemented an internal memory state (`_LAST_CHECKED`) inside `cache_utils` that forcefully prevents checking the Drive API `modifiedTime` for a specific file if we already explicitly checked it within the last 5 minutes. This prevents flooding the Google Drive API while clicking wildly around the app GUI.
- **Semantic Mappings:** Created a `CACHE_SHEET_MAP` that maps friendly internal cache keys (e.g. `attendance_regular`) back to their raw 44-character Google Sheet IDs.
### 4. HTTP / Socket Timeout Safety Fix
- **The Bug:** Originally, `socket.setdefaulttimeout(10)` was used to prevent Google Drive metadata checks from locking up the worker pool. However, this brutally mutated the underlying Werkzeug/Flask default sockets globally. If fetching thousands of lines from Google *Sheets* (the payload logic) took longer than 10 seconds, Flask would just kill the request with a random `TimeoutError('timed out')`.
- **The Fix:** Removed the global mutation. Instantiated a targeted, isolated `httplib2.Http(timeout=10)` injected *specifically* into only the Google Drive API build. The rest of the app can now download massive files without randomly timing out.
### 5. Developer Experience (DX) Enhancements
- **Logging Line Origins:** Enriched the console logging format strings (`logging.basicConfig`) to output `[%(funcName)s]` and `%(filename)s:%(lineno)d` to easily trace exactly which exact file and function is executing on complex stack traces.
- **Improved VS Code Local Debugging:**
- Integrated `debugpy` launch profiles in `.vscode/launch.json` for "Python Debugger: Flask" (Launching) and "Python Debugger: Attach" (Connecting).
- Implemented a standard `make web-attach` target inside the Makefile via `uv run python -m debugpy --listen ...` to allow the background web app to automatically halt and wait for external debuggers before bootstrapping caching layers.

View File

@@ -1,14 +1,22 @@
[project]
name = "fuj-management"
version = "0.05"
version = "0.10"
description = "Management tools for FUJ (Frisbee Ultimate Jablonec)"
dependencies = [
"flask>=3.1.3",
"google-api-python-client>=2.162.0",
"google-auth-httplib2>=0.2.0",
"google-auth-oauthlib>=1.2.1",
"qrcode[pil]>=8.0",
"gunicorn>=23.0",
]
requires-python = ">=3.13"
[dependency-groups]
dev = [
"pytest>=8.0",
"pytest-cov>=6.0",
]
[tool.uv]
package = false

View File

@@ -5,20 +5,48 @@ import io
import urllib.request
from datetime import datetime
SHEET_ID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA"
EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv"
from config import ATTENDANCE_SHEET_ID as SHEET_ID, JUNIOR_SHEET_GID
FEE_FULL = 750 # CZK, for 2+ practices in a month
FEE_SINGLE = 200 # CZK, for exactly 1 practice in a month
EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid=0"
JUNIOR_EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid={JUNIOR_SHEET_GID}"
ADULT_FEE_DEFAULT = 700 # CZK, for 2+ practices in a month
ADULT_FEE_SINGLE = 200 # CZK, for exactly 1 practice in a month
ADULT_FEE_MONTHLY_RATE = {
"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,
}
JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices
JUNIOR_MONTHLY_RATE = {
"2025-09": 250,
"2026-03": 250 # reduced fee for March 2026
}
ADULT_MERGED_MONTHS = {
#"2025-12": "2026-01", # keys are merged into values
#"2025-09": "2025-10"
}
JUNIOR_MERGED_MONTHS = {
"2025-12": "2026-01", # keys are merged into values
"2025-09": "2025-10"
}
COL_NAME = 0
COL_TIER = 1
FIRST_DATE_COL = 3
def fetch_csv() -> list[list[str]]:
def fetch_csv(url: str = EXPORT_URL) -> list[list[str]]:
"""Fetch the attendance Google Sheet as parsed CSV rows."""
req = urllib.request.Request(EXPORT_URL)
req = urllib.request.Request(url)
with urllib.request.urlopen(req) as resp:
text = resp.read().decode("utf-8")
reader = csv.reader(io.StringIO(text))
@@ -28,44 +56,114 @@ def fetch_csv() -> list[list[str]]:
def parse_dates(header_row: list[str]) -> list[tuple[int, datetime]]:
"""Return (column_index, date) pairs for all date columns."""
dates = []
for i in range(FIRST_DATE_COL, len(header_row)):
raw = header_row[i].strip()
if not raw:
continue
try:
dates.append((i, datetime.strptime(raw, "%m/%d/%Y")))
# Try DD.MM.YYYY
dt = datetime.strptime(raw, "%d.%m.%Y")
dates.append((i, dt))
except ValueError:
continue
try:
# Fallback to MM/DD/YYYY
dt = datetime.strptime(raw, "%m/%d/%Y")
dates.append((i, dt))
except ValueError:
pass
return dates
def group_by_month(dates: list[tuple[int, datetime]]) -> dict[str, list[int]]:
"""Group column indices by YYYY-MM."""
def group_by_month(dates: list[tuple[int, datetime]], merged_months: dict[str, str]) -> dict[str, list[int]]:
"""Group column indices by YYYY-MM, handling merged months."""
months: dict[str, list[int]] = {}
for col, dt in dates:
key = dt.strftime("%Y-%m")
months.setdefault(key, []).append(col)
# Apply merged month mapping if configured
target_key = merged_months.get(key, key)
months.setdefault(target_key, []).append(col)
return months
def calculate_fee(attendance_count: int) -> int:
"""Apply fee rules: 0 → 0, 1 → 200, 2+ → 750."""
def calculate_fee(attendance_count: int, month_key: str) -> int:
"""Apply fee rules: 0 → 0, 1 → 200, 2+ → configured rate (default 750)."""
if attendance_count == 0:
return 0
if attendance_count == 1:
return FEE_SINGLE
return FEE_FULL
return ADULT_FEE_SINGLE
return ADULT_FEE_MONTHLY_RATE.get(month_key, ADULT_FEE_DEFAULT)
def calculate_junior_fee(attendance_count: int, month_key: str) -> str | int:
"""Apply junior fee rules: 0 → 0, 1 → '?', 2+ → Configured Rate (default 500)."""
if attendance_count == 0:
return 0
if attendance_count == 1:
return "?"
return JUNIOR_MONTHLY_RATE.get(month_key, JUNIOR_FEE_DEFAULT)
def get_members(rows: list[list[str]]) -> list[tuple[str, str, list[str]]]:
"""Parse member rows. Returns list of (name, tier, row)."""
"""Parse member rows. Returns list of (name, tier, row).
Stopped at row where first column contains '# last line'.
Skips rows starting with '#'.
"""
members = []
for row in rows[1:]:
name = row[COL_NAME].strip() if len(row) > COL_NAME else ""
if not name or name.lower() in ("jméno", "name", "jmeno"):
if not row or len(row) <= COL_NAME:
continue
first_col = row[COL_NAME].strip()
# Terminator for rows to process
if "# last line" in first_col.lower():
break
# Ignore comments
if first_col.startswith("#"):
continue
if not first_col or first_col.lower() in ("jméno", "name", "jmeno"):
continue
tier = row[COL_TIER].strip().upper() if len(row) > COL_TIER else ""
members.append((name, tier, row))
members.append((first_col, tier, row))
return members
def get_junior_members_from_sheet(rows: list[list[str]]) -> list[tuple[str, str, list[str]]]:
"""Parse junior member rows from the junior sheet.
Stopped at row where first column contains '# Treneri'.
Returns list of (name, tier, row) for members where tier is not 'X'.
"""
members = []
for row in rows[1:]:
if not row or len(row) <= COL_NAME:
continue
first_col = row[COL_NAME].strip()
# Terminator for rows to process in junior sheet
if "# treneri" in first_col.lower() or "# trenéři" in first_col.lower():
break
# Ignore comments
if first_col.startswith("#"):
continue
if not first_col or first_col.lower() in ("jméno", "name", "jmeno"):
continue
tier = row[COL_TIER].strip().upper() if len(row) > COL_TIER else ""
if tier == "X":
continue
members.append((first_col, tier, row))
return members
@@ -86,7 +184,7 @@ def get_members_with_fees() -> tuple[list[tuple[str, str, dict[str, int]]], list
if not dates:
return [], []
months = group_by_month(dates)
months = group_by_month(dates, ADULT_MERGED_MONTHS)
sorted_months = sorted(months.keys())
members_raw = get_members(rows)
@@ -100,8 +198,92 @@ def get_members_with_fees() -> tuple[list[tuple[str, str, dict[str, int]]], list
for c in cols
if c < len(row) and row[c].strip().upper() == "TRUE"
)
fee = calculate_fee(count) if tier == "A" else 0
fee = calculate_fee(count, month_key) if tier == "A" else 0
month_fees[month_key] = (fee, count)
members.append((name, tier, month_fees))
return members, sorted_months
def get_junior_members_with_fees() -> tuple[list[tuple[str, str, dict[str, tuple[str | int, int, int, int]]]], list[str]]:
"""Fetch attendance data from both sheets and compute junior fees.
Merges members by exact name match.
Returns:
(members, sorted_months) where members is a list of
(name, tier, {month_key: (fee, total_count, adult_count, junior_count)}).
"""
main_rows = fetch_csv(EXPORT_URL)
junior_rows = fetch_csv(JUNIOR_EXPORT_URL)
if len(main_rows) < 2 or len(junior_rows) < 2:
return [], []
main_dates = parse_dates(main_rows[0])
junior_dates = parse_dates(junior_rows[0])
main_months = group_by_month(main_dates, JUNIOR_MERGED_MONTHS)
junior_months = group_by_month(junior_dates, JUNIOR_MERGED_MONTHS)
# Collect all unique sorted months
all_months = set(main_months.keys()).union(set(junior_months.keys()))
sorted_months = sorted(list(all_months))
from typing import Any
merged_members: dict[str, Any] = {}
# Process Junior Tier from Main Sheet (Adult Practices)
main_members_raw = get_members(main_rows)
for name, tier, row in main_members_raw:
if tier != "J":
continue
if name not in merged_members:
merged_members[name] = {"tier": tier, "months": {}}
for month_key in sorted_months:
if month_key not in merged_members[name]["months"]:
merged_members[name]["months"][month_key] = {"adult": 0, "junior": 0}
cols = main_months.get(month_key, [])
adult_count = sum(
1
for c in cols
if c < len(row) and row[c].strip().upper() == "TRUE"
)
merged_members[name]["months"][month_key]["adult"] += adult_count
# Process Junior Sheet (Junior Practices)
junior_members_raw = get_junior_members_from_sheet(junior_rows)
for name, tier, row in junior_members_raw:
if name not in merged_members:
merged_members[name] = {"tier": tier, "months": {}}
for month_key in sorted_months:
if month_key not in merged_members[name]["months"]:
merged_members[name]["months"][month_key] = {"adult": 0, "junior": 0}
cols = junior_months.get(month_key, [])
junior_count = sum(
1
for c in cols
if c < len(row) and row[c].strip().upper() == "TRUE"
)
merged_members[name]["months"][month_key]["junior"] += junior_count
# Compile the final result format
members = []
for name, data in merged_members.items():
month_fees = {}
for month_key in sorted_months:
adult_count = data["months"].get(month_key, {}).get("adult", 0)
junior_count = data["months"].get(month_key, {}).get("junior", 0)
total_count = adult_count + junior_count
fee = calculate_junior_fee(total_count, month_key)
month_fees[month_key] = (fee, total_count, adult_count, junior_count)
members.append((name, data["tier"], month_fees))
return members, sorted_months

173
scripts/cache_utils.py Normal file
View File

@@ -0,0 +1,173 @@
import json
import socket
import logging
from datetime import datetime
from google.oauth2 import service_account
from googleapiclient.discovery import build
from config import (
CACHE_DIR, CREDENTIALS_PATH as CREDS_PATH, DRIVE_TIMEOUT,
CACHE_TTL_SECONDS, CACHE_API_CHECK_TTL_SECONDS, CACHE_SHEET_MAP,
)
logger = logging.getLogger(__name__)
# Global state to track last Drive API check time per sheet
_LAST_CHECKED = {}
_DRIVE_SERVICE = None
def _get_drive_service():
global _DRIVE_SERVICE
if _DRIVE_SERVICE is not None:
return _DRIVE_SERVICE
if not CREDS_PATH.exists():
logger.warning(f"Credentials not found at {CREDS_PATH}. Cannot check Google Drive API.")
return None
try:
creds = service_account.Credentials.from_service_account_file(
str(CREDS_PATH),
scopes=["https://www.googleapis.com/auth/drive.readonly"]
)
# Apply timeout safely to the httplib2 connection without mutating global socket
import httplib2
import google_auth_httplib2
http = httplib2.Http(timeout=DRIVE_TIMEOUT)
http = google_auth_httplib2.AuthorizedHttp(creds, http=http)
_DRIVE_SERVICE = build("drive", "v3", http=http, cache_discovery=False)
return _DRIVE_SERVICE
except Exception as e:
logger.error(f"Failed to build Drive API service: {e}")
return None
import time
def get_sheet_modified_time(cache_key: str) -> str | None:
"""Gets the modifiedTime from Google Drive API for a given cache_key.
Returns the ISO timestamp string if successful.
If the Drive API fails (e.g., lack of permissions for public sheets),
it generates a virtual time bucket string to provide a 5-minute TTL cache.
"""
sheet_id = CACHE_SHEET_MAP.get(cache_key, cache_key)
cache_file = CACHE_DIR / f"{cache_key}_cache.json"
# 1. Check if we should skip the Drive API check entirely (global memory TTL)
now = time.time()
last_check = _LAST_CHECKED.get(sheet_id, 0)
if CACHE_API_CHECK_TTL_SECONDS > 0 and (now - last_check) < CACHE_API_CHECK_TTL_SECONDS:
# We checked recently. Return cached modifiedTime if cache file exists.
if cache_file.exists():
try:
with open(cache_file, "r", encoding="utf-8") as f:
cache_data = json.load(f)
cached_time = cache_data.get("modifiedTime")
if cached_time:
logger.info(f"Skipping Drive API check for {sheet_id} due to {CACHE_API_CHECK_TTL_SECONDS}s API check TTL")
return cached_time
except Exception as e:
logger.warning(f"Error reading existing cache during API skip for {sheet_id}: {e}")
# 2. Check if the cache file is simply too new (legacy check)
if CACHE_TTL_SECONDS > 0 and cache_file.exists():
try:
file_mtime = cache_file.stat().st_mtime
if time.time() - file_mtime < CACHE_TTL_SECONDS:
with open(cache_file, "r", encoding="utf-8") as f:
cache_data = json.load(f)
cached_time = cache_data.get("modifiedTime")
if cached_time:
logger.info(f"Skipping Drive API check for {sheet_id} due to {CACHE_TTL_SECONDS}s max CACHE_TTL")
# We consider this a valid check, update the global state
_LAST_CHECKED[sheet_id] = now
return cached_time
except Exception as e:
logger.warning(f"Error checking cache TTL for {sheet_id}: {e}")
def _fallback_ttl():
bucket = int(time.time() // 300)
return f"ttl-5m-{bucket}"
logger.info(f"Checking Drive API for {sheet_id}")
drive_service = _get_drive_service()
if not drive_service:
return _fallback_ttl()
try:
file_meta = drive_service.files().get(fileId=sheet_id, fields="modifiedTime", supportsAllDrives=True).execute()
# Successfully checked API, update the global state
_LAST_CHECKED[sheet_id] = time.time()
return file_meta.get("modifiedTime")
except Exception as e:
logger.warning(f"Could not get modifiedTime for sheet {sheet_id}: {e}. Falling back to 5-minute TTL.")
return _fallback_ttl()
def read_cache(sheet_id: str, current_modified_time: str) -> list | dict | None:
"""Reads the JSON cache for the given sheet_id.
Returns the cached data if it exists AND the cached modifiedTime matches
current_modified_time.
Otherwise, returns None.
"""
if not current_modified_time:
return None
cache_file = CACHE_DIR / f"{sheet_id}_cache.json"
if not cache_file.exists():
return None
try:
with open(cache_file, "r", encoding="utf-8") as f:
cache_data = json.load(f)
cached_time = cache_data.get("modifiedTime")
if cached_time == current_modified_time:
logger.info(f"Cache hit for {sheet_id} ({current_modified_time})")
return cache_data.get("data")
else:
logger.info(f"Cache miss for {sheet_id}. Cached: {cached_time}, Current: {current_modified_time}")
return None
except Exception as e:
logger.error(f"Failed to read cache {cache_file}: {e}")
return None
def write_cache(sheet_id: str, modified_time: str, data: list | dict) -> None:
"""Writes the data to a JSON cache file with the given modified_time."""
if not modified_time:
return
try:
CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache_file = CACHE_DIR / f"{sheet_id}_cache.json"
cache_data = {
"modifiedTime": modified_time,
"data": data,
"cachedAt": datetime.now().isoformat()
}
with open(cache_file, "w", encoding="utf-8") as f:
json.dump(cache_data, f, ensure_ascii=False)
logger.info(f"Wrote cache for {sheet_id}")
except Exception as e:
logger.error(f"Failed to write cache {sheet_id}: {e}")
def flush_cache():
"""Delete all cache files and reset in-memory state. Returns count of deleted files."""
global _DRIVE_SERVICE
_LAST_CHECKED.clear()
_DRIVE_SERVICE = None
deleted = 0
if CACHE_DIR.exists():
for f in CACHE_DIR.glob("*_cache.json"):
f.unlink()
deleted += 1
logger.info(f"Deleted cache file: {f.name}")
logger.info(f"Cache flushed: {deleted} files deleted, timers reset")
return deleted

39
scripts/config.py Normal file
View File

@@ -0,0 +1,39 @@
"""Centralized configuration for FUJ management scripts.
External service IDs, credentials, and tunable parameters.
Domain-specific constants (fees, column indices) stay in their respective modules.
"""
import os
from pathlib import Path
# Paths
PROJECT_ROOT = Path(__file__).parent.parent
CREDENTIALS_PATH = Path(os.environ.get(
"CREDENTIALS_PATH",
str(PROJECT_ROOT / ".secret" / "fuj-management-bot-credentials.json"),
))
# Google Sheets IDs
ATTENDANCE_SHEET_ID = "1E2e_gT_K5AwSRCDLDTa2UetZTkHmBOcz0kFbBUNUNBA"
PAYMENTS_SHEET_ID = "1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y"
# Attendance sheet tab GIDs
JUNIOR_SHEET_GID = "1213318614"
# Bank
BANK_ACCOUNT = os.environ.get("BANK_ACCOUNT", "CZ8520100000002800359168")
# Cache settings
CACHE_DIR = PROJECT_ROOT / "tmp"
DRIVE_TIMEOUT = 10 # seconds
CACHE_TTL_SECONDS = int(os.environ.get("CACHE_TTL_SECONDS", 300)) # 5 min default
CACHE_API_CHECK_TTL_SECONDS = int(os.environ.get("CACHE_API_CHECK_TTL_SECONDS", 300)) # 5 min default
# Maps cache keys to their source sheet IDs (used by cache_utils)
CACHE_SHEET_MAP = {
"attendance_regular": ATTENDANCE_SHEET_ID,
"attendance_juniors": ATTENDANCE_SHEET_ID,
"exceptions_dict": PAYMENTS_SHEET_ID,
"payments_transactions": PAYMENTS_SHEET_ID,
}

View File

@@ -102,7 +102,7 @@ def infer_payments(spreadsheet_id: str, credentials_path: str, dry_run: bool = F
member_names = [m[0] for m in members_data]
# 3. Process rows
print("Inffering details for empty rows...")
print("Inferring details for empty rows...")
updates = []
for i, row in enumerate(rows[1:], start=2):

View File

@@ -3,17 +3,29 @@
import argparse
import json
import logging
import os
import re
import urllib.request
from datetime import datetime, timedelta
from html.parser import HTMLParser
logger = logging.getLogger(__name__)
from attendance import get_members_with_fees
from czech_utils import normalize, parse_month_references
from sync_fio_to_sheets import get_sheets_service, DEFAULT_SPREADSHEET_ID
def canonical_member_key(name: str) -> str:
"""Diacritic-, case-, and whitespace-insensitive key for member-name matching.
Used to resolve `Person`-column values from the payments sheet to canonical
attendance-sheet names, tolerating cells like "Maria Maco" vs "Mária Maco".
"""
return re.sub(r"\s+", " ", normalize(name)).strip()
# ---------------------------------------------------------------------------
# Name matching
# ---------------------------------------------------------------------------
@@ -45,6 +57,11 @@ def _build_name_variants(name: str) -> list[str]:
return [v for v in variants if len(v) >= 3]
def _word_in(needle: str, haystack: str) -> bool:
"""Return True if needle appears as a whole word in haystack."""
return bool(re.search(rf"\b{re.escape(needle)}\b", haystack))
def match_members(
text: str, member_names: list[str]
) -> list[tuple[str, str]]:
@@ -53,13 +70,26 @@ def match_members(
Returns list of (member_name, confidence) where confidence is 'auto' or 'review'.
"""
normalized_text = normalize(text)
# Short-circuit: if any member's full canonical name appears verbatim (whole words),
# return only those matches and skip all fuzzy/nickname checks. This prevents a
# nickname that is a substring of another member's surname from producing false hits.
exact_matches = []
for name in member_names:
variants = _build_name_variants(name)
full_name = variants[0] if variants else ""
if full_name and _word_in(full_name, normalized_text):
exact_matches.append((name, "auto"))
if exact_matches:
return exact_matches
matches = []
for name in member_names:
variants = _build_name_variants(name)
full_name = variants[0] if variants else ""
parts = full_name.split()
# 1. Full name match (exact sequence) = high confidence
if full_name and full_name in normalized_text:
matches.append((name, "auto"))
@@ -67,17 +97,16 @@ def match_members(
# 2. Both first and last name present (any order) = high confidence
if len(parts) >= 2:
if parts[0] in normalized_text and parts[-1] in normalized_text:
if _word_in(parts[0], normalized_text) and _word_in(parts[-1], normalized_text):
matches.append((name, "auto"))
continue
# 3. Nickname + one part of the name = high confidence
# 3. Nickname present = high confidence
nickname = ""
nickname_match = re.search(r"\(([^)]+)\)", name)
if nickname_match:
nickname = normalize(nickname_match.group(1))
if nickname and nickname in normalized_text:
# Nickname alone is often enough, but let's check if it's combined with a name part
if nickname and _word_in(nickname, normalized_text):
matches.append((name, "auto"))
continue
@@ -86,19 +115,16 @@ def match_members(
first_name = parts[0]
last_name = parts[-1]
_COMMON_SURNAMES = {"novak", "novakova", "prach"}
# Match last name
if len(last_name) >= 4 and last_name not in _COMMON_SURNAMES and last_name in normalized_text:
if len(last_name) >= 4 and last_name not in _COMMON_SURNAMES and _word_in(last_name, normalized_text):
matches.append((name, "review"))
continue
# Match first name (if not too short)
if len(first_name) >= 3 and first_name in normalized_text:
if len(first_name) >= 3 and _word_in(first_name, normalized_text):
matches.append((name, "review"))
continue
elif len(parts) == 1:
# Single name member
if len(parts[0]) >= 4 and parts[0] in normalized_text:
if len(parts[0]) >= 4 and _word_in(parts[0], normalized_text):
matches.append((name, "review"))
continue
@@ -106,7 +132,6 @@ def match_members(
# If we have any "auto" matches, discard all "review" matches
auto_matches = [m for m in matches if m[1] == "auto"]
if auto_matches:
# If multiple auto matches, keep them (ambiguous but high priority)
return auto_matches
return matches
@@ -159,6 +184,27 @@ def infer_transaction_details(tx: dict, member_names: list[str]) -> dict:
}
def format_date(val) -> str:
"""Normalize date from Google Sheet (handles serial numbers and strings)."""
if val is None or val == "":
return ""
# Handle Google Sheets serial dates (number of days since 1899-12-30)
if isinstance(val, (int, float)):
base_date = datetime(1899, 12, 30)
dt = base_date + timedelta(days=val)
return dt.strftime("%Y-%m-%d")
val_str = str(val).strip()
if not val_str:
return ""
# If already YYYY-MM-DD, return as is
if len(val_str) == 10 and val_str[4] == "-" and val_str[7] == "-":
return val_str
return val_str
def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
"""Fetch all rows from the Google Sheet and convert to a list of dicts."""
service = get_sheets_service(credentials_path)
@@ -182,7 +228,7 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
return -1
idx_date = get_col_index("Date")
idx_amount = get_col_index("Amount")
idx_amount = get_col_index("Amount")
idx_manual = get_col_index("manual fix")
idx_person = get_col_index("Person")
idx_purpose = get_col_index("Purpose")
@@ -191,13 +237,18 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
idx_message = get_col_index("Message")
idx_bank_id = get_col_index("Bank ID")
required = {"Date": idx_date, "Amount": idx_amount, "Person": idx_person, "Purpose": idx_purpose}
missing = [name for name, idx in required.items() if idx == -1]
if missing:
raise ValueError(f"Required columns missing from payments sheet: {', '.join(missing)}. Found headers: {header}")
transactions = []
for row in rows[1:]:
def get_val(idx):
return row[idx] if idx != -1 and idx < len(row) else ""
tx = {
"date": get_val(idx_date),
"date": format_date(get_val(idx_date)),
"amount": get_val(idx_amount),
"manual_fix": get_val(idx_manual),
"person": get_val(idx_person),
@@ -212,10 +263,49 @@ def fetch_sheet_data(spreadsheet_id: str, credentials_path: str) -> list[dict]:
return transactions
def fetch_exceptions(spreadsheet_id: str, credentials_path: str) -> dict[tuple[str, str], dict]:
"""Fetch manual fee overrides from the 'exceptions' sheet.
Returns a dict mapping (member_name, period_YYYYMM) to {'amount': int, 'note': str}.
"""
service = get_sheets_service(credentials_path)
try:
result = service.spreadsheets().values().get(
spreadsheetId=spreadsheet_id,
range="'exceptions'!A2:D",
valueRenderOption="UNFORMATTED_VALUE"
).execute()
rows = result.get("values", [])
except Exception as e:
print(f"Warning: Could not fetch exceptions: {e}")
return {}
exceptions = {}
for row in rows:
if len(row) < 3 or str(row[0]).lower().startswith("name"):
continue
name = str(row[0]).strip()
period = str(row[1]).strip()
# Robust normalization using czech_utils.normalize
norm_name = normalize(name)
norm_period = normalize(period)
try:
amount = int(row[2])
note = str(row[3]).strip() if len(row) > 3 else ""
exceptions[(norm_name, norm_period)] = {"amount": amount, "note": note}
except (ValueError, TypeError):
continue
return exceptions
def reconcile(
members: list[tuple[str, str, dict[str, int]]],
sorted_months: list[str],
transactions: list[dict],
exceptions: dict[tuple[str, str], dict] = None,
) -> dict:
"""Match transactions to members and months.
@@ -226,15 +316,42 @@ def reconcile(
"""
member_names = [name for name, _, _ in members]
member_tiers = {name: tier for name, tier, _ in members}
member_fees = {name: {m: fee for m, (fee, _) in fees.items()} for name, _, fees in members}
member_fees = {name: fees for name, _, fees in members}
# Map canonical key → first attendance-sheet name with that key, so a
# `Person` cell that drifts in diacritics/case/whitespace still resolves.
canonical_by_key: dict[str, str] = {}
for name in member_names:
canonical_by_key.setdefault(canonical_member_key(name), name)
# Initialize ledger
ledger: dict[str, dict[str, dict]] = {}
other_ledger: dict[str, list] = {}
exceptions = exceptions or {}
for name in member_names:
ledger[name] = {}
other_ledger[name] = []
for m in sorted_months:
# Robust normalization for lookup
norm_name = normalize(name)
norm_period = normalize(m)
fee_data = member_fees[name].get(m, (0, 0))
original_expected = fee_data[0] if isinstance(fee_data, (tuple, list)) else fee_data
attendance_count = fee_data[1] if isinstance(fee_data, (tuple, list)) else 0
ex_data = exceptions.get((norm_name, norm_period))
if ex_data is not None:
expected = ex_data["amount"]
exception_info = ex_data
else:
expected = original_expected
exception_info = None
ledger[name][m] = {
"expected": member_fees[name].get(m, 0),
"expected": expected,
"original_expected": original_expected,
"attendance_count": attendance_count,
"exception": exception_info,
"paid": 0,
"transactions": [],
}
@@ -249,12 +366,13 @@ def reconcile(
# Strip markers like [?]
person_str = re.sub(r"\[\?\]\s*", "", person_str)
is_other = purpose_str.lower().startswith("other:")
if person_str and purpose_str:
# We have pre-matched data (either from script or manual)
# Support multiple people/months in the comma-separated string
matched_members = [(p.strip(), "auto") for p in person_str.split(",") if p.strip()]
matched_months = [m.strip() for m in purpose_str.split(",") if m.strip()]
matched_months = [purpose_str] if is_other else [m.strip() for m in purpose_str.split(",") if m.strip()]
# Use Inferred Amount if available, otherwise bank Amount
amount = tx.get("inferred_amount")
@@ -280,37 +398,103 @@ def reconcile(
continue
# Allocate payment across matched members and months
num_allocations = len(matched_members) * len(matched_months)
per_allocation = amount / num_allocations if num_allocations > 0 else 0
if is_other:
num_allocations = len(matched_members)
per_allocation = amount / num_allocations if num_allocations > 0 else 0
for raw_member_name, confidence in matched_members:
member_name = canonical_by_key.get(canonical_member_key(raw_member_name))
if member_name is not None:
other_ledger[member_name].append({
"amount": per_allocation,
"date": tx["date"],
"sender": tx["sender"],
"message": tx["message"],
"purpose": purpose_str,
"confidence": confidence,
})
continue
for member_name, confidence in matched_members:
# If we matched via sheet 'Person' column, name might be partial or have markers
# but usually it's the exact member name from get_members_with_fees.
# Let's ensure it exists in our ledger.
if member_name not in ledger:
# Try matching by base name if it was Jan Novak (Kačerr) etc.
pass
member_share = amount / len(matched_members) if matched_members else 0
for month_key in matched_months:
entry = {
"amount": per_allocation,
"date": tx["date"],
"sender": tx["sender"],
"message": tx["message"],
"confidence": confidence,
}
if month_key in ledger.get(member_name, {}):
ledger[member_name][month_key]["paid"] += per_allocation
ledger[member_name][month_key]["transactions"].append(entry)
else:
# Future month — track as credit
credits[member_name] = credits.get(member_name, 0) + int(per_allocation)
for raw_member_name, confidence in matched_members:
member_name = canonical_by_key.get(canonical_member_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,
)
in_window = [(m, ledger[member_name][m]["expected"]) for m in matched_months if m in ledger[member_name]]
out_of_window = [m for m in matched_months if m not in ledger[member_name]]
# Out-of-window months (outside display range): even split → credit, same as before.
n_total = len(matched_months)
if out_of_window and n_total > 0:
out_credit = member_share / n_total * len(out_of_window)
credits[member_name] = credits.get(member_name, 0) + int(out_credit)
else:
out_credit = 0.0
in_window_share = member_share - out_credit
if not in_window:
continue
total_expected = sum(e for _, e in in_window)
if total_expected > 0 and in_window_share >= total_expected:
# Greedy phase: payment covers all in-window fees; overflow → credit.
credits[member_name] = credits.get(member_name, 0) + int(in_window_share - total_expected)
for m, exp in in_window:
alloc = float(exp)
ledger[member_name][m]["paid"] += alloc
ledger[member_name][m]["transactions"].append({
"amount": alloc,
"date": tx["date"],
"sender": tx["sender"],
"message": tx["message"],
"confidence": confidence,
})
elif total_expected > 0:
# Proportional phase: distribute in_window_share by each month's expected fee.
# Last month absorbs any float remainder so the sum equals in_window_share exactly.
remaining = in_window_share
for i, (m, exp) in enumerate(in_window):
alloc = remaining if i == len(in_window) - 1 else in_window_share * exp / total_expected
remaining -= alloc
ledger[member_name][m]["paid"] += alloc
ledger[member_name][m]["transactions"].append({
"amount": alloc,
"date": tx["date"],
"sender": tx["sender"],
"message": tx["message"],
"confidence": confidence,
})
else:
# Fallback: no expected fees (prepayment before attendance recorded); even split.
per_month = in_window_share / len(in_window)
for m, _ in in_window:
ledger[member_name][m]["paid"] += per_month
ledger[member_name][m]["transactions"].append({
"amount": per_month,
"date": tx["date"],
"sender": tx["sender"],
"message": tx["message"],
"confidence": confidence,
})
# Calculate final total balances (window + off-window credits)
final_balances: dict[str, int] = {}
for name in member_names:
window_balance = sum(
int(mdata["paid"]) - mdata["expected"]
int(mdata["paid"]) - (mdata["expected"] if isinstance(mdata["expected"], int) else 0)
for mdata in ledger[name].values()
)
final_balances[name] = window_balance + credits.get(name, 0)
@@ -320,6 +504,7 @@ def reconcile(
name: {
"tier": member_tiers[name],
"months": ledger[name],
"other_transactions": other_ledger[name],
"total_balance": final_balances[name]
}
for name in member_names
@@ -371,10 +556,12 @@ def print_report(result: dict, sorted_months: list[str]):
for m in sorted_months:
mdata = data["months"].get(m, {"expected": 0, "paid": 0})
expected = mdata["expected"]
original = mdata["original_expected"]
paid = int(mdata["paid"])
total_expected += expected
total_paid += paid
cell_status = ""
if expected == 0 and paid == 0:
cell = "-"
elif paid >= expected and expected > 0:
@@ -383,6 +570,7 @@ def print_report(result: dict, sorted_months: list[str]):
cell = f"{paid}/{expected}"
else:
cell = f"UNPAID {expected}"
member_balance += paid - expected
line += f" | {cell:>10}"
balance_str = f"{member_balance:+d}" if member_balance != 0 else "0"
@@ -488,7 +676,11 @@ def main():
print(f"Processing {len(transactions)} transactions.\n")
result = reconcile(members, sorted_months, transactions)
exceptions = fetch_exceptions(args.sheet_id, args.credentials)
if exceptions:
print(f"Loaded {len(exceptions)} fee exceptions.")
result = reconcile(members, sorted_months, transactions, exceptions)
print_report(result, sorted_months)

View File

@@ -14,13 +14,18 @@ from googleapiclient.discovery import build
from fio_utils import fetch_transactions
# Configuration
DEFAULT_SPREADSHEET_ID = "1Om0YPoDVCH5cV8BrNz5LG5eR5MMU05ypQC7UMN1xn_Y"
from config import PAYMENTS_SHEET_ID as DEFAULT_SPREADSHEET_ID
SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]
TOKEN_FILE = "token.pickle"
COLUMN_LABELS = ["Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"]
_SHEETS_SERVICE = None
def get_sheets_service(credentials_path: str):
"""Authenticate and return the Google Sheets API service."""
global _SHEETS_SERVICE
if _SHEETS_SERVICE is not None:
return _SHEETS_SERVICE
if not os.path.exists(credentials_path):
raise FileNotFoundError(f"Credentials file not found: {credentials_path}")
@@ -50,7 +55,8 @@ def get_sheets_service(credentials_path: str):
with open(TOKEN_FILE, "wb") as token:
pickle.dump(creds, token)
return build("sheets", "v4", credentials=creds)
_SHEETS_SERVICE = build("sheets", "v4", credentials=creds)
return _SHEETS_SERVICE
def generate_sync_id(tx: dict) -> str:

1056
templates/adults.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,196 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FUJ Fees Dashboard</title>
<style>
body {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background-color: #0c0c0c;
/* Deeper black */
color: #cccccc;
/* Base gray terminal text */
padding: 10px;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
font-size: 11px;
/* Even smaller font */
line-height: 1.2;
}
h1 {
color: #00ff00;
/* Terminal green */
font-family: inherit;
/* Use monospace for header too */
margin-top: 10px;
margin-bottom: 20px;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 14px;
}
.table-container {
background-color: transparent;
/* Remove the card background */
border: 1px solid #333;
/* Just a thin outline if needed, or none */
box-shadow: none;
overflow-x: auto;
width: 100%;
max-width: 1200px;
}
table {
border-collapse: collapse;
width: 100%;
table-layout: auto;
}
th,
td {
padding: 2px 8px;
/* Extremely tight padding */
text-align: right;
border-bottom: 1px dashed #222;
/* Dashed lines for terminal feel */
white-space: nowrap;
}
th:first-child,
td:first-child {
text-align: left;
}
th {
background-color: transparent;
color: #888888;
font-weight: normal;
border-bottom: 1px solid #555;
/* Stronger border for header */
text-transform: lowercase;
}
tr:hover {
background-color: #1a1a1a;
/* Very subtle hover */
}
.total {
font-weight: bold;
background-color: transparent;
color: #00ff00;
/* Highlight total row */
border-top: 1px solid #555;
}
.total:hover {
background-color: transparent;
}
.cell-empty {
color: #444444;
/* Darker gray for empty cells */
}
.cell-paid {
color: #aaaaaa;
/* Light gray for normal cells */
}
.nav {
margin-bottom: 20px;
font-size: 12px;
color: #555;
display: flex;
gap: 15px;
}
.nav a {
color: #00ff00;
text-decoration: none;
padding: 2px 8px;
border: 1px solid #333;
}
.nav a.active {
color: #000;
background-color: #00ff00;
border-color: #00ff00;
}
.nav a:hover {
color: #fff;
border-color: #555;
}
.description {
margin-bottom: 20px;
text-align: center;
color: #888;
max-width: 800px;
}
.description a {
color: #00ff00;
text-decoration: none;
}
.description a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="nav">
<a href="/fees" class="active">[Attendance/Fees]</a>
<a href="/reconcile">[Payment Reconciliation]</a>
<a href="/payments">[Payments Ledger]</a>
</div>
<h1>FUJ Fees Dashboard</h1>
<div class="description">
Calculated monthly fees based on attendance markers.<br>
Source: <a href="{{ attendance_url }}" target="_blank">Attendance Sheet</a> |
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Member</th>
{% for m in months %}
<th>{{ m }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in results %}
<tr>
<td>{{ row.name }}</td>
{% for cell in row.months %}
<td class="{% if cell == '-' %}cell-empty{% else %}cell-paid{% endif %}">{{ cell }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="total">
<td>TOTAL</td>
{% for t in totals %}
<td>{{ t }}</td>
{% endfor %}
</tr>
</tfoot>
</table>
</div>
</body>
</html>

163
templates/flush-cache.html Normal file
View File

@@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FUJ - Flush Cache</title>
<style>
body {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background-color: #0c0c0c;
color: #cccccc;
padding: 10px;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
font-size: 11px;
line-height: 1.2;
}
h1 {
color: #00ff00;
font-family: inherit;
margin-top: 10px;
margin-bottom: 20px;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 14px;
}
.nav {
margin-bottom: 20px;
font-size: 12px;
color: #555;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.nav > div {
display: flex;
gap: 15px;
align-items: center;
}
.nav a {
color: #00ff00;
text-decoration: none;
padding: 2px 8px;
border: 1px solid #333;
}
.nav a.active {
color: #000;
background-color: #00ff00;
border-color: #00ff00;
}
.nav a:hover {
color: #fff;
border-color: #555;
}
.nav-archived a {
font-size: 10px;
color: #666;
border-color: #222;
}
.nav-archived a.active {
color: #ccc;
background-color: #333;
border-color: #555;
}
.nav-archived a:hover {
color: #999;
border-color: #444;
}
.flush-container {
background-color: #111;
border: 1px solid #333;
padding: 30px;
width: 100%;
max-width: 600px;
margin-bottom: 30px;
text-align: center;
}
.flush-btn {
font-family: inherit;
font-size: 12px;
color: #00ff00;
background-color: #1a1a1a;
border: 1px solid #00ff00;
padding: 8px 24px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
}
.flush-btn:hover {
background-color: #00ff00;
color: #000;
}
.status {
margin-bottom: 15px;
font-size: 12px;
}
.status-ok { color: #00ff00; }
.footer {
text-align: center;
color: #333;
margin-top: 20px;
font-size: 10px;
}
</style>
</head>
<body>
<div class="nav">
<div>
<a href="/adults">[Adults]</a>
<a href="/juniors">[Juniors]</a>
</div>
<div class="nav-archived">
<span style="color: #666; margin-right: 5px;">Archived:</span>
<a href="/payments">[Payments Ledger]</a>
</div>
<div class="nav-archived">
<span style="color: #666; margin-right: 5px;">Tools:</span>
<a href="/sync-bank">[Sync Bank Data]</a>
<a href="/flush-cache" class="active">[Flush Cache]</a>
</div>
</div>
<h1>Flush Cache</h1>
{% if flushed %}
<div class="status">
<span class="status-ok">Cache flushed successfully. {{ deleted }} file(s) deleted.</span>
</div>
{% endif %}
<div class="flush-container">
<p style="margin-bottom: 20px; color: #888;">Clears all cached Google Sheets data and resets refresh timers.</p>
<form method="POST" action="/flush-cache">
<button type="submit" class="flush-btn">[Flush Cache]</button>
</form>
</div>
<div class="footer">
{{ build_meta.tag }} | {{ build_meta.commit }} | {{ build_meta.build_date }}
</div>
</body>
</html>

1037
templates/juniors.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -45,8 +45,16 @@
margin-bottom: 20px;
font-size: 12px;
color: #555;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.nav > div {
display: flex;
gap: 15px;
align-items: center;
}
.nav a {
@@ -67,6 +75,23 @@
border-color: #555;
}
.nav-archived a {
font-size: 10px;
color: #666;
border-color: #222;
}
.nav-archived a.active {
color: #ccc;
background-color: #333;
border-color: #555;
}
.nav-archived a:hover {
color: #999;
border-color: #444;
}
.description {
margin-bottom: 20px;
text-align: center;
@@ -137,14 +162,41 @@
tr:hover {
background-color: #1a1a1a;
}
.footer {
margin-top: 50px;
margin-bottom: 20px;
color: #333;
font-size: 9px;
text-align: center;
width: 100%;
cursor: pointer;
user-select: none;
}
.perf-breakdown {
display: none;
margin-top: 5px;
color: #222;
}
</style>
</head>
<body>
<div class="nav">
<a href="/fees">[Attendance/Fees]</a>
<a href="/reconcile">[Payment Reconciliation]</a>
<a href="/payments" class="active">[Payments Ledger]</a>
<div>
<a href="/adults">[Adults]</a>
<a href="/juniors">[Juniors]</a>
</div>
<div class="nav-archived">
<span style="color: #666; margin-right: 5px;">Archived:</span>
<a href="/payments" class="active">[Payments Ledger]</a>
</div>
<div class="nav-archived">
<span style="color: #666; margin-right: 5px;">Tools:</span>
<a href="/sync-bank">[Sync Bank Data]</a>
<a href="/flush-cache">[Flush Cache]</a>
</div>
</div>
<h1>Payments Ledger</h1>
@@ -183,6 +235,14 @@
{% endfor %}
</div>
{% set rt = get_render_time() %}
<div class="footer"
onclick="document.getElementById('perf-details').style.display = (document.getElementById('perf-details').style.display === 'block' ? 'none' : 'block')">
{{ build_meta.tag }}@{{ build_meta.commit }} | render time: {{ rt.total }}s
<div id="perf-details" class="perf-breakdown">
{{ rt.breakdown }}
</div>
</div>
</body>
</html>

View File

@@ -1,280 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FUJ Payment Reconciliation</title>
<style>
body {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background-color: #0c0c0c;
color: #cccccc;
padding: 10px;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
font-size: 11px;
line-height: 1.2;
}
h1 {
color: #00ff00;
font-family: inherit;
margin-top: 10px;
margin-bottom: 20px;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 14px;
}
h2 {
color: #00ff00;
font-size: 12px;
margin-top: 30px;
margin-bottom: 10px;
text-transform: uppercase;
width: 100%;
max-width: 1200px;
border-bottom: 1px solid #333;
padding-bottom: 5px;
}
.nav {
margin-bottom: 20px;
font-size: 12px;
color: #555;
display: flex;
gap: 15px;
}
.nav a {
color: #00ff00;
text-decoration: none;
padding: 2px 8px;
border: 1px solid #333;
}
.nav a.active {
color: #000;
background-color: #00ff00;
border-color: #00ff00;
}
.nav a:hover {
color: #fff;
border-color: #555;
}
.description {
margin-bottom: 20px;
text-align: center;
color: #888;
max-width: 800px;
}
.description a {
color: #00ff00;
text-decoration: none;
}
.description a:hover {
text-decoration: underline;
}
.table-container {
background-color: transparent;
border: 1px solid #333;
box-shadow: none;
overflow-x: auto;
width: 100%;
max-width: 1200px;
margin-bottom: 30px;
}
table {
border-collapse: collapse;
width: 100%;
table-layout: auto;
}
th,
td {
padding: 2px 8px;
text-align: right;
border-bottom: 1px dashed #222;
white-space: nowrap;
}
th:first-child,
td:first-child {
text-align: left;
}
th {
background-color: transparent;
color: #888888;
font-weight: normal;
border-bottom: 1px solid #555;
text-transform: lowercase;
}
tr:hover {
background-color: #1a1a1a;
}
.balance-pos {
color: #00ff00;
}
.balance-neg {
color: #ff3333;
}
.cell-ok {
color: #00ff00;
}
.cell-unpaid {
color: #ff3333;
}
.cell-empty {
color: #444444;
}
.list-container {
width: 100%;
max-width: 1200px;
color: #888;
margin-bottom: 40px;
}
.list-item {
display: flex;
justify-content: flex-start;
gap: 20px;
padding: 1px 0;
border-bottom: 1px dashed #222;
}
.list-item-name {
color: #ccc;
min-width: 200px;
}
.list-item-val {
color: #00ff00;
}
.unmatched-row {
font-family: inherit;
display: grid;
grid-template-columns: 100px 100px 200px 1fr;
gap: 15px;
color: #888;
padding: 2px 0;
border-bottom: 1px dashed #222;
}
.unmatched-header {
color: #555;
border-bottom: 1px solid #333;
margin-bottom: 5px;
}
</style>
</head>
<body>
<div class="nav">
<a href="/fees">[Attendance/Fees]</a>
<a href="/reconcile" class="active">[Payment Reconciliation]</a>
<a href="/payments">[Payments Ledger]</a>
</div>
<h1>Payment Reconciliation</h1>
<div class="description">
Balances calculated by matching Google Sheet payments against attendance fees.<br>
Source: <a href="{{ attendance_url }}" target="_blank">Attendance Sheet</a> |
<a href="{{ payments_url }}" target="_blank">Payments Ledger</a>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Member</th>
{% for m in months %}
<th>{{ m }}</th>
{% endfor %}
<th>Balance</th>
</tr>
</thead>
<tbody>
{% for row in results %}
<tr>
<td>{{ row.name }}</td>
{% for cell in row.months %}
<td
class="{% if cell == '-' %}cell-empty{% elif 'UNPAID' in cell %}cell-unpaid{% elif cell == 'OK' %}cell-ok{% endif %}">
{{ cell }}
</td>
{% endfor %}
<td class="{% if row.balance > 0 %}balance-pos{% elif row.balance < 0 %}balance-neg{% endif %}">
{{ "%+d"|format(row.balance) if row.balance != 0 else "0" }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if credits %}
<h2>Credits (Advance Payments / Surplus)</h2>
<div class="list-container">
{% for item in credits %}
<div class="list-item">
<span class="list-item-name">{{ item.name }}</span>
<span class="list-item-val">{{ item.amount }} CZK</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if debts %}
<h2>Debts (Missing Payments)</h2>
<div class="list-container">
{% for item in debts %}
<div class="list-item">
<span class="list-item-name">{{ item.name }}</span>
<span class="list-item-val" style="color: #ff3333;">{{ item.amount }} CZK</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if unmatched %}
<h2>Unmatched Transactions</h2>
<div class="list-container">
<div class="unmatched-row unmatched-header">
<span>Date</span>
<span>Amount</span>
<span>Sender</span>
<span>Message</span>
</div>
{% for tx in unmatched %}
<div class="unmatched-row">
<span>{{ tx.date }}</span>
<span>{{ tx.amount }}</span>
<span>{{ tx.sender }}</span>
<span>{{ tx.message }}</span>
</div>
{% endfor %}
</div>
{% endif %}
</body>
</html>

153
templates/sync.html Normal file
View File

@@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FUJ - Sync Bank Data</title>
<style>
body {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
background-color: #0c0c0c;
color: #cccccc;
padding: 10px;
margin: 0;
display: flex;
flex-direction: column;
align-items: center;
font-size: 11px;
line-height: 1.2;
}
h1 {
color: #00ff00;
font-family: inherit;
margin-top: 10px;
margin-bottom: 20px;
text-transform: uppercase;
letter-spacing: 1px;
font-size: 14px;
}
.nav {
margin-bottom: 20px;
font-size: 12px;
color: #555;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
}
.nav > div {
display: flex;
gap: 15px;
align-items: center;
}
.nav a {
color: #00ff00;
text-decoration: none;
padding: 2px 8px;
border: 1px solid #333;
}
.nav a.active {
color: #000;
background-color: #00ff00;
border-color: #00ff00;
}
.nav a:hover {
color: #fff;
border-color: #555;
}
.nav-archived a {
font-size: 10px;
color: #666;
border-color: #222;
}
.nav-archived a.active {
color: #ccc;
background-color: #333;
border-color: #555;
}
.nav-archived a:hover {
color: #999;
border-color: #444;
}
.output-container {
background-color: #111;
border: 1px solid #333;
padding: 15px;
width: 100%;
max-width: 1200px;
margin-bottom: 30px;
overflow-x: auto;
}
.output-container pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
color: {% if success %}#cccccc{% else %}#ff6666{% endif %};
}
.status {
margin-bottom: 15px;
font-size: 12px;
}
.status-ok { color: #00ff00; }
.status-error { color: #ff6666; }
.footer {
text-align: center;
color: #333;
margin-top: 20px;
font-size: 10px;
}
</style>
</head>
<body>
<div class="nav">
<div>
<a href="/adults">[Adults]</a>
<a href="/juniors">[Juniors]</a>
</div>
<div class="nav-archived">
<span style="color: #666; margin-right: 5px;">Archived:</span>
<a href="/payments">[Payments Ledger]</a>
</div>
<div class="nav-archived">
<span style="color: #666; margin-right: 5px;">Tools:</span>
<a href="/sync-bank" class="active">[Sync Bank Data]</a>
<a href="/flush-cache">[Flush Cache]</a>
</div>
</div>
<h1>Sync Bank Data</h1>
<div class="status">
{% if success %}
<span class="status-ok">Sync completed successfully.</span>
{% else %}
<span class="status-error">Sync failed - see output below.</span>
{% endif %}
</div>
<div class="output-container">
<pre>{{ output }}</pre>
</div>
<div class="footer">
{{ build_meta.tag }} | {{ build_meta.commit }} | {{ build_meta.build_date }}
</div>
</body>
</html>

View File

@@ -1,44 +1,52 @@
import unittest
from unittest.mock import patch, MagicMock
from unittest.mock import patch
from app import app
def _bypass_cache(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
"""Test helper: call fetch_func directly, bypassing the cache layer."""
return fetch_func(*args, **kwargs)
class TestWebApp(unittest.TestCase):
def setUp(self):
# Configure app for testing
app.config['TESTING'] = True
self.client = app.test_client()
@patch('app.get_members_with_fees')
def test_index_page(self, mock_get_members):
def test_index_page(self):
"""Test that / returns the refresh meta tag"""
response = self.client.get('/')
self.assertEqual(response.status_code, 200)
self.assertIn(b'url=/fees', response.data)
@patch('app.get_members_with_fees')
def test_fees_route(self, mock_get_members):
"""Test that /fees returns 200 and renders the dashboard"""
# Mock attendance data
mock_get_members.return_value = (
[('Test Member', 'A', {'2026-01': (750, 4)})],
['2026-01']
)
response = self.client.get('/fees')
self.assertEqual(response.status_code, 200)
self.assertIn(b'FUJ Fees Dashboard', response.data)
self.assertIn(b'Test Member', response.data)
self.assertIn(b'url=/adults', response.data)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.fetch_sheet_data')
def test_payments_route(self, mock_fetch_sheet, mock_cache):
"""Test that /payments returns 200 and groups transactions"""
mock_fetch_sheet.return_value = [{
'date': '2026-01-01',
'amount': 750,
'person': 'Test Member',
'purpose': '2026-01',
'message': 'Direct Member Payment',
'sender': 'External Bank User'
}]
response = self.client.get('/payments')
self.assertEqual(response.status_code, 200)
self.assertIn(b'Payments Ledger', response.data)
self.assertIn(b'Test Member', response.data)
self.assertIn(b'Direct Member Payment', response.data)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.fetch_sheet_data')
@patch('app.fetch_exceptions', return_value={})
@patch('app.get_members_with_fees')
def test_reconcile_route(self, mock_get_members, mock_fetch_sheet):
"""Test that /reconcile returns 200 and shows matches"""
# Mock attendance data
def test_adults_route(self, mock_get_members, mock_exceptions, mock_fetch_sheet, mock_cache):
"""Test that /adults returns 200 and shows combined matches"""
mock_get_members.return_value = (
[('Test Member', 'A', {'2026-01': (750, 4)})],
['2026-01']
)
# Mock sheet data - include all keys required by reconcile
mock_fetch_sheet.return_value = [{
'date': '2026-01-01',
'amount': 750,
@@ -48,31 +56,46 @@ class TestWebApp(unittest.TestCase):
'sender': 'External Bank User',
'inferred_amount': 750
}]
response = self.client.get('/reconcile')
self.assertEqual(response.status_code, 200)
self.assertIn(b'Payment Reconciliation', response.data)
self.assertIn(b'Test Member', response.data)
self.assertIn(b'OK', response.data)
response = self.client.get('/adults')
self.assertEqual(response.status_code, 200)
self.assertIn(b'Adults Dashboard', response.data)
self.assertIn(b'Test Member', response.data)
self.assertNotIn(b'OK', response.data)
self.assertIn(b'750/750 CZK (4)', response.data)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.fetch_sheet_data')
def test_payments_route(self, mock_fetch_sheet):
"""Test that /payments returns 200 and groups transactions"""
# Mock sheet data
@patch('app.fetch_exceptions', return_value={})
@patch('app.get_junior_members_with_fees')
def test_juniors_route(self, mock_get_junior_members, mock_exceptions, mock_fetch_sheet, mock_cache):
"""Test that /juniors returns 200, uses single line format, and displays '?' properly"""
mock_get_junior_members.return_value = (
[
('Junior One', 'J', {'2026-01': (500, 3, 0, 3)}),
('Junior Two', 'X', {'2026-01': ('?', 1, 0, 1)})
],
['2026-01']
)
mock_exceptions.return_value = {}
mock_fetch_sheet.return_value = [{
'date': '2026-01-01',
'amount': 750,
'person': 'Test Member',
'date': '2026-01-15',
'amount': 500,
'person': 'Junior One',
'purpose': '2026-01',
'message': 'Direct Member Payment',
'sender': 'External Bank User'
'message': '',
'sender': 'Parent',
'inferred_amount': 500
}]
response = self.client.get('/payments')
response = self.client.get('/juniors')
self.assertEqual(response.status_code, 200)
self.assertIn(b'Payments Ledger', response.data)
self.assertIn(b'Test Member', response.data)
self.assertIn(b'Direct Member Payment', response.data)
self.assertIn(b'Juniors Dashboard', response.data)
self.assertIn(b'Junior One', response.data)
self.assertIn(b'Junior Two', response.data)
self.assertNotIn(b'OK', response.data)
self.assertIn(b'500/500 CZK', response.data)
self.assertIn(b'?', response.data)
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,53 @@
import unittest
from scripts.match_payments import match_members
MEMBERS = [
"Henrietta Ottová",
"Tomáš Němeček (Tov)",
"František Vrbík (Štrúdl)",
"Jana Nováková",
]
class TestMatchMembersExact(unittest.TestCase):
def test_full_name_in_message_returns_only_that_member(self):
# "tov" is a substring of "ottova" — the old code returned both members
result = match_members("Henrietta Ottová (Heny): 04/2026", MEMBERS)
names = [r[0] for r in result]
self.assertEqual(names, ["Henrietta Ottová"])
self.assertTrue(all(conf == "auto" for _, conf in result))
def test_nickname_tov_not_matched_inside_ottova(self):
# Bare nickname message should NOT match Tomáš via "tov" inside "ottova"
result = match_members("platba ottova 04/2026", MEMBERS)
names = [r[0] for r in result]
self.assertNotIn("Tomáš Němeček (Tov)", names)
def test_combined_payment_two_full_names(self):
result = match_members("Henrietta Ottová a Tomáš Němeček 04/2026", MEMBERS)
names = [r[0] for r in result]
self.assertIn("Henrietta Ottová", names)
self.assertIn("Tomáš Němeček (Tov)", names)
self.assertTrue(all(conf == "auto" for _, conf in result))
def test_nickname_alone_still_matches_correctly(self):
# "Tov" alone should still match Tomáš (as long as "ottova" is not in the text)
result = match_members("Tov platba 04/2026", MEMBERS)
names = [r[0] for r in result]
self.assertIn("Tomáš Němeček (Tov)", names)
def test_full_name_no_diacritics_still_matches(self):
result = match_members("Henrietta Ottova 04/2026", MEMBERS)
names = [r[0] for r in result]
self.assertIn("Henrietta Ottová", names)
self.assertNotIn("Tomáš Němeček (Tov)", names)
def test_first_last_name_present_any_order(self):
result = match_members("Platba od Nemeček Tomas 04/2026", MEMBERS)
names = [r[0] for r in result]
self.assertIn("Tomáš Němeček (Tov)", names)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,69 @@
import unittest
from scripts.match_payments import canonical_member_key, reconcile
class TestCanonicalMemberKey(unittest.TestCase):
def test_diacritics_and_case_collapse(self):
self.assertEqual(canonical_member_key("Mária Maco"), "maria maco")
self.assertEqual(canonical_member_key("MARIA MACO"), "maria maco")
self.assertEqual(canonical_member_key("maria maco"), "maria maco")
def test_whitespace_runs_collapse(self):
self.assertEqual(canonical_member_key("Mária Maco"), "maria maco")
self.assertEqual(canonical_member_key(" Mária Maco "), "maria maco")
def test_unknown_name_passes_through_normalized(self):
# Two genuinely different names must not collide.
self.assertNotEqual(
canonical_member_key("Mária Maco"),
canonical_member_key("Marek Maco"),
)
class TestReconcileTolerantPersonMatching(unittest.TestCase):
def _members(self):
return [("Mária Maco", "A", {"2026-04": (750, 4)})]
def _tx(self, person):
return {
"date": "2026-04-15",
"amount": 750,
"person": person,
"purpose": "2026-04",
"inferred_amount": 750,
"sender": "Maco Family",
"message": "fee",
}
def test_person_without_diacritics_matches(self):
result = reconcile(self._members(), ["2026-04"], [self._tx("Maria Maco")], {})
member = result["members"]["Mária Maco"]
self.assertEqual(member["months"]["2026-04"]["paid"], 750)
self.assertEqual(len(member["months"]["2026-04"]["transactions"]), 1)
self.assertEqual(result["unmatched"], [])
def test_person_with_extra_whitespace_matches(self):
result = reconcile(self._members(), ["2026-04"], [self._tx("Mária Maco")], {})
self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 750)
self.assertEqual(result["unmatched"], [])
def test_person_lowercase_matches(self):
result = reconcile(self._members(), ["2026-04"], [self._tx("mária maco")], {})
self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 750)
self.assertEqual(result["unmatched"], [])
def test_truly_unknown_person_still_unmatched(self):
result = reconcile(
self._members(), ["2026-04"], [self._tx("Někdo Neznámý")], {}
)
self.assertEqual(result["members"]["Mária Maco"]["months"]["2026-04"]["paid"], 0)
self.assertEqual(len(result["unmatched"]), 1)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,159 @@
import unittest
from scripts.match_payments import reconcile
class TestReconcileWithExceptions(unittest.TestCase):
def test_reconcile_applies_exceptions(self):
# 1. Setup mock data
# Member: Alice, Tier A, expected 750 (attendance-based)
members = [
('Alice', 'A', {'2026-01': (750, 4)})
]
sorted_months = ['2026-01']
# Exception: Alice should only pay 400 in 2026-01 (normalized keys, no accents)
exceptions = {
('alice', '2026-01'): {'amount': 400, 'note': 'Test exception'}
}
# Transaction: Alice paid 400
transactions = [{
'date': '2026-01-05',
'amount': 400,
'person': 'Alice',
'purpose': '2026-01',
'inferred_amount': 400,
'sender': 'Alice Sender',
'message': 'fee'
}]
# 2. Reconcile
result = reconcile(members, sorted_months, transactions, exceptions)
# 3. Assertions
alice_data = result['members']['Alice']
jan_data = alice_data['months']['2026-01']
self.assertEqual(jan_data['expected'], 400, "Expected amount should be overridden by exception")
self.assertEqual(jan_data['paid'], 400, "Paid amount should be 400")
self.assertEqual(alice_data['total_balance'], 0, "Balance should be 0 because 400/400")
def test_reconcile_fallback_to_attendance(self):
# Alice has attendance-based fee 750, NO exception
members = [
('Alice', 'A', {'2026-01': (750, 4)})
]
sorted_months = ['2026-01']
exceptions = {} # No exceptions
transactions = []
result = reconcile(members, sorted_months, transactions, exceptions)
alice_data = result['members']['Alice']
self.assertEqual(alice_data['months']['2026-01']['expected'], 750, "Should fallback to attendance fee")
def _tx(person, purpose, amount):
return {
'date': '2026-01-01',
'amount': amount,
'person': person,
'purpose': purpose,
'inferred_amount': amount,
'sender': 'Sender',
'message': 'fee',
}
class TestMultiMonthAllocation(unittest.TestCase):
"""Fee-aware allocation across multiple months in a single payment."""
def test_greedy_exact_match(self):
"""Payment equals total expected → every month fully covered (green)."""
members = [('Alice', 'A', {'2026-02': (750, 3), '2026-03': (350, 3), '2026-04': (150, 2)})]
sorted_months = ['2026-02', '2026-03', '2026-04']
tx = _tx('Alice', '2026-02, 2026-03, 2026-04', 1250)
result = reconcile(members, sorted_months, [tx])
months = result['members']['Alice']['months']
self.assertEqual(int(months['2026-02']['paid']), 750)
self.assertEqual(int(months['2026-03']['paid']), 350)
self.assertEqual(int(months['2026-04']['paid']), 150)
def test_greedy_overpayment_goes_to_credit(self):
"""Payment exceeds total expected → each month fully covered, surplus → credit."""
members = [('Alice', 'A', {'2026-01': (750, 3), '2026-02': (750, 3)})]
sorted_months = ['2026-01', '2026-02']
tx = _tx('Alice', '2026-01, 2026-02', 2000)
result = reconcile(members, sorted_months, [tx])
months = result['members']['Alice']['months']
self.assertEqual(int(months['2026-01']['paid']), 750)
self.assertEqual(int(months['2026-02']['paid']), 750)
self.assertEqual(result['credits'].get('Alice', 0), 500)
def test_proportional_underpayment(self):
"""Payment < total expected → proportional split; sum of paid == payment amount."""
members = [('Alice', 'A', {'2026-02': (750, 3), '2026-03': (350, 3), '2026-04': (750, 3)})]
sorted_months = ['2026-02', '2026-03', '2026-04']
amount = 1250
tx = _tx('Alice', '2026-02, 2026-03, 2026-04', amount)
result = reconcile(members, sorted_months, [tx])
months = result['members']['Alice']['months']
paid_02 = months['2026-02']['paid']
paid_03 = months['2026-03']['paid']
paid_04 = months['2026-04']['paid']
# All months should be partial (underpaid)
self.assertLess(paid_02, 750)
self.assertLess(paid_03, 350)
self.assertLess(paid_04, 750)
# Sum must equal the original payment (no CZK lost)
self.assertAlmostEqual(paid_02 + paid_03 + paid_04, amount, places=2)
# 02 and 04 have equal expected → equal allocation
self.assertAlmostEqual(paid_02, paid_04, places=2)
def test_single_month_unchanged(self):
"""Single-month payment: full amount goes to that month (regression guard)."""
members = [('Alice', 'A', {'2026-01': (750, 3)})]
sorted_months = ['2026-01']
tx = _tx('Alice', '2026-01', 750)
result = reconcile(members, sorted_months, [tx])
self.assertAlmostEqual(result['members']['Alice']['months']['2026-01']['paid'], 750, places=2)
def test_two_members_multi_month(self):
"""Two members, 2 months: each member gets member_share, then fee-aware per month."""
members = [
('Alice', 'A', {'2026-01': (750, 3), '2026-02': (350, 3)}),
('Bob', 'A', {'2026-01': (750, 3), '2026-02': (350, 3)}),
]
sorted_months = ['2026-01', '2026-02']
# Both members pay together; total expected per member = 1100
tx = _tx('Alice, Bob', '2026-01, 2026-02', 2200)
result = reconcile(members, sorted_months, [tx])
for name in ('Alice', 'Bob'):
months = result['members'][name]['months']
self.assertAlmostEqual(months['2026-01']['paid'], 750, places=2)
self.assertAlmostEqual(months['2026-02']['paid'], 350, places=2)
def test_fallback_even_split_when_no_expected(self):
"""All matched months have expected=0 → falls back to even split."""
members = [('Alice', 'A', {'2026-01': (0, 0), '2026-02': (0, 0)})]
sorted_months = ['2026-01', '2026-02']
tx = _tx('Alice', '2026-01, 2026-02', 300)
result = reconcile(members, sorted_months, [tx])
months = result['members']['Alice']['months']
self.assertAlmostEqual(months['2026-01']['paid'], 150, places=2)
self.assertAlmostEqual(months['2026-02']['paid'], 150, places=2)
if __name__ == '__main__':
unittest.main()

240
uv.lock generated
View File

@@ -127,6 +127,75 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.13.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
{ url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
{ url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
{ url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
{ url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
{ url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
{ url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
{ url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
{ url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
{ url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
{ url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
{ url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
{ url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
{ url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
{ url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
{ url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
{ url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
{ url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
{ url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
{ url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
{ url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
{ url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
{ url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
{ url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
{ url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
{ url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
{ url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
{ url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
{ url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
{ url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
{ url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
{ url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
{ url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
{ url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
{ url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
{ url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
{ url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
{ url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
{ url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
{ url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
{ url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
{ url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
{ url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
{ url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
{ url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
{ url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
{ url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
{ url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
{ url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
{ url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
{ url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
{ url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
{ url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
]
[[package]]
name = "cryptography"
version = "46.0.5"
@@ -199,13 +268,21 @@ wheels = [
[[package]]
name = "fuj-management"
version = "0.2"
version = "0.10"
source = { virtual = "." }
dependencies = [
{ name = "flask" },
{ name = "google-api-python-client" },
{ name = "google-auth-httplib2" },
{ name = "google-auth-oauthlib" },
{ name = "gunicorn" },
{ name = "qrcode", extra = ["pil"] },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
]
[package.metadata]
@@ -214,6 +291,14 @@ requires-dist = [
{ name = "google-api-python-client", specifier = ">=2.162.0" },
{ name = "google-auth-httplib2", specifier = ">=0.2.0" },
{ name = "google-auth-oauthlib", specifier = ">=1.2.1" },
{ name = "gunicorn", specifier = ">=23.0" },
{ name = "qrcode", extras = ["pil"], specifier = ">=8.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-cov", specifier = ">=6.0" },
]
[[package]]
@@ -300,6 +385,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
]
[[package]]
name = "gunicorn"
version = "25.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" },
]
[[package]]
name = "httplib2"
version = "0.31.2"
@@ -321,6 +418,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
@@ -403,6 +509,82 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pillow"
version = "12.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "proto-plus"
version = "1.27.1"
@@ -460,6 +642,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pyparsing"
version = "3.3.2"
@@ -469,6 +660,53 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
[[package]]
name = "qrcode"
version = "8.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
]
[package.optional-dependencies]
pil = [
{ name = "pillow" },
]
[[package]]
name = "requests"
version = "2.32.5"