Compare commits

...
Author SHA1 Message Date
kacerrandClaude Opus 5.5 82406935ed docs: add pre-feature cleanup task list and API parity plan
Deploy to K8s / deploy (push) Successful in 14s
Task list from the 2026-10-08 repo audit, plus the plan for the two
parity fixes found while validating the legacy/ move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:34:20 +02:00
kacerrandClaude Opus 5.5 e7c8101c3b fix(python): return months_to_show from the JSON API
Go returns months_to_show in /api/adults and /api/juniors; the Python
API did not, so make parity failed on both routes. The Python HTML
pages already had the feature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:34:20 +02:00
kacerrandClaude Opus 5.5 97f94afa02 fix: treat VS as a string on read and write
Go stringified sheet cells with fmt.Sprint, so a numeric VS read with
UNFORMATTED_VALUE was rendered as 2.609767878e+09. Text columns of the
payments sheet now go through sheets.CellString, which never uses
exponent form; bank inference uses the same helper.

Both sync implementations now append VS as a text cell so Sheets keeps
it a string and leading zeros survive. Existing rows are not rewritten.

Python already read VS as a digit string; a regression test pins that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:34:20 +02:00
kacerrandClaude Opus 5.5 022facc957 refactor: move Python implementation to legacy/, make Go primary
Deploy to K8s / deploy (push) Successful in 20s
Go (go/) becomes the primary implementation. The original Python
version moves to legacy/ and is kept runnable; new features are still
built in both (see the Dual implementation rule in CLAUDE.md).

- git mv app.py, scripts/, templates/, tests/, pyproject.toml, uv.lock
  and build/ into legacy/
- Makefile: Python targets point at legacy/, venv in legacy/.venv,
  CREDENTIALS_PATH exported for the root .secret/ file
- CI and make image build the Python image with legacy/ as context;
  the Makefile is no longer copied into the image
- legacy/.dockerignore moved to the context root so it takes effect
- CLAUDE.md, docs/README.md, Go comments, fixture README, capture
  script and VS Code launch config updated to the new layout

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:33:57 +02:00
kacerr 96c14f0b22 Merge pull request 'fix(ci): resolve image tag via Gitea API instead of artifact' (#42) from fix/gitops-tag-via-api into main
Deploy to K8s / deploy (push) Successful in 8s
Build and Push / build (push) Successful in 8s
Build and Push / build-go (push) Successful in 40s
Reviewed-on: #42
2026-06-12 19:59:40 +02:00
kacerrandClaude Opus 4.8 6d7dbfa624 fix(ci): resolve image tag via Gitea API instead of artifact
Deploy to K8s / deploy (push) Successful in 9s
upload/download-artifact@v4 is not supported on Gitea (GHES). Replace
with a direct Gitea API call in gitops-update: look up the tag name
whose commit SHA matches workflow_run.head_sha. Reverts the artifact
upload from build.yaml; no changes to build.yaml logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:59:24 +02:00
kacerr c00111cff1 Merge pull request 'fix(ci): pass Go image tag from build to gitops via artifact' (#41) from fix/gitops-pass-tag-via-artifact into main
Deploy to K8s / deploy (push) Successful in 8s
Build and Push / build (push) Successful in 7s
Build and Push / build-go (push) Failing after 42s
Reviewed-on: #41
2026-06-12 19:53:19 +02:00
kacerrandClaude Opus 4.8 d263d8a534 fix(ci): pass Go image tag from build to gitops via artifact
Deploy to K8s / deploy (push) Successful in 14s
github.event.workflow_run.head_branch is not populated for tag pushes
in Gitea Actions, causing the image tag to resolve to empty (-go suffix
with no version). Fix: build-go uploads the full image reference as a
one-line artifact; gitops-update downloads it via download-artifact@v4
with run-id from the workflow_run event.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:52:02 +02:00
kacerr af030c8255 Merge pull request 'fix(ci): separate git credentials from --git-repo URL to fix tea pr create' (#40) from fix/gitops-tea-url into main
Build and Push / build (push) Successful in 42s
Build and Push / build-go (push) Successful in 1m23s
Deploy to K8s / deploy (push) Successful in 10s
Reviewed-on: #40
2026-06-12 19:39:31 +02:00
kacerrandClaude Opus 4.8 ad127d36ea fix(ci): separate git credentials from --git-repo URL to fix tea pr create
Deploy to K8s / deploy (push) Successful in 12s
tea pr create matches the remote URL against the configured login URL to
auto-detect owner/repo. Embedding credentials in the URL (user:token@host)
breaks that match and produces "path segment [0] is empty". Store creds
via git credential helper instead and pass a clean URL to uh-cli.

Also adds set -x to the PR step for shell-level tracing in CI logs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:37:29 +02:00
kacerrandClaude Opus 4.8 29938d7a0c chore(changelog): log gitops-update workflow addition
Deploy to K8s / deploy (push) Successful in 8s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:32:20 +02:00
kacerr 1df1863725 Merge pull request 'feat(ci): gitops image-update PR workflow for home-kubernetes' (#39) from feat/gitops-pr-action into main
Deploy to K8s / deploy (push) Successful in 11s
Reviewed-on: #39
2026-06-12 19:31:51 +02:00
kacerrandClaude Opus 4.8 995abfacb2 feat(ci): add gitops-update workflow to open image-bump PR in home-kubernetes
Deploy to K8s / deploy (push) Successful in 12s
After a successful Go image build, uh-cli opens a PR against
kacerr/home-kubernetes that bumps the fuj-management Deployment
(namespace fuj) to the newly published image tag. Supports
workflow_run auto-trigger and workflow_dispatch with dry-run option.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:28:29 +02:00
kacerrandClaude Opus 4.8 f047150004 fix(tests): derive fee test expectations from constants, not hardcoded values
Deploy to K8s / deploy (push) Successful in 9s
Configured-month cases now read expected values from AdultFeeMonthlyRate /
JuniorFeeMonthlyRate via a mustRate helper that panics if a test month is
removed from the map. Fallback cases use AdultFeeDefault / JuniorFeeDefault.

This way the tests verify dispatch logic (0/1/2+ branching, map vs. fallback)
without breaking when rates are intentionally updated in the map.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:37:52 +02:00
kacerr 6f2994b8ad Merge pull request 'feat(display): limit /adults and /juniors to last N months by default' (#38) from feat/months-to-show into main
Deploy to K8s / deploy (push) Successful in 14s
Build and Push / build (push) Successful in 9s
Build and Push / build-go (push) Successful in 1m10s
Reviewed-on: #38
2026-06-08 09:31:46 +00:00
kacerrandClaude Opus 4.8 c2a381bb63 fix(display): default from-selector to last N months; keep all months selectable
Deploy to K8s / deploy (push) Successful in 12s
Instead of hiding older months entirely, show all months in the from/to
selectors but default the from-select to the last MONTHS_TO_SHOW months
on page load. The "All" button resets to full history as before.

Python: passes months_to_show to render_template, IIFE sets fromSelect.value.
Go: adds MonthsToShow to response structs, data-months-to-show attr in
templates, filters.js reads it and defaults fromSelect after hideFutureMonths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:28:40 +02:00
kacerrandClaude Opus 4.8 c0487e3af0 feat(display): limit /adults and /juniors to last N months by default
Deploy to K8s / deploy (push) Successful in 17s
Show only the last MONTHS_TO_SHOW months (default 5) in the fee table columns
so the page fits on screen without horizontal scrolling. Reconciliation still
runs over the full month history so balances, credits, and debts are unaffected.
Set MONTHS_TO_SHOW=0 to show all months. Implemented in both Python and Go.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:18:55 +02:00
kacerrandClaude Opus 4.8 37fc17cf9c chore(settings): accumulate Claude Code permission allowlist entries
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:11:36 +02:00
kacerrandClaude Opus 4.8 20b618685f fix(reconcile): handle '?' expected fee in fill-first allocation
Juniors with exactly 1 session get expected='?' (manual-review marker
from attendance.py). The fill-first allocation block summed and cast
expected values numerically without guarding against this, causing a
TypeError: unsupported operand type(s) for +: 'int' and 'str' on the
/juniors route whenever any matched payment landed on such a month.

Add _expected_amount() helper that coerces non-numeric markers to 0
(same convention the final-balance calculation at line 512 already used)
and apply it in the two failing spots plus the existing isinstance check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 11:10:41 +02:00
kacerrandClaude Sonnet 4.6 72e29b1882 chore(changelog): add entry for fee rate update 2026-05 through 2026-08
Build and Push / build (push) Successful in 11s
Build and Push / build-go (push) Successful in 1m24s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 21:58:25 +02:00
kacerr 241fecfb2c Merge pull request 'feat: multi-account Fio sync + switch QR default to 2502035405/2010' (#37) from feat/multi-account-bank-sync into main
Deploy to K8s / deploy (push) Successful in 22s
Reviewed-on: #37
2026-05-24 19:57:52 +00:00
kacerr 152908fec6 Merge pull request 'gitignore go/parity' (#36) from fix/fill-first-multi-month-allocation into main
Deploy to K8s / deploy (push) Successful in 34s
Reviewed-on: #36
2026-05-24 19:16:27 +00:00
kacerr fbc5a41d12 gitignore go/parity
Deploy to K8s / deploy (push) Successful in 22s
2026-05-24 21:15:38 +02:00
104 changed files with 1262 additions and 186 deletions
+28 -1
View File
@@ -10,7 +10,34 @@
"Bash(./bin/fuj help *)",
"Bash(./bin/fuj version *)",
"Bash(make go-test *)",
"Bash(make go-lint *)"
"Bash(make go-lint *)",
"Bash(tea pr create --title 'fix\\(go\\): pass raw value to FormatDate so numeric dates format' --description ' *)",
"Bash(git checkout *)",
"Bash(go build *)",
"Bash(go test *)",
"Bash(make parity *)",
"Bash(tea pr create --title 'fix\\(go\\): accept single-digit day/month in attendance date headers' --description ' *)",
"Bash(lsof -nP -iTCP:8080 -sTCP:LISTEN)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management checkout -b fix/period-selector-restore)",
"Bash(git pull *)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management log --oneline -20)",
"Bash(curl -s -o /tmp/fio-transparent.html \"https://ib.fio.cz/ib/transparent?a=2800359168\")",
"Read(//tmp/**)",
"Bash(grep -oE '[0-9]{1,2}\\\\.[0-9]{1,2}\\\\.[0-9]{2,4}' /tmp/fio-transparent.html | head -20)",
"Read(//private/tmp/**)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management status)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management diff --stat HEAD)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management log -8 --oneline)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management tag --sort=-v:refname)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management branch --show-current)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management show --stat --format='%H %s%n%nbranch?: %d' 0.33)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management for-each-ref --format='%\\(refname:short\\) %\\(objectname:short\\) %\\(subject\\)' refs/tags)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management branch -r --contains 0.33)",
"Bash(git -C /Users/jan.novak/srv/personal/fuj-management diff --stat main HEAD -- Makefile scripts/fio_utils.py scripts/sync_fio_to_sheets.py CHANGELOG.md)",
"Bash(cp /Users/jan.novak/.claude/plans/in-python-app-i-m-distributed-muffin.md /Users/jan.novak/srv/personal/fuj-management/docs/plans/2026-06-08-1110-junior-expected-fix.md)"
],
"additionalDirectories": [
"/Users/jan.novak/srv/personal/fuj-management/docs/plans"
]
}
}
+2 -2
View File
@@ -31,11 +31,11 @@ jobs:
TAG=${{ inputs.tag }}
fi
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG
docker build -f build/Dockerfile \
docker build -f legacy/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 .
-t $IMAGE legacy/
docker push $IMAGE
build-go:
+105
View File
@@ -0,0 +1,105 @@
name: GitOps image update
on:
# Auto-fires when "Build and Push" completes successfully (tag push).
workflow_run:
workflows: ["Build and Push"]
types: [completed]
# Manual trigger for dry-runs and one-off bumps.
workflow_dispatch:
inputs:
tag:
description: "Git tag to deploy (without the -go suffix, e.g. 0.37)"
required: true
dry_run:
description: "Dry run — print diff, do not open a PR"
type: boolean
default: false
uh_cli_version:
description: "uh-cli version override (e.g. v0.2.0). Defaults to v0.1.0."
required: false
env:
TEA_VERSION: "0.9.2"
# Resolved priority: manual input → repo/org variable → hardcoded default.
UH_CLI_VERSION: ${{ inputs.uh_cli_version || vars.UH_CLI_VERSION || 'v0.1.0' }}
jobs:
gitops-pr:
runs-on: ubuntu-latest
# Skip if triggered by workflow_run that did not succeed.
if: >
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
container:
image: ubuntu:latest
env:
GITEA_TOKEN: ${{ secrets.GITOPS_TOKEN }}
steps:
- name: Install git, curl, ca-certificates, jq
run: |
apt-get update -qq
apt-get install -y --no-install-recommends git curl ca-certificates jq
- name: Install tea
run: |
curl -fsSL \
"https://gitea.com/gitea/tea/releases/download/v${TEA_VERSION}/tea-${TEA_VERSION}-linux-amd64" \
-o /usr/local/bin/tea
chmod +x /usr/local/bin/tea
- name: Install uh-cli
run: |
curl -fsSL \
"https://gitea.home.hrajfrisbee.cz/kacerr/uh-cli/releases/download/${UH_CLI_VERSION}/uh-cli-${UH_CLI_VERSION}-linux-amd64" \
-o /usr/local/bin/uh-cli
chmod +x /usr/local/bin/uh-cli
- name: Resolve image tag
id: resolve
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
IMAGE="gitea.home.hrajfrisbee.cz/${{ github.repository }}:${{ inputs.tag }}-go"
else
# workflow_run: head_branch is not populated for tag pushes in Gitea Actions.
# Look up the tag name that points to the triggering commit SHA via the API.
SHA="${{ github.event.workflow_run.head_sha }}"
GIT_TAG=$(curl -fsSL \
-H "Authorization: token ${GITEA_TOKEN}" \
"https://gitea.home.hrajfrisbee.cz/api/v1/repos/${{ github.repository }}/tags?limit=50" \
| jq -r --arg sha "$SHA" '.[] | select(.commit.sha == $sha) | .name')
IMAGE="gitea.home.hrajfrisbee.cz/${{ github.repository }}:${GIT_TAG}-go"
fi
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "Resolved image: ${IMAGE}"
- name: Configure git identity and credentials
run: |
git config --global user.name "uh-cli bot"
git config --global user.email "bot@hrajfrisbee.cz"
# Store credentials separately so the --git-repo URL stays clean.
# Tea matches the login URL against the remote URL; embedded credentials
# break that matching and cause "path segment [0] is empty" on pr create.
git config --global credential.helper store
echo "https://kacerr:${GITEA_TOKEN}@gitea.home.hrajfrisbee.cz" >> ~/.git-credentials
- name: Authenticate tea
run: |
tea login add \
--name ci \
--url https://gitea.home.hrajfrisbee.cz \
--token "$GITEA_TOKEN"
- name: Open image-update PR (or dry run)
run: |
set -x
uh-cli -v gitops deployment update \
--deployment-name fuj-management \
--deployment-namespace fuj \
--set-image "${{ steps.resolve.outputs.image }}" \
--git-repo "https://gitea.home.hrajfrisbee.cz/kacerr/home-kubernetes" \
--git-path gitops/home-kubernetes \
${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run == 'true') && '--dry-run' || '' }}
+1
View File
@@ -7,3 +7,4 @@ tmp/
# go build output
bin/
go/parity
+5 -3
View File
@@ -2,14 +2,16 @@
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Flask",
"name": "Python Debugger: Flask (legacy)",
"type": "debugpy",
"request": "launch",
"module": "flask",
"python": "${workspaceFolder}/.venv/bin/python",
"python": "${workspaceFolder}/legacy/.venv/bin/python",
"cwd": "${workspaceFolder}/legacy",
"env": {
"FLASK_APP": "app.py",
"FLASK_DEBUG": "1"
"FLASK_DEBUG": "1",
"CREDENTIALS_PATH": "${workspaceFolder}/.secret/fuj-management-bot-credentials.json"
},
"args": [
"run",
+30
View File
@@ -1,5 +1,35 @@
# Changelog
## 2026-10-08 11:34 CEST — fix(python): return months_to_show from /api/adults and /api/juniors
- Go already returned `months_to_show` in the JSON API; Python only passed it to its HTML templates. Backported the key so the API contract matches and `make parity` reports 3/3 routes.
- Key files: `legacy/app.py`, `legacy/tests/test_app.py`.
## 2026-10-08 11:34 CEST — fix: VS is always a string, on read and on write
- Go rendered numeric sheet cells with `fmt.Sprint`, so a numeric VS came out as `2.609767878e+09`. Text columns of the payments sheet now go through `sheets.CellString` (plain digits); bank inference uses it too.
- Both sync implementations now append VS as a text cell (leading apostrophe under `USER_ENTERED`), so Sheets no longer stores it as a number or drops leading zeros. Existing rows are unchanged.
- Key files: `go/internal/io/sheets/cell.go`, `go/internal/services/membership/sources.go`, `go/internal/services/banksync/{sync,infer}.go`, `legacy/scripts/sync_fio_to_sheets.py`.
## 2026-10-08 11:33 CEST — refactor: move Python implementation to legacy/, Go is primary
- Go (`go/`) is now the primary implementation. The original Python version moved to `legacy/` (`app.py`, `scripts/`, `templates/`, `tests/`, `pyproject.toml`, `uv.lock`, `build/`) and is considered legacy; new features are still built in both.
- `CLAUDE.md` rewritten for the Go-first layout, with a "Dual implementation rule" section.
- Makefile Python targets keep their names and point at `legacy/`; virtualenv is `legacy/.venv`, Python cache is `legacy/tmp/`; `CREDENTIALS_PATH` is exported pointing at the root `.secret/` file.
- CI builds the Python image with `legacy/` as context; the image no longer contains the Makefile. `legacy/.dockerignore` now takes effect and excludes nested `__pycache__`.
- Go comments, fixture README, capture script and VS Code launch config updated to the new paths.
## 2026-06-12 19:32 CEST — feat(ci): gitops image-update PR workflow
- Added `.gitea/workflows/gitops-update.yaml`: after each successful Go image build, `uh-cli gitops deployment update` opens a PR in `kacerr/home-kubernetes` bumping the `fuj-management` Deployment (namespace `fuj`) to the new image tag.
- Supports `workflow_run` auto-trigger and `workflow_dispatch` with `dry_run` / `uh_cli_version` inputs.
- Requires `GITOPS_TOKEN` repo secret (Gitea PAT with write+PR access to `home-kubernetes`).
## 2026-05-24 21:58 CEST — feat(fees): update adult monthly rates for 2026-05 through 2026-08
- 2026-05: 700 → 450 CZK; 2026-06/07/08: 600 CZK (new months added).
- Mirrored in both `scripts/attendance.py` and `go/internal/domain/fees/fees.go`.
## 2026-05-24 21:42 CEST — feat: multi-account Fio sync + switch QR default to 2502035405/2010
- Added second bank account `2502035405/2010` (IBAN `CZ0820100000002502035405`) to sync.
+86 -24
View File
@@ -4,28 +4,62 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Status
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.
Financial management system for FUJ (Frisbee Ultimate Jablonec). Handles attendance-based fee calculation, Fio bank transaction sync, payment reconciliation, and a web dashboard.
- **Go is the primary implementation** (`go/`). It is the version that is built, deployed and developed first.
- **Python is legacy** (`legacy/`). The project was originally developed in Python (Flask); that version is kept runnable and in sync, but is considered legacy from now on.
## Dual implementation rule
Until the Python version is retired, **every new feature and every behaviour change must be implemented in both Go and Python**:
1. Implement and test in Go first (`go/`).
2. Mirror the change in the legacy Python code (`legacy/`), including its tests.
3. Keep the two in parity: shared constants (fee rates, merged months, bank accounts, sheet IDs) must match on both sides, and the JSON API contract (`/api/adults`, `/api/juniors`, `/api/payments`) must stay identical. The HTML frontends are allowed to diverge.
4. If a pure function's behaviour changes, add or update seeds in `legacy/scripts/_fixture_seeds.py`, run `make capture-fixtures`, and make sure `make go-test-all` passes. With both servers running, `make parity` diffs the live JSON APIs.
Go-only or Python-only changes are fine for things that exist on one side only (build, tooling, HTML/CSS/JS, refactors with no behaviour change).
## Key Constraints
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
- **Configuration**: External service IDs, credentials, and tunable parameters are centralized in `scripts/config.py`. Domain-specific constants (fees, merged months) stay in their respective modules.
- **Configuration**: External service IDs, credentials, and tunable parameters are centralized in `go/internal/config/config.go` (mirrored in `legacy/scripts/config.py`). Domain-specific constants (fees, merged months) stay in their respective modules.
## Development Setup
This project uses `uv` for dependency management.
Go (primary): a recent Go toolchain (see `go/go.mod`) and `golangci-lint`.
```bash
uv venv && uv sync
source .venv/bin/activate
make go-build # builds bin/fuj
```
Set `PYTHONPATH=scripts:.` when running scripts directly (the Makefile does this automatically).
Python (legacy) uses `uv`; the project file and virtualenv live in `legacy/`.
```bash
make venv # uv sync --project legacy → legacy/.venv
```
The Makefile sets `PYTHONPATH` (`legacy/scripts:legacy`) and `CREDENTIALS_PATH` (`.secret/fuj-management-bot-credentials.json` at the repo root) for the Python targets. Set both yourself when running Python scripts directly.
## Commands
Go (primary):
```bash
make web # Start dashboard at http://localhost:5001
make web-go # Build and start dashboard at http://localhost:8080
make go-build # Build bin/fuj
make go-test # Unit tests (go test -race ./...)
make go-parity # Parity tests against the captured Python fixtures
make go-test-all # Both of the above
make go-lint # golangci-lint
make go-run ARGS="fees" # Run a subcommand: server, fees, reconcile, sync, infer, version
make go-sync-debug [DAYS=N] # Dry-run bank sync with debug logs
```
Python (legacy):
```bash
make web-py # Start dashboard at http://localhost:5001 (make web is an alias)
make web-debug # Same with FLASK_DEBUG=1
make test # Run all tests (unittest discover)
make test-v # Tests with verbose output
@@ -33,12 +67,20 @@ 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
make image # Build the legacy Python Docker image
```
Cross-implementation:
```bash
make capture-fixtures # Regenerate go/tests/fixtures from the Python implementation
make parity # Diff /api/* between web-py (:5001) and web-go (:8080); both must be running
```
Run a single test:
```bash
PYTHONPATH=scripts:. python -m unittest tests.test_app.TestWebApp.test_adults_route
cd go && go test ./internal/domain/reconcile -run TestName
cd legacy && PYTHONPATH=scripts:. .venv/bin/python -m unittest tests.test_app.TestWebApp.test_adults_route
```
## Architecture
@@ -46,25 +88,45 @@ PYTHONPATH=scripts:. python -m unittest tests.test_app.TestWebApp.test_adults_ro
### 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)
Google Sheets (attendance) ──► attendance loader ──► reconcile ──► HTTP handlers ──► templates
Google Sheets (payments) ──► payments loader ──┘
Fio Bank API ──► bank sync ──► Google Sheets (payments)
```
### Key modules
### Repository layout
- `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`).
- `go/` — primary implementation (single binary `fuj`, templates and static assets embedded).
- `legacy/` — legacy Python implementation (`app.py`, `scripts/`, `templates/`, `tests/`, `pyproject.toml`, `build/Dockerfile`).
- `docs/` — documentation; `docs/plans/` holds plans and task lists.
- `.gitea/workflows/` — CI: image builds on tag push, gitops image-update PR.
### Key modules — Go (primary)
- `go/cmd/fuj` — CLI entry point; subcommands `server`, `fees`, `reconcile`, `sync`, `infer`, `version`.
- `go/cmd/parity` — diffs the JSON API of the Go and Python servers.
- `go/internal/config` — sheet IDs, bank accounts, env-driven settings (`CREDENTIALS_PATH`, `CACHE_DIR`, `CACHE_TTL_SECONDS`, `CACHE_API_CHECK_TTL_SECONDS`, `MONTHS_TO_SHOW`, `SERVER_ADDR`, `LOG_LEVEL`, `FIO_API_TOKEN_NEW` / `FIO_API_TOKEN_OLD`).
- `go/internal/domain` — pure logic: `fees` (rates, `CalculateFee`, `CalculateJuniorFee`), `reconcile`, `matching` (name matching, inference), `czech` (normalize, month parsing), `money`, `synch` (sync IDs).
- `go/internal/io` — external systems: `attendance` (CSV export), `sheets`, `drive`, `fio` (API + transparent-page scraper), `cache` (file cache). Each has a fake for tests.
- `go/internal/services` — `membership` (loading, merged months, fee and reconcile reports) and `banksync` (Fio sync + inference).
- `go/internal/web` — routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/flush-cache`, `/qr`, `/version`, `/api/*`; `templates/` and `static/` are embedded.
- `go/tests` — parity tests and the scrubbed fixture corpus captured from Python.
### Key modules — Python (legacy)
- `legacy/app.py` — Flask app; routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/qr`, `/flush-cache`, `/api/*`
- `legacy/scripts/attendance.py` — Fetches attendance CSV from Google Sheets, computes per-member per-month fees. Contains fee rate constants (`ADULT_FEE_DEFAULT`, `JUNIOR_FEE_DEFAULT`) and `ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` dicts.
- `legacy/scripts/match_payments.py` — `reconcile()` matches transactions to members/months. `fetch_sheet_data()` reads the payments sheet. `fetch_exceptions()` reads the `exceptions` tab.
- `legacy/scripts/cache_utils.py` — Invalidation via Google Drive API `modifiedTime`; falls back to 5-minute TTL buckets when Drive API is unavailable. Cache files live in `legacy/tmp/`.
- `legacy/scripts/sync_fio_to_sheets.py` — Pulls Fio bank transactions and appends them to the payments Google Sheet.
- `legacy/scripts/infer_payments.py` — Fills in Person/Purpose/Inferred Amount columns using name-matching heuristics.
- `legacy/scripts/views.py` — View models shared by the HTML routes and the JSON API.
- `legacy/scripts/config.py` — All external IDs, paths, and tunable TTLs. Override via env vars (`CREDENTIALS_PATH`, `CACHE_TTL_SECONDS`, `MONTHS_TO_SHOW`).
- `legacy/scripts/capture_fixtures.py`, `scrub_fixtures.py`, `_fixture_seeds.py` — produce the scrubbed parity fixtures for Go.
### Member tiers
Tiers are set in column B of the attendance sheet:
- `A` — Adult, pays fees (per-month rate from `ADULT_FEE_MONTHLY_RATE`, fallback 700 CZK for 2+ sessions; 200 CZK for exactly 1)
- `A` — Adult, pays fees (per-month rate from `AdultFeeMonthlyRate` / `ADULT_FEE_MONTHLY_RATE`, fallback 700 CZK for 2+ sessions; 200 CZK for exactly 1)
- `J` — Junior attending adult practices; their attendance is merged with the junior sheet
- `X` — Excluded from junior fee calculation (coaches, etc.)
@@ -76,17 +138,17 @@ Tiers are set in column B of the attendance sheet:
### 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.
`AdultMergedMonths` / `JuniorMergedMonths` in `go/internal/services/membership/sources.go` (`ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` in `legacy/scripts/attendance.py`) map a source month to a target month (e.g., `"2025-12": "2026-01"` merges December into January billing). The target month accumulates attendance from both months.
### Caching
`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()`.
Both implementations check the Drive API `modifiedTime` and serve a cached JSON file when the sheet hasn't changed, falling back to 5-minute TTL buckets when the Drive API is unavailable. Go: `go/internal/io/cache`, files in `tmp/go/` (`CACHE_DIR`). Python: `get_cached_data()` in `legacy/app.py`, files in `legacy/tmp/`, warmed up at startup (`warmup_cache()`). Flush via `/flush-cache` (POST).
### 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.
`Person` and `Purpose` are written by the inference step (`fuj infer` / `infer_payments.py`) and can be manually corrected. `manual fix` column presence disables re-inference for that row. Multiple people or months are comma-separated in Person/Purpose.
### QR codes
+32 -28
View File
@@ -1,26 +1,30 @@
.PHONY: help fees match web web-py web-debug web-go go-build go-test go-test-all go-parity go-run go-sync-debug go-lint capture-fixtures parity 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
# Legacy Python implementation lives in legacy/ (Go in go/ is primary).
LEGACY := legacy
export PYTHONPATH := $(CURDIR)/$(LEGACY)/scripts:$(CURDIR)/$(LEGACY):$(PYTHONPATH)
VENV := $(LEGACY)/.venv
PYTHON := $(CURDIR)/$(VENV)/bin/python3
CREDENTIALS := $(CURDIR)/.secret/fuj-management-bot-credentials.json
# Both implementations read CREDENTIALS_PATH; the Python default would resolve under legacy/.
export CREDENTIALS_PATH ?= $(CREDENTIALS)
GO_SRC := go
GO_BIN := bin/fuj
$(PYTHON): .venv/.last_sync
$(PYTHON): $(VENV)/.last_sync
.venv/.last_sync: pyproject.toml
uv sync
touch .venv/.last_sync
$(VENV)/.last_sync: $(LEGACY)/pyproject.toml
uv sync --project $(LEGACY)
touch $(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 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 - Start legacy Python dashboard (alias for web-py)"
@echo " make web-py - Start legacy Python dashboard on :5001"
@echo " make web-go - Build and start Go dashboard on :8080 (primary)"
@echo " make web-debug - Start Python dashboard in debug mode"
@echo " make go-build - Build Go binary to bin/fuj"
@echo " make go-test - Run Go unit tests"
@@ -44,21 +48,21 @@ help:
@echo " make docs - Serve documentation in a browser"
venv:
uv sync
uv sync --project $(LEGACY)
fees: $(PYTHON)
$(PYTHON) scripts/calculate_fees.py
$(PYTHON) $(LEGACY)/scripts/calculate_fees.py
match: $(PYTHON)
$(PYTHON) scripts/match_payments.py
$(PYTHON) $(LEGACY)/scripts/match_payments.py
web: web-py
web-py: $(PYTHON)
$(PYTHON) app.py
$(PYTHON) $(LEGACY)/app.py
web-debug: $(PYTHON)
FLASK_DEBUG=1 $(PYTHON) app.py
FLASK_DEBUG=1 $(PYTHON) $(LEGACY)/app.py
go-build:
cd $(GO_SRC) && go build -trimpath \
@@ -82,10 +86,10 @@ capture-fixtures: $(PYTHON)
infer_transaction_details format_date reconcile; do \
dir="go/tests/fixtures/$$([[ $$func == reconcile ]] && echo reconcile || echo pure/$$func)"; \
mkdir -p "$$dir"; \
PYTHONPATH=scripts:. $(PYTHON) scripts/capture_fixtures.py --func $$func --all \
$(PYTHON) $(LEGACY)/scripts/capture_fixtures.py --func $$func --all \
| while IFS= read -r line; do \
case_id=$$(echo "$$line" | $(PYTHON) -c "import sys,json; print(json.load(sys.stdin)['case'])"); \
echo "$$line" | $(PYTHON) scripts/scrub_fixtures.py > "$$dir/$${case_id}.json"; \
echo "$$line" | $(PYTHON) $(LEGACY)/scripts/scrub_fixtures.py > "$$dir/$${case_id}.json"; \
done; \
echo " $$func done"; \
done
@@ -112,34 +116,34 @@ image:
--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 .
-f $(LEGACY)/build/Dockerfile $(LEGACY)
run:
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
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS)
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
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --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
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from 2026-01-01 --to 2026-12-31 --sort-by-date
sync-debug: $(PYTHON) ## Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)
$(PYTHON) scripts/sync_fio_to_sheets.py --credentials .secret/fuj-management-bot-credentials.json --days $(DAYS) --dry-run --print-fio-table
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --days $(DAYS) --dry-run --print-fio-table
infer: $(PYTHON)
$(PYTHON) scripts/infer_payments.py --credentials $(CREDENTIALS)
$(PYTHON) $(LEGACY)/scripts/infer_payments.py --credentials $(CREDENTIALS)
reconcile: ## Match payments against attendance
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts && $(PYTHON) scripts/match_payments.py --credentials $(CREDENTIALS)
reconcile: $(PYTHON) ## Match payments against attendance
$(PYTHON) $(LEGACY)/scripts/match_payments.py --credentials $(CREDENTIALS)
test: $(PYTHON) ## Run web application tests
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover tests
cd $(LEGACY) && $(PYTHON) -m unittest discover tests
test-v: $(PYTHON) ## Run tests with verbose output
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts:$(CURDIR) && $(PYTHON) -m unittest discover -v tests
cd $(LEGACY) && $(PYTHON) -m unittest discover -v tests
docs: ## Serve documentation locally
@echo "Starting documentation server at http://localhost:8000"
+2
View File
@@ -4,6 +4,8 @@ Welcome to the documentation for the FUJ Management application.
This project automates financial and operational management for the FUJ (Frisbee Ultimate Jablonec) club.
> **Go is the primary implementation** (`go/`). The project was originally developed in Python; that version now lives in `legacy/` and is considered legacy. New features are still built in both. Most guides below predate this change and describe the Python version with its old paths (`app.py`, `scripts/` are now `legacy/app.py`, `legacy/scripts/`).
## Navigation
Use the sidebar to explore the documentation:
@@ -0,0 +1,90 @@
# Fix `/juniors` 500: `int + str` in reconcile allocation
## Context
The `/juniors` route crashes with:
```
File ".../scripts/match_payments.py", line 469, in reconcile
total_expected = sum(e for _, e in in_window)
TypeError: unsupported operand type(s) for +: 'int' and 'str'
```
**Why it happens:** A junior with exactly **1 session** in a month gets an
expected fee of the string `"?"` (manual-review marker), set in
[attendance.py:107](scripts/attendance.py#L107). That value flows unchanged into
the ledger as `ledger[name][m]["expected"]`
([match_payments.py:370](scripts/match_payments.py#L370)).
When a bank payment is matched to such a junior+month, the new **fill-first
allocation** block builds `in_window` from those `expected` values and then sums
them ([match_payments.py:469](scripts/match_payments.py#L469)) and later does
`float(exp)` ([match_payments.py:480](scripts/match_payments.py#L480)) — both
blow up on the string `"?"`. Adults never hit this because adults never get
`"?"`, which is why only `/juniors` 500s.
Note the final-balance code at
[match_payments.py:511-512](scripts/match_payments.py#L511-L512) **already**
handles this defensively (`mdata["expected"] if isinstance(..., int) else 0`).
The allocation block added in the recent `fill-first` work simply missed the
same guard. The intended convention is clear: a `"?"` (unknown) expected counts
as **0** for arithmetic, so any payment landing on such a month becomes surplus
/ positive balance rather than filling a deficit.
## Approach
Add one small helper and apply the existing "non-numeric expected → 0"
convention consistently inside `reconcile()`.
### 1. Helper (near the top of `scripts/match_payments.py`)
```python
def _expected_amount(value):
"""Numeric value of an 'expected' fee; non-numeric markers like '?' → 0."""
return value if isinstance(value, (int, float)) else 0
```
### 2. Apply it in the allocation block
- [match_payments.py:469](scripts/match_payments.py#L469):
`total_expected = sum(_expected_amount(e) for _, e in in_window)`
- [match_payments.py:480](scripts/match_payments.py#L480):
`deficit = max(0.0, float(_expected_amount(exp)) - paid_so_far)`
With expected coerced to 0 for `"?"` months:
- A `"?"`-only payment falls into the existing `total_expected == 0` fallback
([match_payments.py:495-499](scripts/match_payments.py#L495-L499)) and is
recorded as paid (prepayment behaviour) → shows as positive balance.
- Mixed with real months, the `"?"` month gets deficit 0 (skipped), real months
fill first, surplus → credit. Consistent with line 512.
### 3. Reuse the helper at line 512 (optional consistency tidy)
Replace the inline `isinstance(mdata["expected"], int)` check at
[match_payments.py:512](scripts/match_payments.py#L512) with
`_expected_amount(mdata["paid"]/expected)` form, i.e. use `_expected_amount(...)`.
This also closes a latent gap where a **float** exception amount would be treated
as 0 (current check only accepts `int`).
`print_report` (line 552+) iterates adults only, so it's unaffected and needs no
change.
## Verification
1. `make web` and open `http://localhost:5001/juniors` — page renders 200 (was
500). Confirm a junior with a single-session `"?"` month who also has a
matched payment shows a sensible balance.
2. `/adults` still renders unchanged (regression check).
3. `make test` — existing reconcile tests still pass; if there is a
reconcile test fixture, add a case where a junior month has `expected == "?"`
plus a matched transaction and assert no exception + payment counted as
surplus.
## Notes
- Single-file change: [scripts/match_payments.py](scripts/match_payments.py).
- This is a bug fix; per CLAUDE.md a small fix may go straight to `main`, but
confirm with the user whether to branch (`fix/junior-expected-question-mark`).
- Add a CHANGELOG.md entry once confirmed working.
- On execution, copy this plan to `docs/plans/<ts>-junior-expected-fix.md` per
the repo plan convention.
@@ -0,0 +1,146 @@
# Plan: Limit /adults and /juniors to last N months (default 5)
## Context
The `/adults` and `/juniors` dashboard tables render one column per month of
attendance/fee history. As the season accumulates months, the tables grow wider
than the screen. The goal is to show only the **last N months** by default
(N = 5), with N configurable via an env var, so the tables fit on screen. This
must be implemented in **both** the Python/Flask version and the Go version,
keeping their behavior identical.
### Key correctness requirement
Member **balances, credits, and debts** must continue to reflect *all* history,
not just the visible window. Hiding older columns must not hide older debt.
This is naturally satisfied because in both codebases the balance math iterates
over the full per-member month map produced by reconcile, while only the
*column rendering* iterates over the passed-in month list:
- Python `scripts/views.py`: `settled_balance` / `_settled_balance` /
credits / debts loop over `data["months"].items()` (full), whereas columns,
totals, and per-row cells loop over `sorted_months`.
- Go `build_adults.go` / `build_juniors.go`: `settledBalance(mr, ...)` loops
over `mr.Months` (full); columns/totals/cells loop over `sortedMonths`.
Therefore the correct seam is: run `reconcile()` / `Reconcile()` on the **full**
month list, then trim the list to the last N **only for the view-model builder**.
The member-details modal also keeps full history because it reads the untrimmed
`member_data` / `MemberData`.
## Approach
Add a `MONTHS_TO_SHOW` tunable (default 5; `<= 0` means "show all" as an escape
hatch). Trim `sorted_months`/`sortedMonths` to the last N immediately before the
view-model builder, leaving reconcile on the full list.
### Python
1. **`scripts/config.py`** — add, next to the existing `CACHE_TTL_SECONDS`
pattern (`int(os.environ.get(...))`):
```python
MONTHS_TO_SHOW = int(os.environ.get("MONTHS_TO_SHOW", 5))
```
2. **`app.py`** — add a small helper (module-level) and apply it in all four
routes that build the adults/juniors view models:
```python
from config import MONTHS_TO_SHOW # add to existing config import
def _last_n_months(months):
return months[-MONTHS_TO_SHOW:] if MONTHS_TO_SHOW > 0 else months
```
In each route, keep `reconcile(members, sorted_months, ...)` on the full
list, then pass the trimmed list to the builder:
```python
result = reconcile(members, sorted_months, transactions, exceptions)
display_months = _last_n_months(sorted_months)
vm = build_adults_view_model(members, display_months, result, ...)
```
Apply to: `adults_view()` (~`app.py:226`), `juniors_view()` (~`app.py:260`),
and the JSON twins `/api/adults` (~`app.py:161`) and `/api/juniors`
(~`app.py:184`) for parity.
No changes to `scripts/views.py` — it already derives `months`,
`raw_months`, `totals`, and per-row `row.months` from whatever month list it
receives, and balances/credits/debts from the full `result`.
### Go
1. **`go/internal/config/config.go`** — add `MonthsToShow int` to the `Config`
struct (~line 57-67), populate it in `Load()` (~line 80-90) with a new
integer helper modeled on `envDuration`:
```go
func envInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
```
```go
MonthsToShow: envInt("MONTHS_TO_SHOW", 5),
```
(Note: unlike `envDuration`, accept `<= 0` so it can mean "show all".)
2. **`go/internal/web/api/handler.go`** — in `AssembleAdults`
(lines 50-57) and `AssembleJuniors` (lines 71-78), keep `Reconcile` on the
full `sortedMonths`, then trim before the builder:
```go
result := domreconcile.Reconcile(members, sortedMonths, txns, exceptions, time.Now().Year())
displayMonths := lastNMonths(sortedMonths, h.Config.MonthsToShow)
return buildAdultsResponse(members, displayMonths, result, txns, h.Config, time.Now().Format("2006-01")), nil
```
Add a small helper (e.g. in handler.go):
```go
func lastNMonths(months []string, n int) []string {
if n > 0 && len(months) > n {
return months[len(months)-n:]
}
return months
}
```
No changes to `build_adults.go` / `build_juniors.go` or the templates — they
already derive `Months`, `RawMonths`, `Totals`, and per-row cells from the
passed-in `sortedMonths`, and balances/credits/debts from the full
`result.Members[...]`.
## Critical files
- `scripts/config.py` — new `MONTHS_TO_SHOW` constant.
- `app.py` — trim helper + apply in 4 routes (HTML + JSON, adults + juniors).
- `go/internal/config/config.go` — `MonthsToShow` field + `envInt` helper.
- `go/internal/web/api/handler.go` — `lastNMonths` helper + apply in
`AssembleAdults` / `AssembleJuniors`.
No template or `views.py` / `build_*.go` changes required.
## Verification
Python:
- `make test` (and a targeted run, e.g.
`PYTHONPATH=scripts:. python -m unittest tests.test_app`).
- `make web`, open `/adults` and `/juniors`: confirm exactly 5 month columns by
default, and that the **Balance** column / credits / debts are unchanged from
before (compare against an untrimmed run, e.g. `MONTHS_TO_SHOW=0`).
- `MONTHS_TO_SHOW=3 make web` → 3 columns; `MONTHS_TO_SHOW=0` → all columns.
- Spot-check that the month-range filter dropdowns and a member-details modal
(full history) still work.
Go:
- `cd go && go build ./... && go test ./...`.
- Run the Go server, open `/adults` and `/juniors`: same checks as above
(default 5 columns, balances unchanged, `MONTHS_TO_SHOW` env override works).
- Confirm Python and Go render the same number of columns and identical
balances for the same data.
## Housekeeping
- Per `CLAUDE.md`, copy this plan to
`docs/plans/YYYY-MM-DD-HHMM-months-to-show.md` during implementation (create
`docs/plans/` if missing) and add a `CHANGELOG.md` entry once verified.
- Per `CLAUDE.md`, this is a feature → do it on a `feat/months-to-show` branch
and open a Gitea MR with `tea`; do not commit to `main`.
@@ -0,0 +1,112 @@
# Plan: Gitea Action to open a gitops image-update PR for fuj-management
## Context
The Go image of this app is built and pushed by the `build-go` job in
[.gitea/workflows/build.yaml](.gitea/workflows/build.yaml), tagged
`gitea.home.hrajfrisbee.cz/kacerr/fuj-management:<git-tag>-go` (e.g. `0.37-go`).
Kubernetes manifests live in a **separate** repo,
`gitea.home.hrajfrisbee.cz/kacerr/home-kubernetes`. Today, bumping the image in
the `fuj-management` Deployment (namespace `fuj`) is a manual edit there.
We want CI to automate that bump: when a new Go image is built, open a PR against
`home-kubernetes` that swaps the image to the freshly built tag — using the
`uh-cli gitops deployment update` command. The user reviews/merges that PR in
Gitea (matching the existing branch-per-change, merge-in-browser workflow).
Decisions confirmed with the user:
- **Separate workflow file** (not a job inside build.yaml).
- **New `GITOPS_TOKEN` secret** for home-kubernetes write + PR access.
- **uh-cli version pinned with a default, overridable via env/var/input.**
## How uh-cli works (from `/Users/jan.novak/srv/go/uh-cli/docs/`)
- `uh-cli gitops deployment update` clones `--git-repo`, walks `--git-path`
recursively for a `kind: Deployment` whose `metadata.name`/`namespace` match,
edits the first container image surgically, commits on a new branch
`gitops/update-<name>-<timestamp>`, pushes, and **opens the PR itself** via
`tea pr create`. PR base is always `main`; title/body are hardcoded (no flags).
- Requires on PATH: `git` and `tea` (tea only for the PR flow; `--force` skips it).
- Auth: token embedded in the `--git-repo` URL (`https://user:TOKEN@host/...`);
`tea login add` for PR creation; git identity via `git config`/env vars.
- `--dry-run` prints the unified diff and makes no git changes. Global `-v`
(placed **before** the subcommand) enables debug logging on stderr.
- Release binaries are named `uh-cli-<version>-linux-amd64` (version includes the
`v`), attached to the Gitea release. Latest tag today is **`v0.1.0`**.
## Change: new workflow `.gitea/workflows/gitops-update.yaml`
Triggers:
- `workflow_run` on `workflows: ["Build and Push"]`, `types: [completed]`, gated
to `conclusion == 'success'` — auto-fires after the image build succeeds.
- `workflow_dispatch` with inputs: `tag` (git tag without the `-go` suffix, e.g.
`0.37`), `dry_run` (boolean, default false), `uh_cli_version` (optional override).
Single job `gitops-pr`, `runs-on: ubuntu-latest`, in a `container: ubuntu:latest`
for a hermetic install (matches the uh-cli CI doc pattern). Steps:
1. **Install git, curl, ca-certificates, tea** — apt-get + download tea
`0.9.2` from `gitea.com/gitea/tea/releases/...` to `/usr/local/bin/tea`.
2. **Install uh-cli** — download
`https://gitea.home.hrajfrisbee.cz/kacerr/uh-cli/releases/download/${UH_CLI_VERSION}/uh-cli-${UH_CLI_VERSION}-linux-amd64`
to `/usr/local/bin/uh-cli`.
`UH_CLI_VERSION: ${{ inputs.uh_cli_version || vars.UH_CLI_VERSION || 'v0.1.0' }}`.
3. **Resolve image tag** — if `workflow_dispatch`, use `inputs.tag`; else use
`github.event.workflow_run.head_branch` (the pushed tag name). Output
`gitea.home.hrajfrisbee.cz/${{ github.repository }}:<tag>-go`.
4. **Configure git identity** — `git config --global user.name/email` for the bot.
5. **Authenticate tea** — `tea login add --name ci --url https://gitea.home.hrajfrisbee.cz --token "$GITEA_TOKEN"`.
6. **Open image-update PR** — run, with `--dry-run` appended only when the
dispatch `dry_run` input is true:
```
uh-cli -v gitops deployment update \
--deployment-name fuj-management \
--deployment-namespace fuj \
--set-image "<resolved image>" \
--git-repo "https://fuj-gitops-bot:${GITEA_TOKEN}@gitea.home.hrajfrisbee.cz/kacerr/home-kubernetes" \
--git-path gitops/home-kubernetes
```
`GITEA_TOKEN` is sourced from `secrets.GITOPS_TOKEN` at job level.
Job-level guard: `if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}`.
## Prerequisites (user must set up in Gitea — call out in handoff)
1. **Create `GITOPS_TOKEN` secret** in the `fuj-management` repo: a Gitea token
for a user (`fuj-gitops-bot` or `kacerr`) that has **write + pull-request**
access to `kacerr/home-kubernetes`. The username in the `--git-repo` URL must
match that token's owner (adjust `fuj-gitops-bot` if using `kacerr`).
2. **uh-cli `v0.1.0` release assets must exist** (the `uh-cli-v0.1.0-linux-amd64`
binary attached to the release). If not yet published, cut that release in the
uh-cli repo first, or set `UH_CLI_VERSION` to a published tag.
3. **Confirm the manifest path**: `--git-path gitops/home-kubernetes` must contain
the `fuj-management` Deployment; `--deployment-namespace fuj` disambiguates.
Cannot verify from this repo — verify against home-kubernetes (narrow the path
if uh-cli reports an ambiguity error).
## Files
- **New**: `.gitea/workflows/gitops-update.yaml` (the workflow above).
- After it works: prepend a `CHANGELOG.md` entry; save this plan to
`docs/plans/<ts>-gitops-pr-action.md` per CLAUDE.md convention.
## Branching
Feature work → branch `feat/gitops-pr-action` off `main`, commit with the
`Co-Authored-By` trailer, push with `-u`, open the MR with
`tea pr create --base main --head feat/gitops-pr-action`. Do not merge from CLI.
## Verification
1. **Dry run (manual)**: trigger `gitops-update.yaml` via workflow_dispatch with
`tag=0.37`, `dry_run=true`. Confirm logs show the unified diff (image line
`…:0.37-go`) and `-v` debug milestones; **no PR** is created.
2. **Real run (manual)**: re-trigger with `dry_run=false`. Confirm a PR appears in
`home-kubernetes` against `main` with the image bump, and the PR URL is printed.
3. **Auto-trigger**: push a new git tag to fuj-management → `Build and Push`
completes → `gitops-update` fires via `workflow_run` and opens the PR.
(Note: `workflow_run`/`head_branch` behavior depends on this Gitea/act_runner
version; if it doesn't fire, manual `workflow_dispatch` is the fallback and the
plan still delivers the core capability.)
@@ -0,0 +1,157 @@
# Pre-feature cleanup — task list
Findings from a full repo audit on 2026-10-08, written up as tasks to clear
before new feature work starts. Companion to the Go rewrite tracker,
[2026-05-03-2349-go-backend-rewrite-progress.md](2026-05-03-2349-go-backend-rewrite-progress.md).
## State at audit time
- Two full implementations live side by side: Flask (`app.py`, `scripts/`,
`templates/`) and Go (`go/`). Recent changes were made in both.
- Update 2026-10-08: the Python code was moved to `legacy/` after this audit.
Python paths below are relative to `legacy/` (`build/Dockerfile` is now
`legacy/build/Dockerfile`).
- Python tests pass (32 tests). Go unit tests and the parity suite pass.
Go lint could not run (see T7).
- The gitops workflow bumps the cluster deployment to the `-go` image, so Go
appears to be what is deployed. This was not verified against the cluster.
- `CLAUDE.md` and all docs outside `docs/plans/` describe a Python-only
project. The rewrite tracker stops at M6; M7 and M8 are untouched.
## How to use
- Tick a checkbox when the task's PR or commit lands and append the SHA.
- Tasks in "Fix first" are small and independent of each other and of the
cutover decision. Do them in any order.
- T5 is a decision. Several cleanup tasks become unnecessary once it is made;
they are marked "moot if Python is retired".
---
## Fix first
- [ ] **T1** Remove the secret leak in `.gitea/workflows/kubernetes-deploy.yaml`.
- It runs on every push to every branch and deploys nothing.
- It echoes the raw Vault login response, the Kanidm token response and the
OIDC token response body to the log, and dumps the environment twice.
- It uses `--insecure-skip-tls-verify=true`.
- Delete or disable the workflow. Rotate the Vault AppRole secret and the
Kanidm API token if anyone else can read the CI logs.
- [ ] **T2** Confirm the fee rates for 2026-09 onward.
- `ADULT_FEE_MONTHLY_RATE` ends at 2026-08 in `scripts/attendance.py` and
`go/internal/domain/fees/fees.go`.
- September and October 2026 are billed at the defaults: 700 CZK adults,
500 CZK juniors.
- If those are not the intended rates, add the months in both files.
- [ ] **T3** Remove the hardcoded 2026 window from Python `/sync-bank`
(`app.py`, the `sync_to_sheets` call with `2026-01-01` / `2026-12-31`).
- It stops picking up transactions on 2027-01-01. Go already uses the
current year.
- Same applies to the `sync-2026` Makefile target.
- Moot if Python is retired before then.
- [ ] **T4** Remove member names from tracked files (PII rule in `CLAUDE.md`).
- `CHANGELOG.md` line 29 names a member in a fix description.
- `tests/test_match_payments.py`, `tests/test_reconcile_exceptions.py` and
`go/internal/domain/reconcile/reconcile_test.go` use names that may be
real. Check them and replace with obviously fictional ones.
- Three files in `docs/plans/` contain the same names:
`2026-05-05-1717-payment-person-name-canonicalization.md`,
`2026-05-06-1626-infer-payments-junior-roster.md`,
`2026-05-11-2353-fill-first-multi-month-allocation.md`.
- The parity fixtures under `go/tests/fixtures/` are scrubbed and need no
change.
- Decide whether the names also need removing from git history.
## Decide before new features
- [ ] **T5** Decide how to finish the Python → Go cutover (M7 and M8 in the
rewrite tracker).
- Recommended: declare Go primary now, freeze Python, run one month-end
comparison of the balance report on both sides, then delete Python.
- Until this is done, every feature has to be written twice.
- Confirm first that the cluster really runs the `-go` image.
- [ ] **T6** Add a test job to CI.
- `.gitea/workflows/build.yaml` only builds images on tag push. Nothing
runs tests.
- Run `go test -race ./...`, the parity suite (`-tags=parity`) and
`golangci-lint` on pull requests.
- Stop building the Python image on every tag once Python is retired.
- [ ] **T7** Fix `make go-lint`.
- `go/.golangci.yml` is in the v1 config format. golangci-lint 2.12 refuses
to load it ("unsupported version of the configuration").
- Migrate the config to v2 and pin the linter version used in CI.
- [ ] **T8** Protect the side-effectful routes.
- Neither app has any authentication.
- `GET /sync-bank` writes to the payments sheet, so a link prefetcher or
crawler can trigger a sync. Change it to POST with a confirmation page,
as `/flush-cache` already does.
- Confirm that the ingress enforces access control, or add it in the app.
- The Go server uses `http.ListenAndServe` with no read, write or idle
timeouts (`go/internal/web/server.go`). Use an `http.Server` with
timeouts.
- [ ] **T9** Move fee rates and merged months out of code.
- Today a rate change needs a code change in two languages and a release.
- Candidate: a tab in the payments sheet, read the same way as
`exceptions`.
- This also removes the cause of T2.
## Cleanup
- [x] **T10** Rewrite `CLAUDE.md` for the current state: Go commands,
architecture and key modules, and which backend is primary. Done with the
move of Python to `legacy/`.
- [ ] **T11** Collapse the documentation into one set.
- `docs/by-claude-opus/`, `docs/by-gemini/` and the top-level docs overlap,
date from February–March 2026 and predate the Go rewrite.
- Update `docs/_sidebar.md` and `docs/README.md` to match.
- [ ] **T12** Update the rewrite tracker.
- M6.7 is unchecked although the changelog records it as done.
- M7.1 and M8.4 refer to a `docker-compose.yml` that does not exist.
- [ ] **T13** Remove leftovers: `.agent/rules.md` (Antigravity config) and
`prompts/`.
- [ ] **T14** Prune merged branches: 42 local and 44 remote branches are
fully merged into `main`.
- [ ] **T15** Clean up `.claude/settings.json`.
- It is tracked and contains absolute paths under one user's home
directory and one-off commands.
- Keep only shared, generic permissions there; move the rest to
`.claude/settings.local.json`, which is already ignored.
- [ ] **T16** Align versions.
- `pyproject.toml` says `0.10`; git tags are at `0.39`.
- `build/Dockerfile` pip-installs unpinned packages and ignores `uv.lock`.
- `go.mod` and `go/build/Dockerfile` are on Go 1.26; the local toolchain is
1.27.
- Dependencies on both sides are a few months behind.
- The Python items are moot if Python is retired.
- [ ] **T17** Stop the Python tests from calling live Google APIs.
- `warmup_cache()` runs at import of `app.py`, so `make test` makes real
Drive and Sheets read calls and needs the credentials file.
- Moot if Python is retired.
- [ ] **T18** Set the time zone in the Go container.
- The image has no time zone configured, so `time.Now()` is UTC and the
"current month" flips one to two hours late at month boundaries.
- The Python image sets `Europe/Prague`.
- [ ] **T19** Decide where `fujarna/` lives.
- `fujarna/project.md` is untracked. It is a brief for a separate
tournament website and shares almost nothing with this codebase.
- Options: its own repository, or a clearly separate Go module here.
- [ ] **T20** Add tests for the untested Go packages: `cmd/fuj`,
`internal/io/sheets`, `internal/io/drive`, `internal/config` (about 590
lines in total).
## Found during validation of the `legacy/` move
Plan: [2026-10-08-1103-api-parity-vs-months-to-show.md](2026-10-08-1103-api-parity-vs-months-to-show.md).
- [x] **T21** Go rendered numeric VS cells in exponent form
(`2.609767878e+09`). Sheet text cells now go through `sheets.CellString`.
- [x] **T22** Python `/api/adults` and `/api/juniors` did not return
`months_to_show`. Backported; `make parity` reports 3/3 routes matching.
- [x] **T23** Write VS to the payments sheet as text. Both sync
implementations use `USER_ENTERED`, so Sheets stored VS as a number and
dropped leading zeros. New rows now carry a text marker; existing rows are
left as they are and are read correctly either way.
## Notes & decisions
(Add entries as you go. Format: `YYYY-MM-DD — short note`.)
@@ -0,0 +1,112 @@
# Fix the two Go/Python parity differences
## Context
Running `make parity` against both containers on 2026-10-08 reported
`0/3 routes match`. Two causes, neither related to the move to `legacy/`:
1. **VS rendered in scientific notation by Go.** The payments sheet is read
with `UNFORMATTED_VALUE`, so a numeric VS cell arrives as `float64`. Go
stringifies cells with `fmt.Sprint`, which prints any float ≥ 1e6 in
exponent form (`2.609767878e+09`). Python already converts integer floats
to plain digits for VS (`get_str` in `fetch_sheet_data`). VS must always be
a plain string.
2. **`months_to_show` missing from the Python JSON API.** Go returns it in
`/api/adults` and `/api/juniors` (and it is `required` in the committed
schemas). Python already has the feature in its HTML pages
(`MONTHS_TO_SHOW` is passed to the templates) but never added the key to
the API responses. Backport the key so the API contract matches.
Outcome: `make parity` reports 3/3 routes matching.
## Branching
Both are bug fixes that touch `legacy/` paths, so they depend on the
uncommitted `feat/python-to-legacy` work. Commit that branch first (still
awaiting confirmation), then branch `fix/api-parity-vs-months-to-show` from
it, with one commit per task. Open the MR with `tea` after the legacy move is
merged, or target it at that branch.
## Task 1 — VS (and other text cells) always a plain string in Go
Root cause is `getVal` in `parseTransactionRows`
(`go/internal/services/membership/sources.go`, the `fmt.Sprint(row[i])`
closure). The same pattern exists in `get` in
`go/internal/services/banksync/infer.go`.
1. Add `CellString(v any) string` to `go/internal/io/sheets` (new small file
`cell.go`; both services already import this package):
- `nil` → `""`
- `float64` → `strconv.FormatFloat(x, 'f', -1, 64)` (never exponent form;
`2609767878.0` → `"2609767878"`, `12.5` → `"12.5"`)
- `string` → unchanged
- anything else → `fmt.Sprint(v)`
2. Use it in `getVal` in `sources.go`. This fixes VS and protects the other
text columns (Message, Bank ID, Sender, …) from the same failure.
- `getVal` currently returns `"<nil>"` for nil cells and the inferred-amount
code checks for that literal; simplify that check to `iv != ""`.
- Leave `getRaw`/`FormatDate` and `parseFloat` for Date and Amount as they
are — those must stay numeric.
3. Use it in `get` in `banksync/infer.go` (keeping the `TrimSpace`), so
inference sees the same text the dashboard shows.
4. Tests:
- `go/internal/io/sheets/cell_test.go`: table test for `CellString`
(large integer float, small integer float, fractional, string with
leading zeros, nil).
- Extend `TestLoadTransactions` in
`go/internal/services/membership/sources_test.go` with a row whose VS is
`2609767878.0` and whose Bank ID is an 11-digit float; assert plain
digit strings.
5. Python: no code change needed — `get_str` already does this for VS. Add one
regression test for `fetch_sheet_data` in
`legacy/tests/test_match_payments.py` (mock the Sheets service, numeric VS
cell → `"2609767878"`) so the two sides are pinned to the same behaviour.
Not included (note for later): VS is *written* to the sheet with
`USER_ENTERED` by both sync implementations, so Sheets stores it as a number
and drops leading zeros. Fixing that means writing VS as text and is a
separate change to the sheet's data.
## Task 2 — backport `months_to_show` to the Python JSON API
`legacy/app.py` only; `MONTHS_TO_SHOW` is already imported there.
1. In `api_adults()` and `api_juniors()`, add the key to the response:
build `out = _unwrap_view_model_for_api(vm)`, set
`out["months_to_show"] = MONTHS_TO_SHOW`, return `jsonify(out)`.
- Do not add it inside `build_*_view_model` or `_unwrap_view_model_for_api`:
the HTML routes already pass `months_to_show=` to `render_template`
alongside `**vm`, and a duplicate key would raise `TypeError`.
- `/api/payments` is unchanged; Go does not return the key there.
2. Tests in `legacy/tests/test_app.py`:
- Add `"months_to_show"` to the expected key list used by
`test_api_adults` / `test_api_juniors` (the tuple near the top of the
file), and assert the value is an `int`.
3. Go: no change. The key and schemas
(`go/tests/fixtures/api-schema/{adults,juniors}.schema.json`) already
contain it.
## Wrap-up
- `CHANGELOG.md` entry at the top (after confirmation it works), per repo
convention.
- Copy this plan to
`docs/plans/2026-10-08-HHMM-api-parity-vs-months-to-show.md`, per the repo's
plan-location rule.
- Add both items to
`docs/plans/2026-10-08-1026-pre-feature-cleanup-tasks.md` as done tasks so
the audit list stays complete.
## Verification
1. `make go-test-all` — unit tests (including the new ones) and parity
fixtures pass.
2. `make test` — Python tests pass, including the new API key and VS tests.
3. `cd go && go test ./internal/web/api/...` — schema golden test still
matches (no schema change expected).
4. End to end: start `make web-py` and `make web-go`, then `make parity`;
expect `3/3 routes match`. Alternatively repeat the two-container run used
to find the bugs (`make image`, Go image from `go/build/Dockerfile`, then
`go run ./cmd/parity -py … -go …`).
5. Open `/payments` on :8080 and check the affected transaction's VS shows as
plain digits.
+1 -1
View File
@@ -21,7 +21,7 @@ var allRoutes = []string{"/api/adults", "/api/juniors", "/api/payments"}
// defaultAllowlist holds dotted key paths to strip before diffing.
// render_time.total is forward-compatible insurance: today it lives in the Jinja
// template context only (app.py inject_render_time) and is logged via
// template context only (legacy/app.py inject_render_time) and is logged via
// middleware/timer.go on the Go side, so it isn't in any JSON response. If either
// side ever surfaces it under a render_time envelope, the scrubber handles it.
var defaultAllowlist = []string{"render_time.total"}
+13 -2
View File
@@ -42,7 +42,7 @@ const (
JuniorSheetGID = "1213318614" // gid=1213318614 — junior practices tab (IMPORTRANGE'd)
)
// CacheSheetMap mirrors scripts/config.py CACHE_SHEET_MAP.
// CacheSheetMap mirrors legacy/scripts/config.py CACHE_SHEET_MAP.
// Maps a cache key to the Google Sheet ID whose Drive modifiedTime gates it.
// Both attendance keys map to the same spreadsheet — different tabs, one Drive file.
var CacheSheetMap = map[string]string{
@@ -53,7 +53,7 @@ var CacheSheetMap = map[string]string{
}
// Config holds all runtime configuration loaded from environment variables.
// Mirrors scripts/config.py.
// Mirrors legacy/scripts/config.py.
type Config struct {
CredentialsPath string
QRAccount string // IBAN of the primary account used for QR codes
@@ -64,6 +64,7 @@ type Config struct {
DriveTimeout time.Duration
LogLevel string
ServerAddr string
MonthsToShow int // show last N month columns; 0 means show all
}
// Load reads configuration from the environment, applying defaults that
@@ -87,6 +88,7 @@ func Load() Config {
DriveTimeout: envDuration("DRIVE_TIMEOUT_SECONDS", 10),
LogLevel: env("LOG_LEVEL", "INFO"),
ServerAddr: env("SERVER_ADDR", ":8080"),
MonthsToShow: envInt("MONTHS_TO_SHOW", 5),
}
}
@@ -121,3 +123,12 @@ func envDuration(key string, defaultSeconds int) time.Duration {
}
return time.Duration(defaultSeconds) * time.Second
}
func envInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
@@ -9,7 +9,7 @@ func TestParseMonthReferences(t *testing.T) {
t.Parallel()
// All expected outputs verified against live Python implementation on 2026-05-05:
// PYTHONPATH=scripts:. python -c 'from czech_utils import parse_month_references; print(parse_month_references("<input>", 2026))'
// PYTHONPATH=legacy/scripts:legacy python -c 'from czech_utils import parse_month_references; print(parse_month_references("<input>", 2026))'
tests := []struct {
name string
input string
+2 -2
View File
@@ -1,4 +1,4 @@
// Package fees ports fee calculation from scripts/attendance.py.
// Package fees ports fee calculation from legacy/scripts/attendance.py.
package fees
const (
@@ -6,7 +6,7 @@ const (
AdultFeeSingle = 200 // CZK for exactly 1 practice
)
// AdultFeeMonthlyRate mirrors ADULT_FEE_MONTHLY_RATE in scripts/attendance.py.
// AdultFeeMonthlyRate mirrors ADULT_FEE_MONTHLY_RATE in legacy/scripts/attendance.py.
// Months absent from this map fall back to AdultFeeDefault.
var AdultFeeMonthlyRate = map[string]int{
"2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750,
+26 -10
View File
@@ -5,24 +5,40 @@ import "testing"
func TestCalculateFee(t *testing.T) {
t.Parallel()
// All expected outputs verified against live Python implementation on 2026-05-06:
// PYTHONPATH=scripts:. python -c 'from attendance import calculate_fee; print([calculate_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2026-03"),(2,"2025-09"),(5,"2026-05"),(2,"2027-01"),(2,"")]])'
// mustRate returns the configured rate for a month that must be in the map.
// It panics immediately if the month is absent — so if a rate entry is ever
// removed, the test fails loudly rather than silently comparing against
// Go's zero value.
mustRate := func(month string) int {
r, ok := AdultFeeMonthlyRate[month]
if !ok {
panic("test month not in AdultFeeMonthlyRate: " + month)
}
return r
}
tests := []struct {
name string
count int
month string
want int
}{
// Zero attendance always returns 0.
{"zero short-circuits", 0, "2026-05", 0},
{"zero empty month", 0, "", 0},
{"single practice", 1, "2026-05", 200},
{"single ignores monthKey", 1, "unknown", 200},
{"two practices configured month", 2, "2026-05", 700},
{"two practices reduced march", 2, "2026-03", 350},
{"two practices early season", 2, "2025-09", 750},
{"high count same as two", 5, "2026-05", 700},
{"unknown future month falls back", 2, "2027-01", 700},
{"empty month falls back", 2, "", 700},
// Single practice returns AdultFeeSingle regardless of month.
{"single practice", 1, "2026-05", AdultFeeSingle},
{"single ignores monthKey", 1, "unknown", AdultFeeSingle},
// Two+ practices for a configured month: must use the map value, not the default.
// Expected values are read from AdultFeeMonthlyRate so this test stays correct
// when rates are updated — the assertion verifies dispatch logic, not rate values.
{"two practices configured month", 2, "2026-05", mustRate("2026-05")},
{"two practices reduced march", 2, "2026-03", mustRate("2026-03")},
{"two practices early season", 2, "2025-09", mustRate("2025-09")},
{"high count same as two", 5, "2026-05", mustRate("2026-05")},
// Two+ practices for an unknown/future month: must fall back to AdultFeeDefault.
{"unknown future month falls back", 2, "2027-01", AdultFeeDefault},
{"empty month falls back", 2, "", AdultFeeDefault},
}
for _, tc := range tests {
+1 -1
View File
@@ -2,7 +2,7 @@ package fees
const JuniorFeeDefault = 500 // CZK fallback for 2+ practices when month not in JuniorFeeMonthlyRate
// JuniorFeeMonthlyRate mirrors JUNIOR_MONTHLY_RATE in scripts/attendance.py.
// JuniorFeeMonthlyRate mirrors JUNIOR_MONTHLY_RATE in legacy/scripts/attendance.py.
// Months absent from this map fall back to JuniorFeeDefault.
var JuniorFeeMonthlyRate = map[string]int{
"2025-09": 250,
+21 -8
View File
@@ -5,24 +5,37 @@ import "testing"
func TestCalculateJuniorFee(t *testing.T) {
t.Parallel()
// All expected outputs verified against live Python implementation on 2026-05-06:
// PYTHONPATH=scripts:. python -c 'from attendance import calculate_junior_fee; print([calculate_junior_fee(c,m) for c,m in [(0,"2026-05"),(0,""),(1,"2026-05"),(1,"unknown"),(2,"2026-05"),(2,"2025-09"),(2,"2026-03"),(5,"2025-09"),(2,"2027-01"),(2,"")]])'
// mustRate returns the configured rate for a month that must be in the map.
// Panics immediately if the month is absent so a removed entry causes a loud
// failure rather than a silent comparison against Go's zero value.
mustRate := func(month string) Expected {
r, ok := JuniorFeeMonthlyRate[month]
if !ok {
panic("test month not in JuniorFeeMonthlyRate: " + month)
}
return Expected{Value: r}
}
tests := []struct {
name string
count int
month string
want Expected
}{
// Zero attendance always returns 0.
{"zero short-circuits", 0, "2026-05", Expected{Value: 0}},
{"zero empty month", 0, "", Expected{Value: 0}},
// Single practice returns the Unknown sentinel regardless of month.
{"single practice sentinel", 1, "2026-05", Expected{Unknown: true}},
{"single ignores monthKey", 1, "unknown", Expected{Unknown: true}},
{"two practices default month", 2, "2026-05", Expected{Value: 500}},
{"two practices reduced sept", 2, "2025-09", Expected{Value: 250}},
{"two practices reduced march", 2, "2026-03", Expected{Value: 250}},
{"high count same as two", 5, "2025-09", Expected{Value: 250}},
{"unknown future month falls back", 2, "2027-01", Expected{Value: 500}},
{"empty month falls back", 2, "", Expected{Value: 500}},
// Two+ practices for a configured month: must use the map value, not the default.
{"two practices unconfigured month", 2, "2026-05", Expected{Value: JuniorFeeDefault}},
{"two practices reduced sept", 2, "2025-09", mustRate("2025-09")},
{"two practices reduced march", 2, "2026-03", mustRate("2026-03")},
{"high count same as two", 5, "2025-09", mustRate("2025-09")},
// Two+ practices for an unknown/future month: must fall back to JuniorFeeDefault.
{"unknown future month falls back", 2, "2027-01", Expected{Value: JuniorFeeDefault}},
{"empty month falls back", 2, "", Expected{Value: JuniorFeeDefault}},
}
for _, tc := range tests {
+1 -1
View File
@@ -1,2 +1,2 @@
// Package matching ports name/member matching from scripts/match_payments.py.
// Package matching ports name/member matching from legacy/scripts/match_payments.py.
package matching
+1 -1
View File
@@ -14,7 +14,7 @@ var sheetsEpoch = time.Date(1899, 12, 30, 0, 0, 0, 0, time.UTC)
// a pre-formatted "YYYY-MM-DD" string (returned as-is), or any other value
// (returned as fmt.Sprint(v).TrimSpace). Never returns an error.
//
// Ports scripts/match_payments.py format_date.
// Ports legacy/scripts/match_payments.py format_date.
func FormatDate(val any) string {
if val == nil {
return ""
@@ -1,8 +1,8 @@
package matching
// Expected values verified against scripts/match_payments.py on 2026-05-06:
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
//
// PYTHONPATH=scripts:. python3 -c '
// PYTHONPATH=legacy/scripts:legacy python3 -c '
// from match_payments import format_date
// for v in [None, "", 44197, 44197.5, "2026-04-15", "garbage", " 2026-04-15 "]:
// print(repr(format_date(v)))
+1 -1
View File
@@ -33,7 +33,7 @@ type InferredDetails struct {
// defaultYear seeds czech.ParseMonthReferences (Python defaulted to the current year;
// callers should pass time.Now().Year() or a fixed year for deterministic tests).
//
// Ports scripts/match_payments.py infer_transaction_details.
// Ports legacy/scripts/match_payments.py infer_transaction_details.
func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear int) InferredDetails {
searchText := fmt.Sprintf("%s %s %s", tx.Sender, tx.Message, tx.UserID)
+2 -2
View File
@@ -1,8 +1,8 @@
package matching
// Expected values verified against scripts/match_payments.py on 2026-05-06:
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
//
// PYTHONPATH=scripts:. python3 << 'EOF'
// PYTHONPATH=legacy/scripts:legacy python3 << 'EOF'
// from match_payments import infer_transaction_details
// MEMBERS = ["Tomáš Němeček (Tov)", "Jana Nováková"]
// cases = [
+1 -1
View File
@@ -28,7 +28,7 @@ var commonSurnames = map[string]bool{
// MatchMembers finds members mentioned in text and returns them with a
// confidence level of "auto" (reliable) or "review" (needs human verification).
//
// Algorithm (ported verbatim from scripts/match_payments.py match_members):
// Algorithm (ported verbatim from legacy/scripts/match_payments.py match_members):
// 1. Exact short-circuit: if any member's full normalized name appears as whole
// words in normalize(text), return ONLY those matches as auto. This prevents
// nickname "tov" from matching inside surname "ottova".
@@ -1,9 +1,9 @@
package matching
// Expected values verified against scripts/match_payments.py and
// Expected values verified against legacy/scripts/match_payments.py and
// tests/test_match_members.py on 2026-05-06:
//
// PYTHONPATH=scripts:. python3 -c '
// PYTHONPATH=legacy/scripts:legacy python3 -c '
// from match_payments import match_members
// MEMBERS = ["Henrietta Ottová", "Tomáš Němeček (Tov)", "František Vrbík (Štrúdl)", "Jana Nováková"]
// cases = [
+1 -1
View File
@@ -19,7 +19,7 @@ var (
// this invariant for the exact short-circuit pass. Variants shorter than 3 characters are
// dropped.
//
// Ports scripts/match_payments.py _build_name_variants.
// Ports legacy/scripts/match_payments.py _build_name_variants.
func BuildNameVariants(name string) []string {
var nickname string
if m := nicknameRe.FindStringSubmatch(name); m != nil {
@@ -1,8 +1,8 @@
package matching
// Expected values verified against scripts/match_payments.py on 2026-05-06:
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
//
// PYTHONPATH=scripts:. python3 -c '
// PYTHONPATH=legacy/scripts:legacy python3 -c '
// from match_payments import _build_name_variants
// for n in ["František Vrbík (Štrúdl)", "Tov (St)", "Jana", " Petr Novák ( Jenda ) "]:
// print(repr(n), "->", _build_name_variants(n))
+2 -2
View File
@@ -1,4 +1,4 @@
// Package money ports Czech-locale currency parsing from scripts/infer_payments.py.
// Package money ports Czech-locale currency parsing from legacy/scripts/infer_payments.py.
package money
import (
@@ -11,7 +11,7 @@ import (
var ErrInvalidAmount = errors.New("money: invalid CZK amount")
// ParseCZK parses a Czech-locale amount string and returns the value in CZK
// as a float64. Mirrors scripts/infer_payments.py parse_czk_amount:
// as a float64. Mirrors legacy/scripts/infer_payments.py parse_czk_amount:
//
// - empty input → (0, nil)
// - "Kč"/"CZK" suffixes stripped (case-sensitive, like Python)
+1 -1
View File
@@ -8,7 +8,7 @@ func TestParseCZK(t *testing.T) {
t.Parallel()
// All expected outputs verified against live Python implementation on 2026-05-06:
// PYTHONPATH=scripts:. python -c '
// PYTHONPATH=legacy/scripts:legacy python -c '
// from infer_payments import parse_czk_amount
// for v in [None, "", "0", "500", "500 Kč", "500 CZK",
// "1 500", "1500.00", "1 500.00",
+3 -3
View File
@@ -1,4 +1,4 @@
// Package reconcile ports the three-phase payment reconciliation from scripts/match_payments.py.
// Package reconcile ports the three-phase payment reconciliation from legacy/scripts/match_payments.py.
package reconcile
import (
@@ -105,7 +105,7 @@ var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
// canonicalMemberKey returns a diacritic-, case-, and whitespace-insensitive key
// used to resolve Person-column values that drift from canonical attendance-sheet names.
// Ports scripts/match_payments.py canonical_member_key.
// Ports legacy/scripts/match_payments.py canonical_member_key.
func canonicalMemberKey(name string) string {
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
}
@@ -124,7 +124,7 @@ type monthExpected struct {
// defaultYear seeds czech.ParseMonthReferences in the inference fallback.
// Pass time.Now().Year() in production; pass a fixed year in tests.
//
// Ports scripts/match_payments.py reconcile.
// Ports legacy/scripts/match_payments.py reconcile.
func Reconcile(
members []Member,
sortedMonths []string,
@@ -1,8 +1,8 @@
package reconcile
// Expected values verified against scripts/match_payments.py on 2026-05-06:
// Expected values verified against legacy/scripts/match_payments.py on 2026-05-06:
//
// PYTHONPATH=scripts:. python3 -m unittest tests.test_reconcile_exceptions tests.test_match_payments -v
// PYTHONPATH=legacy/scripts:legacy python3 -m unittest tests.test_reconcile_exceptions tests.test_match_payments -v
//
// All Python test cases are ported below. Additional Go-only cases are marked with [Go].
+2 -2
View File
@@ -1,5 +1,5 @@
// Package synch ports the bank-sync deduplication helper from
// scripts/sync_fio_to_sheets.py.
// legacy/scripts/sync_fio_to_sheets.py.
package synch
import (
@@ -30,7 +30,7 @@ type Transaction struct {
// "date|amount|currency|sender|vs|message|bank_id" (lower-cased), used
// as the dedup key in column K of the payments sheet.
//
// Byte-stable with scripts/sync_fio_to_sheets.py generate_sync_id.
// Byte-stable with legacy/scripts/sync_fio_to_sheets.py generate_sync_id.
func GenerateSyncID(tx Transaction) string {
currency := tx.Currency
if currency == "" {
+2 -2
View File
@@ -6,7 +6,7 @@ import (
// All expected digests verified against the live Python implementation on 2026-05-06:
//
// PYTHONPATH=scripts:. python -c '
// PYTHONPATH=legacy/scripts:legacy python -c '
// from sync_fio_to_sheets import generate_sync_id
// cases = [
// {"date":"2026-01-15","amount":500.0,"currency":"CZK","sender":"Jan Novak","vs":"123","message":"clenske 1/2026","bank_id":"abc123"},
@@ -88,7 +88,7 @@ func TestGenerateSyncID(t *testing.T) {
// All expected strings verified against the live Python implementation on 2026-05-06:
//
// PYTHONPATH=scripts:. python -c '
// PYTHONPATH=legacy/scripts:legacy python -c '
// for v in [0.0, 500.0, -500.0, 0.1, 1234.56, 99999.99, 1500000.0, 1e16, 1e-5]:
// print(repr(v), "->", repr(str(v)))
// '
+2 -2
View File
@@ -1,5 +1,5 @@
// Package cache implements a Drive-modifiedTime-gated JSON file cache,
// mirroring scripts/cache_utils.py.
// mirroring legacy/scripts/cache_utils.py.
package cache
import (
@@ -26,7 +26,7 @@ type cacheFile struct {
// FileCache wraps a Drive client to gate JSON file caching on sheet modifiedTime.
//
// Two TTL knobs mirror scripts/cache_utils.py:
// Two TTL knobs mirror legacy/scripts/cache_utils.py:
// - ttl: if the cache file on disk is younger than this, skip the Drive check entirely.
// - apiCheckTTL: debounces in-memory Drive API calls per sheet ID.
//
+1 -1
View File
@@ -17,7 +17,7 @@ type httpDoer interface {
}
// apiClient fetches transactions from the Fio REST API (JSON).
// Ports scripts/fio_utils.py fetch_transactions_api.
// Ports legacy/scripts/fio_utils.py fetch_transactions_api.
type apiClient struct {
token string
hc httpDoer
+1 -1
View File
@@ -15,7 +15,7 @@ import (
)
// transparentClient fetches transactions from the Fio transparent account page (HTML).
// Ports scripts/fio_utils.py FioTableParser + fetch_transactions_transparent.
// Ports legacy/scripts/fio_utils.py FioTableParser + fetch_transactions_transparent.
type transparentClient struct {
accountNum string
hc httpDoer
+33
View File
@@ -0,0 +1,33 @@
package sheets
import (
"fmt"
"strconv"
)
// CellString renders a cell read with UNFORMATTED_VALUE as text.
// Numeric cells arrive as float64; fmt.Sprint would print large ones in
// exponent form ("2.609767878e+09"), which corrupts identifiers such as VS
// or Bank ID. Mirrors get_str in legacy/scripts/match_payments.py.
func CellString(v any) string {
switch x := v.(type) {
case nil:
return ""
case string:
return x
case float64:
return strconv.FormatFloat(x, 'f', -1, 64)
}
return fmt.Sprint(v)
}
// TextCell marks s to be stored as text when written with USER_ENTERED:
// a leading apostrophe stops Sheets from parsing digits as a number (which
// would drop leading zeros). The apostrophe is not part of the stored value.
// Empty stays empty. Mirrors as_text_cell in legacy/scripts/sync_fio_to_sheets.py.
func TextCell(s string) string {
if s == "" {
return ""
}
return "'" + s
}
+36
View File
@@ -0,0 +1,36 @@
package sheets
import "testing"
func TestTextCell(t *testing.T) {
if got := TextCell("0308"); got != "'0308" {
t.Errorf(`TextCell("0308"): got %q`, got)
}
if got := TextCell(""); got != "" {
t.Errorf(`TextCell(""): got %q`, got)
}
}
func TestCellString(t *testing.T) {
cases := []struct {
name string
in any
want string
}{
{"nil", nil, ""},
{"string", "fee", "fee"},
{"string with leading zeros", "0308", "0308"},
{"small integer float", 123.0, "123"},
{"large integer float", 2609767878.0, "2609767878"},
{"eleven digit float", 26123456789.0, "26123456789"},
{"fractional float", 12.5, "12.5"},
{"bool", true, "true"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := CellString(tc.in); got != tc.want {
t.Errorf("CellString(%v): want %q, got %q", tc.in, tc.want, got)
}
})
}
}
+2 -2
View File
@@ -30,7 +30,7 @@ type sheetReadWriter interface {
// InferPayments fills empty Person/Purpose/Inferred Amount cells in the payments
// sheet using name and month matching against the member list.
// Returns the number of rows updated (or that would be updated on dry-run).
// Ports scripts/infer_payments.py infer_payments.
// Ports legacy/scripts/infer_payments.py infer_payments.
func InferPayments(
ctx context.Context,
spreadsheetID string,
@@ -94,7 +94,7 @@ func InferPayments(
if idx < 0 || idx >= len(row) {
return ""
}
return strings.TrimSpace(fmt.Sprint(row[idx]))
return strings.TrimSpace(sheets.CellString(row[idx]))
}
// Skip rule: any of manual fix / Person / Purpose non-empty → leave alone
+5 -4
View File
@@ -6,13 +6,14 @@ import (
"fmt"
"fuj-management/go/internal/domain/synch"
"fuj-management/go/internal/io/fio"
"fuj-management/go/internal/io/sheets"
"os"
"strings"
"time"
)
// columnLabels is the canonical header for the payments sheet.
// Mirrors COLUMN_LABELS in scripts/sync_fio_to_sheets.py.
// Mirrors COLUMN_LABELS in legacy/scripts/sync_fio_to_sheets.py.
var columnLabels = []string{
"Date", "Amount", "manual fix", "Person", "Purpose",
"Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID",
@@ -37,7 +38,7 @@ type SyncOpts struct {
// SyncToSheets fetches Fio transactions from all provided clients and appends
// new ones to the payments sheet. Returns the number of rows appended.
// Ports scripts/sync_fio_to_sheets.py sync_to_sheets.
// Ports legacy/scripts/sync_fio_to_sheets.py sync_to_sheets.
func SyncToSheets(
ctx context.Context,
spreadsheetID string,
@@ -130,7 +131,7 @@ func SyncToSheets(
newRows = append(newRows, []any{
tx.Date, tx.Amount,
"", "", "", "", // manual fix, Person, Purpose, Inferred Amount
tx.Sender, tx.VS, tx.Message, tx.BankID, syncIDs[i],
tx.Sender, sheets.TextCell(tx.VS), tx.Message, tx.BankID, syncIDs[i],
})
}
@@ -141,7 +142,7 @@ func SyncToSheets(
if opts.DryRun {
for _, row := range newRows {
fmt.Printf("Dry run: would append date=%v amount=%v sender=%v vs=%v message=%v\n",
row[0], row[1], row[6], row[7], row[8])
row[0], row[1], row[6], strings.TrimPrefix(fmt.Sprint(row[7]), "'"), row[8])
}
if opts.Sort {
fmt.Println("Dry run: would sort by date")
@@ -38,6 +38,10 @@ func TestSyncToSheets_EmptySheet(t *testing.T) {
if syncID, ok := rows[0][10].(string); !ok || len(syncID) != 64 {
t.Errorf("expected 64-char hex sync ID, got %v", rows[0][10])
}
// VS (index 7) is written as text so Sheets keeps it a string.
if vs, _ := rows[0][7].(string); vs != "'123" {
t.Errorf("expected VS written as text cell '123, got %v", rows[0][7])
}
}
func TestSyncToSheets_Dedup(t *testing.T) {
@@ -9,11 +9,11 @@ import (
)
// printFeesTable writes a fixed-width adult-fees table to w.
// Mirrors scripts/calculate_fees.py main().
// Mirrors legacy/scripts/calculate_fees.py main().
//
// Verify with:
//
// PYTHONPATH=scripts:. python scripts/calculate_fees.py
// PYTHONPATH=legacy/scripts:legacy python legacy/scripts/calculate_fees.py
func printFeesTable(w io.Writer, members []reconcile.Member, sortedMonths []string) {
type row struct {
name string
@@ -1,8 +1,8 @@
package membership
// Golden strings verified against scripts/calculate_fees.py on 2026-05-06:
// Golden strings verified against legacy/scripts/calculate_fees.py on 2026-05-06:
//
// PYTHONPATH=scripts:. python scripts/calculate_fees.py
// PYTHONPATH=legacy/scripts:legacy python legacy/scripts/calculate_fees.py
//
// (feed equivalent fixture data via attendance sheet or local CSV)
@@ -10,11 +10,11 @@ import (
)
// printReconcileReport writes the full balance report to w.
// Mirrors scripts/match_payments.py print_report().
// Mirrors legacy/scripts/match_payments.py print_report().
//
// Verify with:
//
// PYTHONPATH=scripts:. python -c '
// PYTHONPATH=legacy/scripts:legacy python -c '
// from match_payments import print_report, reconcile, fetch_sheet_data, fetch_exceptions
// ...'
func printReconcileReport(w io.Writer, result reconcile.Result, sortedMonths []string) {
@@ -1,8 +1,8 @@
package membership
// Golden strings verified against scripts/match_payments.py print_report() on 2026-05-06:
// Golden strings verified against legacy/scripts/match_payments.py print_report() on 2026-05-06:
//
// PYTHONPATH=scripts:. python -c '
// PYTHONPATH=legacy/scripts:legacy python -c '
// from match_payments import print_report
// result = {
// "members": {
+11 -11
View File
@@ -18,20 +18,20 @@ import (
"time"
)
// Attendance CSV column indices (mirrors COL_* in scripts/attendance.py)
// Attendance CSV column indices (mirrors COL_* in legacy/scripts/attendance.py)
const (
colName = 0
colTier = 1
firstDateCol = 3
)
// AdultMergedMonths mirrors ADULT_MERGED_MONTHS in scripts/attendance.py.
// AdultMergedMonths mirrors ADULT_MERGED_MONTHS in legacy/scripts/attendance.py.
// Source month → target month (source attendance accumulated into target).
var AdultMergedMonths = map[string]string{
"2025-09": "2025-10",
}
// JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in scripts/attendance.py.
// JuniorMergedMonths mirrors JUNIOR_MERGED_MONTHS in legacy/scripts/attendance.py.
var JuniorMergedMonths = map[string]string{
"2025-12": "2026-01",
"2025-09": "2025-10",
@@ -127,11 +127,11 @@ func (s *realSources) LoadExceptions(ctx context.Context) (map[reconcile.Excepti
}
// ---------------------------------------------------------------------------
// Attendance CSV parsing (ports scripts/attendance.py)
// Attendance CSV parsing (ports legacy/scripts/attendance.py)
// ---------------------------------------------------------------------------
// parseDates returns (columnIndex, YYYY-MM) pairs for all date columns.
// Ports scripts/attendance.py parse_dates + strftime("%Y-%m").
// Ports legacy/scripts/attendance.py parse_dates + strftime("%Y-%m").
func parseDates(header []string) []struct {
col int
month string
@@ -200,7 +200,7 @@ func countTrue(row []string, cols []int) int {
// parseAdultRows converts raw CSV rows to []reconcile.Member.
// Includes all tiers; fee is 0 for non-A tiers (reconcile filters downstream).
// Ports scripts/attendance.py get_members_with_fees.
// Ports legacy/scripts/attendance.py get_members_with_fees.
func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
if len(rows) < 2 {
return nil, nil, nil
@@ -246,7 +246,7 @@ func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
// parseJuniorRows builds junior members by merging tier-J rows from the adult tab
// with the junior sheet, then calling CalculateJuniorFee.
// Ports scripts/attendance.py get_junior_members_with_fees.
// Ports legacy/scripts/attendance.py get_junior_members_with_fees.
func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []string, error) {
if len(adultRows) < 2 || len(juniorRows) < 2 {
return nil, nil, nil
@@ -362,7 +362,7 @@ func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []st
}
// ---------------------------------------------------------------------------
// Payments sheet row parsing (ports scripts/match_payments.py fetch_sheet_data)
// Payments sheet row parsing (ports legacy/scripts/match_payments.py fetch_sheet_data)
// ---------------------------------------------------------------------------
func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
@@ -402,7 +402,7 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
if i < 0 || i >= len(row) {
return ""
}
return fmt.Sprint(row[i])
return sheets.CellString(row[i])
}
// getRaw returns row[i] without stringifying — needed for FormatDate to
@@ -425,7 +425,7 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
amount := parseFloat(amountRaw)
var inferredAmount *float64
if iv := getVal(row, idxInferred); iv != "" && iv != "<nil>" {
if iv := getVal(row, idxInferred); iv != "" {
if f := parseFloat(iv); f != 0 {
inferredAmount = &f
}
@@ -466,7 +466,7 @@ func parseFloat(v any) float64 {
}
// ---------------------------------------------------------------------------
// Exceptions tab parsing (ports scripts/match_payments.py fetch_exceptions)
// Exceptions tab parsing (ports legacy/scripts/match_payments.py fetch_exceptions)
// ---------------------------------------------------------------------------
func parseExceptionRows(rows [][]any) map[reconcile.ExceptionKey]reconcile.Exception {
@@ -123,6 +123,8 @@ func TestLoadTransactions(t *testing.T) {
{"Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID"},
{"2026-04-01", 700.0, "", "Alice", "2026-04", "", "Alice Bank", "", "fee", "", "abc"},
{46147.0, 500.0, "", "", "", "", "Bob Bank", "", "platba", "", "def"}, // 46147 serial-day = 2026-05-05
// Numeric VS / Bank ID cells arrive as float64 and must stay plain digits.
{"2026-05-06", 500.0, "", "", "", "", "Carol Bank", 2609767878.0, "platba", 26123456789.0, "ghi"},
},
}}
s := buildSources(t, &attendance.Fake{}, sh)
@@ -131,8 +133,8 @@ func TestLoadTransactions(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(txns) != 2 {
t.Fatalf("want 2 transactions, got %d", len(txns))
if len(txns) != 3 {
t.Fatalf("want 3 transactions, got %d", len(txns))
}
if txns[0].Person != "Alice" {
t.Errorf("txn[0].Person: want Alice, got %q", txns[0].Person)
@@ -146,6 +148,12 @@ func TestLoadTransactions(t *testing.T) {
if txns[1].Date != "2026-05-05" {
t.Errorf("txn[1].Date (numeric serial-day): want 2026-05-05, got %q", txns[1].Date)
}
if txns[2].VS != "2609767878" {
t.Errorf("txn[2].VS (numeric cell): want 2609767878, got %q", txns[2].VS)
}
if txns[2].BankID != "26123456789" {
t.Errorf("txn[2].BankID (numeric cell): want 26123456789, got %q", txns[2].BankID)
}
}
func TestLoadExceptions(t *testing.T) {
+1
View File
@@ -39,4 +39,5 @@ type AdultsResponse struct {
PaymentsURL string `json:"payments_url"`
BankAccount string `json:"bank_account"`
CurrentMonth string `json:"current_month"`
MonthsToShow int `json:"months_to_show"`
}
+2 -1
View File
@@ -13,7 +13,7 @@ import (
type monthSums struct{ expected, paid int }
// buildAdultsResponse constructs the AdultsResponse wire type from reconcile output.
// Mirrors scripts/views.py:build_adults_view_model.
// Mirrors legacy/scripts/views.py:build_adults_view_model.
func buildAdultsResponse(
members []domreconcile.Member,
sortedMonths []string,
@@ -140,6 +140,7 @@ func buildAdultsResponse(
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
BankAccount: cfg.QRAccount,
CurrentMonth: currentMonth,
MonthsToShow: cfg.MonthsToShow,
}
}
+3 -3
View File
@@ -12,7 +12,7 @@ import (
// getMonthLabels builds display labels for sortedMonths, merging month names
// (e.g. "Dec+Jan 2026") when mergedMonths maps a source month into this target.
// Mirrors scripts/views.py:get_month_labels.
// Mirrors legacy/scripts/views.py:get_month_labels.
func getMonthLabels(sortedMonths []string, mergedMonths map[string]string) map[string]string {
labels := make(map[string]string, len(sortedMonths))
for _, m := range sortedMonths {
@@ -72,14 +72,14 @@ func labelsForMonths(sortedMonths []string, labels map[string]string) []string {
var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
// canonicalKey returns a normalized form of a person name used for deduplication.
// Mirrors scripts/match_payments.py:canonical_member_key.
// Mirrors legacy/scripts/match_payments.py:canonical_member_key.
func canonicalKey(name string) string {
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
}
// groupRawPaymentsByPerson groups transactions by the "person" column,
// canonicalizing names against memberNames where possible.
// Mirrors scripts/views.py:group_payments_by_person (without the
// Mirrors legacy/scripts/views.py:group_payments_by_person (without the
// "Unmatched / Unknown" bucket that is payments-view-specific).
func groupRawPaymentsByPerson(txns []domreconcile.Transaction, memberNames []string) map[string][]RawTransaction {
canonicalByKey := make(map[string]string, len(memberNames))
+2 -1
View File
@@ -11,7 +11,7 @@ import (
)
// buildJuniorsResponse constructs the JuniorsResponse wire type from reconcile output.
// Mirrors scripts/views.py:build_juniors_view_model.
// Mirrors legacy/scripts/views.py:build_juniors_view_model.
func buildJuniorsResponse(
members []domreconcile.Member,
sortedMonths []string,
@@ -136,6 +136,7 @@ func buildJuniorsResponse(
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
BankAccount: cfg.QRAccount,
CurrentMonth: currentMonth,
MonthsToShow: cfg.MonthsToShow,
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
)
// buildPaymentsResponse constructs the PaymentsResponse wire type.
// Mirrors scripts/views.py:build_payments_view_model.
// Mirrors legacy/scripts/views.py:build_payments_view_model.
func buildPaymentsResponse(
txns []domreconcile.Transaction,
memberNames []string,
+1
View File
@@ -38,4 +38,5 @@ type JuniorsResponse struct {
PaymentsURL string `json:"payments_url"`
BankAccount string `json:"bank_account"`
CurrentMonth string `json:"current_month"`
MonthsToShow int `json:"months_to_show"`
}
+1 -1
View File
@@ -1,7 +1,7 @@
package api
// VersionResponse is the JSON contract for GET /api/version.
// Keys match Python's BUILD_META dict (see app.py).
// Keys match Python's BUILD_META dict (see legacy/app.py).
type VersionResponse struct {
Tag string `json:"tag"`
Commit string `json:"commit"`
+8 -1
View File
@@ -12,7 +12,8 @@
const container = document.getElementById('filterContainer');
if (!container) return;
const currentMonth = container.dataset.currentMonth || '';
const currentMonth = container.dataset.currentMonth || '';
const monthsToShow = parseInt(container.dataset.monthsToShow || '0', 10);
const nameInput = document.getElementById('nameFilter');
const fromSelect = document.getElementById('fromMonth');
@@ -88,4 +89,10 @@
// ── Initialise ────────────────────────────────────────────────────────────
hideFutureMonths();
// Default the from-select to show only the last N months.
if (monthsToShow > 0 && toSelect.value !== '') {
const defaultFrom = Math.max(0, parseInt(toSelect.value, 10) - monthsToShow + 1);
fromSelect.value = String(defaultFrom);
applyMonthFilter();
}
}());
+1 -1
View File
@@ -12,7 +12,7 @@
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
</div>
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="adults" data-bank-account="{{.Data.BankAccount}}">
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="adults" data-bank-account="{{.Data.BankAccount}}" data-months-to-show="{{.Data.MonthsToShow}}">
<div class="filter-item">
<label class="filter-label" for="nameFilter">Member</label>
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
+1 -1
View File
@@ -12,7 +12,7 @@
<a href="{{.Data.PaymentsURL}}" target="_blank" rel="noopener">Payments Ledger</a>
</div>
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="juniors" data-bank-account="{{.Data.BankAccount}}">
<div class="filter-container" id="filterContainer" data-current-month="{{.Data.CurrentMonth}}" data-page="juniors" data-bank-account="{{.Data.BankAccount}}" data-months-to-show="{{.Data.MonthsToShow}}">
<div class="filter-item">
<label class="filter-label" for="nameFilter">Member</label>
<input id="nameFilter" class="filter-input" type="text" placeholder="Filter by name…">
+3 -3
View File
@@ -85,10 +85,10 @@ git diff go/tests/fixtures/ # review changes before committing
To refresh a single function:
```bash
PYTHONPATH=scripts:. python3 scripts/capture_fixtures.py --func normalize --all \
PYTHONPATH=legacy/scripts:legacy python3 legacy/scripts/capture_fixtures.py --func normalize --all \
| while IFS= read -r line; do
id=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin)['case'])")
echo "$line" | python3 scripts/scrub_fixtures.py \
echo "$line" | python3 legacy/scripts/scrub_fixtures.py \
> go/tests/fixtures/pure/normalize/${id}.json
done
```
@@ -120,7 +120,7 @@ verify that no real roster names appear in their `member_names` arrays.
## Adding a new fixture
1. Add a seed to `scripts/_fixture_seeds.py` under `SEEDS[("func_name", "case_id")]`.
1. Add a seed to `legacy/scripts/_fixture_seeds.py` under `SEEDS[("func_name", "case_id")]`.
2. Add `In`/`Out` struct fields to `go/tests/parity/parityio.go` if the function
is new.
3. Run the single-file capture recipe above and review the diff.
+5 -1
View File
@@ -143,6 +143,9 @@
},
"current_month": {
"type": "string"
},
"months_to_show": {
"type": "integer"
}
},
"additionalProperties": false,
@@ -161,7 +164,8 @@
"attendance_url",
"payments_url",
"bank_account",
"current_month"
"current_month",
"months_to_show"
]
},
"Credit": {
+5 -1
View File
@@ -187,6 +187,9 @@
},
"current_month": {
"type": "string"
},
"months_to_show": {
"type": "integer"
}
},
"additionalProperties": false,
@@ -205,7 +208,8 @@
"attendance_url",
"payments_url",
"bank_account",
"current_month"
"current_month",
"months_to_show"
]
},
"MemberOtherEntry": {
@@ -13,7 +13,7 @@ import (
// Verify expected values against live Python:
//
// PYTHONPATH=scripts:. python3 -c "from match_payments import _build_name_variants; print(_build_name_variants('František Vrbík (Štrúdl)'))"
// PYTHONPATH=legacy/scripts:legacy python3 -c "from match_payments import _build_name_variants; print(_build_name_variants('František Vrbík (Štrúdl)'))"
func TestBuildNameVariantsParity(t *testing.T) {
t.Parallel()
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python:
//
// PYTHONPATH=scripts:. python3 -c "from attendance import calculate_fee; print(calculate_fee(2, '2026-01'))"
// PYTHONPATH=legacy/scripts:legacy python3 -c "from attendance import calculate_fee; print(calculate_fee(2, '2026-01'))"
func TestCalculateFeeParity(t *testing.T) {
t.Parallel()
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python:
//
// PYTHONPATH=scripts:. python3 -c "from attendance import calculate_junior_fee; print(calculate_junior_fee(1, '2026-01'))"
// PYTHONPATH=legacy/scripts:legacy python3 -c "from attendance import calculate_junior_fee; print(calculate_junior_fee(1, '2026-01'))"
func TestCalculateJuniorFeeParity(t *testing.T) {
t.Parallel()
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python:
//
// PYTHONPATH=scripts:. python3 -c "from match_payments import format_date; print(format_date(46027))"
// PYTHONPATH=legacy/scripts:legacy python3 -c "from match_payments import format_date; print(format_date(46027))"
func TestFormatDateParity(t *testing.T) {
t.Parallel()
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python:
//
// PYTHONPATH=scripts:. python3 -c "
// PYTHONPATH=legacy/scripts:legacy python3 -c "
// from sync_fio_to_sheets import generate_sync_id
// print(generate_sync_id({'date':'2026-01-15','amount':750.0,'currency':'CZK','sender':'Test','vs':'123','message':'x','bank_id':'1'}))"
//
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python:
//
// PYTHONPATH=scripts:. python3 -c "
// PYTHONPATH=legacy/scripts:legacy python3 -c "
// from match_payments import infer_transaction_details
// print(infer_transaction_details({'sender':'Henrietta Ottová','message':'leden 2026','user_id':'','date':'2026-01-15'}, ['Henrietta Ottová']))"
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python:
//
// PYTHONPATH=scripts:. python3 -c "from match_payments import match_members; print(match_members('henrietta ottova leden', ['Henrietta Ottová','Jan Novák']))"
// PYTHONPATH=legacy/scripts:legacy python3 -c "from match_payments import match_members; print(match_members('henrietta ottova leden', ['Henrietta Ottová','Jan Novák']))"
func TestMatchMembersParity(t *testing.T) {
t.Parallel()
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python:
//
// PYTHONPATH=scripts:. python3 -c "from czech_utils import normalize; print(normalize('štefan čakrtový'))"
// PYTHONPATH=legacy/scripts:legacy python3 -c "from czech_utils import normalize; print(normalize('štefan čakrtový'))"
func TestNormalizeParity(t *testing.T) {
t.Parallel()
@@ -12,7 +12,7 @@ import (
// Verify expected values against live Python:
//
// PYTHONPATH=scripts:. python3 -c "from infer_payments import parse_czk_amount; print(parse_czk_amount('1.500,00'))"
// PYTHONPATH=legacy/scripts:legacy python3 -c "from infer_payments import parse_czk_amount; print(parse_czk_amount('1.500,00'))"
//
// Note: Go's ParseCZK returns an error for unparseable input; Python returns 0.0.
// Callers should discard the error to match Python semantics: v, _ := money.ParseCZK(s)
@@ -13,7 +13,7 @@ import (
// Verify expected values against live Python:
//
// PYTHONPATH=scripts:. python3 -c "from czech_utils import parse_month_references; print(parse_month_references('prosinec-leden', 2026))"
// PYTHONPATH=legacy/scripts:legacy python3 -c "from czech_utils import parse_month_references; print(parse_month_references('prosinec-leden', 2026))"
func TestParseMonthReferencesParity(t *testing.T) {
t.Parallel()
@@ -6,7 +6,7 @@
//
// Verify expected values against live Python:
//
// PYTHONPATH=scripts:. python3 -c "
// PYTHONPATH=legacy/scripts:legacy python3 -c "
// from match_payments import reconcile
// # ... build members/months/txs/exceptions...
// import json; print(json.dumps(reconcile(...)))"
+2 -2
View File
@@ -1,6 +1,6 @@
.git
.venv
__pycache__
*.pyc
**/__pycache__
**/*.pyc
.claude
.gemini
+9 -5
View File
@@ -19,7 +19,7 @@ sys.path.append(str(scripts_dir))
from config import (
ATTENDANCE_SHEET_ID, PAYMENTS_SHEET_ID, JUNIOR_SHEET_GID,
BANK_ACCOUNT, CREDENTIALS_PATH,
BANK_ACCOUNT, CREDENTIALS_PATH, MONTHS_TO_SHOW,
)
from attendance import get_members_with_fees, get_junior_members_with_fees
from match_payments import reconcile, fetch_sheet_data, fetch_exceptions
@@ -179,7 +179,9 @@ def api_adults():
datetime.now().strftime("%Y-%m"),
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
)
return jsonify(_unwrap_view_model_for_api(vm))
out = _unwrap_view_model_for_api(vm)
out["months_to_show"] = MONTHS_TO_SHOW
return jsonify(out)
@app.route("/api/juniors")
def api_juniors():
@@ -203,7 +205,9 @@ def api_juniors():
datetime.now().strftime("%Y-%m"),
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
)
return jsonify(_unwrap_view_model_for_api(vm))
out = _unwrap_view_model_for_api(vm)
out["months_to_show"] = MONTHS_TO_SHOW
return jsonify(out)
@app.route("/api/payments")
def api_payments():
@@ -255,7 +259,7 @@ def adults_view():
bank_account=BANK_ACCOUNT,
)
record_step("process_data")
return render_template("adults.html", **vm)
return render_template("adults.html", months_to_show=MONTHS_TO_SHOW, **vm)
@app.route("/juniors")
def juniors_view():
@@ -291,7 +295,7 @@ def juniors_view():
bank_account=BANK_ACCOUNT,
)
record_step("process_data")
return render_template("juniors.html", **vm)
return render_template("juniors.html", months_to_show=MONTHS_TO_SHOW, **vm)
@app.route("/payments")
def payments():
+1 -1
View File
@@ -17,7 +17,7 @@ RUN pip install --no-cache-dir \
pillow \
gunicorn
COPY app.py Makefile ./
COPY app.py ./
COPY scripts/ ./scripts/
COPY templates/ ./templates/
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "fuj-management"
version = "0.10"
description = "Management tools for FUJ (Frisbee Ultimate Jablonec)"
description = "Legacy Python implementation of the FUJ (Frisbee Ultimate Jablonec) management tools; the Go implementation in ../go is primary"
dependencies = [
"flask>=3.1.3",
"google-api-python-client>=2.162.0",
@@ -1,14 +1,15 @@
#!/usr/bin/env bash
# Regenerate the full fixture corpus.
# Safe to re-run — always overwrites.
# Requires: tmp/*_cache.json present (for real-data seeds for parse_month_references and match_members).
# Requires: legacy/tmp/*_cache.json present (for real-data seeds for parse_month_references and match_members).
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LEGACY="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO="$(cd "$LEGACY/.." && pwd)"
FIXTURES="$REPO/go/tests/fixtures"
CAPTURE_CMD="PYTHONPATH=$REPO/scripts:. python3 $REPO/scripts/capture_fixtures.py"
SCRUB_CMD="python3 $REPO/scripts/scrub_fixtures.py"
CAPTURE_CMD="PYTHONPATH=$LEGACY/scripts:$LEGACY python3 $LEGACY/scripts/capture_fixtures.py"
SCRUB_CMD="python3 $LEGACY/scripts/scrub_fixtures.py"
run_func() {
local func="$1"
@@ -18,7 +19,7 @@ run_func() {
eval "$CAPTURE_CMD --func $func --all" | while IFS= read -r line; do
case_id="$(python3 -c "import sys,json; print(json.loads('''$line''')['case'])" 2>/dev/null || \
echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['case'])")"
echo "$line" | python3 "$REPO/scripts/scrub_fixtures.py" > "$dir/${case_id}.json"
echo "$line" | python3 "$LEGACY/scripts/scrub_fixtures.py" > "$dir/${case_id}.json"
done
}
@@ -39,7 +40,7 @@ echo "==> Capturing reconcile fixtures..."
mkdir -p "$FIXTURES/reconcile"
eval "$CAPTURE_CMD --func reconcile --all" | while IFS= read -r line; do
case_id="$(echo "$line" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['case'])")"
echo "$line" | python3 "$REPO/scripts/scrub_fixtures.py" > "$FIXTURES/reconcile/${case_id}.json"
echo "$line" | python3 "$LEGACY/scripts/scrub_fixtures.py" > "$FIXTURES/reconcile/${case_id}.json"
done
echo "==> Done. Review with: git diff go/tests/fixtures/"
@@ -40,6 +40,9 @@ 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
# Display settings
MONTHS_TO_SHOW = int(os.environ.get("MONTHS_TO_SHOW", 5)) # show last N months; 0 = show all
# Maps cache keys to their source sheet IDs (used by cache_utils)
CACHE_SHEET_MAP = {
"attendance_regular": ATTENDANCE_SHEET_ID,
@@ -26,6 +26,15 @@ def canonical_member_key(name: str) -> str:
return re.sub(r"\s+", " ", normalize(name)).strip()
def _expected_amount(value) -> float:
"""Numeric value of an expected fee; non-numeric markers like '?' → 0.
Juniors with exactly 1 session get expected='?' (manual-review marker).
Treat those as 0 for arithmetic so payments become surplus/credit.
"""
return value if isinstance(value, (int, float)) else 0
# ---------------------------------------------------------------------------
# Name matching
# ---------------------------------------------------------------------------
@@ -466,7 +475,7 @@ def reconcile(
if not in_window:
continue
total_expected = sum(e for _, e in in_window)
total_expected = sum(_expected_amount(e) for _, e in in_window)
if total_expected > 0:
# Fill-first: iterate in_window in matched_months order (chronological by
@@ -477,7 +486,7 @@ def reconcile(
remaining = in_window_share
for m, exp in in_window:
paid_so_far = ledger[member_name][m]["paid"]
deficit = max(0.0, float(exp) - paid_so_far)
deficit = max(0.0, float(_expected_amount(exp)) - paid_so_far)
alloc = min(remaining, deficit)
if alloc <= 0:
continue
@@ -509,7 +518,7 @@ def reconcile(
final_balances: dict[str, int] = {}
for name in member_names:
window_balance = sum(
int(mdata["paid"]) - (mdata["expected"] if isinstance(mdata["expected"], int) else 0)
int(mdata["paid"]) - _expected_amount(mdata["expected"])
for mdata in ledger[name].values()
)
final_balances[name] = window_balance + credits.get(name, 0)
@@ -59,6 +59,16 @@ def get_sheets_service(credentials_path: str):
return _SHEETS_SERVICE
def as_text_cell(value) -> str:
"""Mark a value to be stored as text when written with USER_ENTERED.
A leading apostrophe stops Sheets from parsing digits as a number (which
would drop leading zeros); it is not part of the stored value. Empty stays empty.
"""
s = "" if value is None else str(value)
return f"'{s}" if s else ""
def generate_sync_id(tx: dict) -> str:
"""Generate a unique SHA-256 hash for a transaction.
@@ -209,7 +219,7 @@ def sync_to_sheets(
"", # Purpose
"", # Inferred Amount
tx.get("sender", ""),
tx.get("vs", ""),
as_text_cell(tx.get("vs", "")),
tx.get("message", ""),
tx.get("bank_id", ""),
sync_id,
@@ -1045,6 +1045,7 @@
});
toSelect.value = maxMonthIdx;
fromSelect.value = Math.max(0, maxMonthIdx - {{ months_to_show }} + 1);
applyMonthFilter();
})();
</script>
@@ -1026,6 +1026,7 @@
});
toSelect.value = maxMonthIdx;
fromSelect.value = Math.max(0, maxMonthIdx - {{ months_to_show }} + 1);
applyMonthFilter();
})();
</script>
@@ -6,7 +6,7 @@ from app import app
EXPECTED_ADULTS_KEYS = {
"months", "raw_months", "results", "totals", "member_data", "month_labels",
"raw_payments", "credits", "debts", "unmatched", "attendance_url",
"payments_url", "bank_account", "current_month",
"payments_url", "bank_account", "current_month", "months_to_show",
}
EXPECTED_JUNIORS_KEYS = EXPECTED_ADULTS_KEYS
EXPECTED_PAYMENTS_KEYS = {"grouped_payments", "sorted_people", "attendance_url", "payments_url"}
@@ -139,6 +139,7 @@ class TestWebApp(unittest.TestCase):
self.assertIsInstance(data['member_data'], dict)
self.assertIsInstance(data['month_labels'], dict)
self.assertIsInstance(data['raw_payments'], dict)
self.assertIsInstance(data['months_to_show'], int)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.fetch_sheet_data')
@@ -166,6 +167,7 @@ class TestWebApp(unittest.TestCase):
self.assertIsInstance(data['member_data'], dict)
self.assertIsInstance(data['month_labels'], dict)
self.assertIsInstance(data['raw_payments'], dict)
self.assertIsInstance(data['months_to_show'], int)
@patch('app.get_cached_data', side_effect=_bypass_cache)
@patch('app.fetch_sheet_data')

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