Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62c6dc2ca0 | ||
|
|
23633f8cb2 | ||
|
|
2d8aada8d6 | ||
|
|
14cdbeac21 | ||
|
|
5b162d0030 | ||
|
|
cfdc5cbb2a | ||
|
|
e2ae5a7ec4 | ||
|
|
82406935ed | ||
|
|
e7c8101c3b | ||
|
|
97f94afa02 |
@@ -1,111 +0,0 @@
|
||||
name: Deploy to K8s
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Debug - print Gitea Actions environment
|
||||
run: |
|
||||
echo "=== All environment variables ==="
|
||||
env | sort
|
||||
echo ""
|
||||
echo "=== GITHUB_* / GITEA_* / ACTIONS_* vars ==="
|
||||
env | grep -E '^(GITHUB|GITEA|ACTIONS)_' | sort
|
||||
|
||||
- name: Install kubectl
|
||||
run: |
|
||||
curl -sfLO "https://dl.k8s.io/release/$(curl -sfL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
|
||||
install kubectl /usr/local/bin/
|
||||
|
||||
- name: Get Kanidm token from Vault
|
||||
id: vault
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
|
||||
VAULT_AUTH_RESPONSE=$(curl -f --request POST \
|
||||
--data '{"role_id":"${{ secrets.VAULT_ROLE_ID }}","secret_id":"${{ secrets.VAULT_SECRET_ID }}"}' \
|
||||
https://vault.hrajfrisbee.cz/v1/auth/approle/login)
|
||||
|
||||
echo "Vault auth response: $VAULT_AUTH_RESPONSE" >&2
|
||||
VAULT_TOKEN=$(echo "$VAULT_AUTH_RESPONSE" | jq -r '.auth.client_token')
|
||||
|
||||
# Read the kanidm API token
|
||||
SECRET_RESPONSE=$(curl -f \
|
||||
-H "X-Vault-Token: ${VAULT_TOKEN}" \
|
||||
https://vault.hrajfrisbee.cz/v1/secret/data/gitea/gitea-ci)
|
||||
|
||||
echo "Secret response: $SECRET_RESPONSE" >&2
|
||||
API_TOKEN=$(echo "$SECRET_RESPONSE" | jq -r '.data.data.token')
|
||||
|
||||
echo "::add-mask::${API_TOKEN}"
|
||||
echo "api_token=${API_TOKEN}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Exchange for K8s OIDC token via Kanidm
|
||||
id: k8s
|
||||
run: |
|
||||
API_TOKEN="${{ steps.vault.outputs.api_token }}"
|
||||
echo "api_token length: ${#API_TOKEN}" >&2
|
||||
echo "api_token prefix (first 8 chars): ${API_TOKEN:0:8}..." >&2
|
||||
|
||||
HTTP_BODY=$(mktemp)
|
||||
HTTP_STATUS=$(curl -sS -X POST "https://idm.home.hrajfrisbee.cz/oauth2/token" \
|
||||
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
|
||||
-d "client_id=k8s" \
|
||||
-d "subject_token=${API_TOKEN}" \
|
||||
-d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
|
||||
-d "audience=k8s" \
|
||||
-d "scope=openid groups" \
|
||||
-o "$HTTP_BODY" -w "%{http_code}")
|
||||
|
||||
echo "HTTP status: $HTTP_STATUS" >&2
|
||||
echo "Response body:" >&2
|
||||
cat "$HTTP_BODY" >&2
|
||||
|
||||
RESPONSE=$(cat "$HTTP_BODY")
|
||||
ID_TOKEN=$(echo "$RESPONSE" | jq -r '.id_token // empty')
|
||||
|
||||
if [ -z "$ID_TOKEN" ]; then
|
||||
echo "::error::Kanidm token exchange failed (HTTP $HTTP_STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::add-mask::${ID_TOKEN}"
|
||||
echo "id_token=${ID_TOKEN}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Sanity check
|
||||
# Warning - this is the part that is failing
|
||||
# echo "$ID_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{sub, groups, exp}'
|
||||
|
||||
|
||||
- name: Debug - print environment before kubectl
|
||||
run: env | sort
|
||||
|
||||
- name: Configure kubectl & deploy
|
||||
run: |
|
||||
echo "${{ secrets.K8S_CA_CERT }}" > /tmp/ca.crt
|
||||
|
||||
kubectl config set-cluster mycluster \
|
||||
--server=https://192.168.0.31:6443 \
|
||||
--insecure-skip-tls-verify=true
|
||||
# --certificate-authority=/tmp/ca.crt \
|
||||
|
||||
kubectl config set-credentials gitea-ci \
|
||||
--token="${{ steps.k8s.outputs.id_token }}"
|
||||
|
||||
kubectl config set-context gitea-ci \
|
||||
--cluster=mycluster --user=gitea-ci
|
||||
|
||||
kubectl config use-context gitea-ci
|
||||
|
||||
kubectl auth whoami
|
||||
kubectl get ns
|
||||
|
||||
# your deploy here
|
||||
# kubectl apply -f k8s/
|
||||
@@ -1,5 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-10-08 12:04 CEST — fix(sync): dashboard sync covers the current year plus Nov–Dec of the previous one
|
||||
|
||||
- `/sync-bank` now uses one shared default window in both implementations: 1 November of the previous year to 31 December of the current year. Payments dated around New Year are picked up in January; duplicates are still filtered by Sync ID.
|
||||
- Python previously used a hardcoded `2026-01-01`..`2026-12-31` and would have stopped syncing on 2027-01-01; Go used the current calendar year only.
|
||||
- Added `make sync-year [YEAR=N]` with the same window; `make sync-2026` is kept. The CLI default without flags (last 30 days) is unchanged.
|
||||
- Key files: `go/internal/services/banksync/sync.go` (`DefaultWindow`), `go/cmd/fuj/main.go`, `legacy/scripts/sync_fio_to_sheets.py` (`default_sync_window`), `legacy/app.py`.
|
||||
|
||||
## 2026-10-08 11:53 CEST — feat(fees): adult rates for 2026-07 through 2026-12, default 600
|
||||
|
||||
- Adult default monthly rate (months not in the table): 700 → 600 CZK.
|
||||
- 2026-07 and 2026-08: 600 → 0 CZK; 2026-09 through 2026-12 added at 600 CZK (September and October were previously billed at the old 700 default).
|
||||
- The single-session fee (200 CZK) is now capped at the month's rate, so a 0-rate month is free for one session too.
|
||||
- Mirrored in `go/internal/domain/fees/fees.go` and `legacy/scripts/attendance.py`; three new parity fixtures for `calculate_fee`.
|
||||
|
||||
## 2026-10-08 11:48 CEST — fix(ci): remove kubernetes-deploy workflow that leaked secrets to logs
|
||||
|
||||
- Deleted `.gitea/workflows/kubernetes-deploy.yaml`. It ran on every push to every branch, deployed nothing, and echoed the Vault login response, the Kanidm API token response and the OIDC token response to the job log.
|
||||
- Deployment is handled by `gitops-update.yaml`. Repo secrets `VAULT_ROLE_ID`, `VAULT_SECRET_ID` and `K8S_CA_CERT` are now unused.
|
||||
- Follow-up outside the repo: rotate the Vault AppRole secret and the Kanidm API token, and clear old job logs.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -64,7 +64,7 @@ make web-debug # Same with FLASK_DEBUG=1
|
||||
make test # Run all tests (unittest discover)
|
||||
make test-v # Tests with verbose output
|
||||
make fees # Print fee report from attendance sheet
|
||||
make sync-2026 # Sync Fio bank transactions for 2026 to Google Sheets
|
||||
make sync-year # Sync Fio transactions for the current year plus Nov-Dec of the previous one (YEAR=N to override)
|
||||
make infer # Auto-fill Person/Purpose/Amount columns in payments sheet
|
||||
make reconcile # Print balance report from Google Sheets data
|
||||
make image # Build the legacy Python Docker image
|
||||
@@ -126,13 +126,13 @@ Fio Bank API ──► bank sync ──► Google Sheets (payments
|
||||
### Member tiers
|
||||
|
||||
Tiers are set in column B of the attendance sheet:
|
||||
- `A` — Adult, pays fees (per-month rate from `AdultFeeMonthlyRate` / `ADULT_FEE_MONTHLY_RATE`, fallback 700 CZK for 2+ sessions; 200 CZK for exactly 1)
|
||||
- `A` — Adult, pays fees (per-month rate from `AdultFeeMonthlyRate` / `ADULT_FEE_MONTHLY_RATE`, fallback 600 CZK for 2+ sessions; 200 CZK for exactly 1, capped at the month's rate)
|
||||
- `J` — Junior attending adult practices; their attendance is merged with the junior sheet
|
||||
- `X` — Excluded from junior fee calculation (coaches, etc.)
|
||||
|
||||
### Fee calculation
|
||||
|
||||
- Adults: 0 sessions → 0, 1 session → 200 CZK, 2+ sessions → monthly rate (default 700 CZK)
|
||||
- Adults: 0 sessions → 0, 1 session → 200 CZK (never more than the month's rate, so a 0-rate month is free), 2+ sessions → monthly rate (default 600 CZK)
|
||||
- Juniors: 0 → 0, 1 → `"?"` (manual review required), 2+ → monthly rate (default 500 CZK)
|
||||
- Per-member per-month overrides live in the `exceptions` tab of the payments sheet (columns: Name, Period YYYY-MM, Amount, Note). Exceptions are keyed by `(normalize(name), normalize(period))`.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.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
|
||||
.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 sync-year test test-v docs
|
||||
|
||||
# Legacy Python implementation lives in legacy/ (Go in go/ is primary).
|
||||
LEGACY := legacy
|
||||
@@ -39,6 +39,7 @@ help:
|
||||
@echo " make sync - Sync Fio transactions to Google Sheets"
|
||||
@echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)"
|
||||
@echo " make sync-2026 - Sync Fio transactions for the whole year of 2026"
|
||||
@echo " make sync-year [YEAR=N] - Sync Fio transactions for YEAR plus Nov-Dec of the year before (default: current year)"
|
||||
@echo " make sync-debug [DAYS=N] - Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)"
|
||||
@echo " make infer - Infer payment details (Person, Purpose, Amount) in the sheet"
|
||||
@echo " make reconcile - Show balance report using Google Sheets data"
|
||||
@@ -130,6 +131,10 @@ sync-2025: $(PYTHON)
|
||||
sync-2026: $(PYTHON)
|
||||
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from 2026-01-01 --to 2026-12-31 --sort-by-date
|
||||
|
||||
YEAR ?= $(shell date +%Y)
|
||||
sync-year: $(PYTHON)
|
||||
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --from $(shell expr $(YEAR) - 1)-11-01 --to $(YEAR)-12-31 --sort-by-date
|
||||
|
||||
sync-debug: $(PYTHON) ## Dry-run Python sync with Fio diagnostics and txn table (default DAYS=30)
|
||||
$(PYTHON) $(LEGACY)/scripts/sync_fio_to_sheets.py --credentials $(CREDENTIALS) --days $(DAYS) --dry-run --print-fio-table
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ fuj-management/
|
||||
│
|
||||
├── .gitea/workflows/
|
||||
│ ├── build.yaml # CI: build + push Docker image
|
||||
│ └── kubernetes-deploy.yaml # CD: deploy to K8s cluster
|
||||
│ └── gitops-update.yaml # CD: open image-update PR in home-kubernetes
|
||||
│
|
||||
├── .secret/ # (gitignored) API credentials
|
||||
├── docs/ # Project documentation
|
||||
|
||||
@@ -129,32 +129,27 @@ The project uses two Gitea Actions workflows:
|
||||
|
||||
**Tag resolution**: Uses the git tag name. For manual dispatch, uses the provided input.
|
||||
|
||||
#### 2. Deploy to Kubernetes (`kubernetes-deploy.yaml`)
|
||||
#### 2. GitOps image update (`gitops-update.yaml`)
|
||||
|
||||
**Triggers**:
|
||||
- Push to any branch
|
||||
- Manual dispatch
|
||||
- Completion of a successful "Build and Push" run
|
||||
- Manual dispatch (with `tag`, `dry_run` and `uh_cli_version` inputs)
|
||||
|
||||
**Steps**:
|
||||
1. Checkout code
|
||||
2. Install kubectl
|
||||
3. Retrieve Kanidm token from HashiCorp Vault:
|
||||
- Authenticate to Vault via AppRole (`VAULT_ROLE_ID` / `VAULT_SECRET_ID`)
|
||||
- Fetch API token from `secret/data/gitea/gitea-ci`
|
||||
4. Exchange API token for K8s OIDC token via Kanidm:
|
||||
- POST to `https://idm.home.hrajfrisbee.cz/oauth2/token`
|
||||
- Token exchange using `urn:ietf:params:oauth:grant-type:token-exchange`
|
||||
5. Configure kubectl with the OIDC token
|
||||
6. Run `kubectl auth whoami` and `kubectl get ns` (deploy commands are commented out — WIP)
|
||||
1. Install `tea` and `uh-cli`
|
||||
2. Resolve the image tag of the Go image that was just built
|
||||
3. Open a PR in `kacerr/home-kubernetes` bumping the `fuj-management` Deployment (namespace `fuj`) to that image
|
||||
|
||||
The cluster picks the change up once that PR is merged. CI does not talk to the cluster directly.
|
||||
|
||||
> The earlier `kubernetes-deploy.yaml` workflow (Vault → Kanidm → `kubectl`) was removed on 2026-10-08. It never deployed anything and printed credentials to the job log.
|
||||
|
||||
**Required secrets**:
|
||||
|
||||
| Secret | Purpose |
|
||||
|--------|---------|
|
||||
| `REGISTRY_TOKEN` | Docker registry authentication |
|
||||
| `VAULT_ROLE_ID` | HashiCorp Vault AppRole role ID |
|
||||
| `VAULT_SECRET_ID` | HashiCorp Vault AppRole secret ID |
|
||||
| `K8S_CA_CERT` | Kubernetes cluster CA certificate |
|
||||
| `GITOPS_TOKEN` | Gitea token with write and PR access to `home-kubernetes` |
|
||||
|
||||
### Infrastructure Topology
|
||||
|
||||
@@ -164,8 +159,7 @@ Gitea (git push / tag)
|
||||
├── build.yaml → Docker Build → Gitea Container Registry
|
||||
│ (gitea.home.hrajfrisbee.cz)
|
||||
│
|
||||
└── kubernetes-deploy.yaml → Vault → Kanidm → K8s Cluster
|
||||
(192.168.0.31:6443)
|
||||
└── gitops-update.yaml → PR in home-kubernetes → K8s Cluster
|
||||
```
|
||||
|
||||
This is a self-hosted infrastructure stack:
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# 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
|
||||
|
||||
- [x] **T1** Remove the secret leak in `.gitea/workflows/kubernetes-deploy.yaml`.
|
||||
Workflow deleted on 2026-10-08. Still open for the owner: rotate the
|
||||
credentials and remove the now-unused repo secrets (see below).
|
||||
- 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.
|
||||
- After deletion the repo secrets `VAULT_ROLE_ID`, `VAULT_SECRET_ID` and
|
||||
`K8S_CA_CERT` are unused and can be removed. Old job logs still contain
|
||||
the leaked tokens until they are deleted or expire.
|
||||
- [x] **T2** Confirm the fee rates for 2026-09 onward. Done 2026-10-08:
|
||||
adult default 600, 2026-07/08 at 0, 2026-09 to 2026-12 at 600. Junior
|
||||
rates were not changed.
|
||||
- `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.
|
||||
- [x] **T3** Done 2026-10-08, extended: both dashboards now sync the
|
||||
current year plus November–December of the previous one. Original task:
|
||||
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
-3
@@ -92,9 +92,7 @@ func serverCmd(args []string) {
|
||||
|
||||
actions := web.ActionHandlers{
|
||||
BankSync: func(ctx context.Context, out io.Writer) error {
|
||||
yr := time.Now().Year()
|
||||
from := time.Date(yr, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
to := time.Date(yr, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||
from, to := banksync.DefaultWindow(time.Now())
|
||||
|
||||
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
|
||||
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioClients, sheetsCli,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
package fees
|
||||
|
||||
const (
|
||||
AdultFeeDefault = 700 // CZK fallback for 2+ practices when month not in AdultFeeMonthlyRate
|
||||
AdultFeeDefault = 600 // CZK fallback for 2+ practices when month not in AdultFeeMonthlyRate
|
||||
AdultFeeSingle = 200 // CZK for exactly 1 practice
|
||||
)
|
||||
|
||||
@@ -11,24 +11,27 @@ const (
|
||||
var AdultFeeMonthlyRate = map[string]int{
|
||||
"2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750,
|
||||
"2026-01": 750, "2026-02": 750, "2026-03": 350,
|
||||
"2026-04": 700, "2026-05": 450, "2026-06": 600, "2026-07": 600, "2026-08": 600,
|
||||
"2026-04": 700, "2026-05": 450, "2026-06": 600, "2026-07": 0, "2026-08": 0,
|
||||
"2026-09": 600, "2026-10": 600, "2026-11": 600, "2026-12": 600,
|
||||
}
|
||||
|
||||
// CalculateFee returns the adult fee in CZK for attendanceCount practices in
|
||||
// the given monthKey (format "YYYY-MM").
|
||||
//
|
||||
// 0 practices → 0
|
||||
// 1 practice → AdultFeeSingle (200)
|
||||
// 1 practice → AdultFeeSingle (200), capped at the month's rate so a
|
||||
// 0-rate month is free for everyone
|
||||
// 2+ → AdultFeeMonthlyRate[monthKey] or AdultFeeDefault
|
||||
func CalculateFee(attendanceCount int, monthKey string) int {
|
||||
if attendanceCount == 0 {
|
||||
return 0
|
||||
}
|
||||
rate, ok := AdultFeeMonthlyRate[monthKey]
|
||||
if !ok {
|
||||
rate = AdultFeeDefault
|
||||
}
|
||||
if attendanceCount == 1 {
|
||||
return AdultFeeSingle
|
||||
return min(AdultFeeSingle, rate)
|
||||
}
|
||||
if rate, ok := AdultFeeMonthlyRate[monthKey]; ok {
|
||||
return rate
|
||||
}
|
||||
return AdultFeeDefault
|
||||
return rate
|
||||
}
|
||||
|
||||
@@ -26,9 +26,11 @@ func TestCalculateFee(t *testing.T) {
|
||||
// Zero attendance always returns 0.
|
||||
{"zero short-circuits", 0, "2026-05", 0},
|
||||
{"zero empty month", 0, "", 0},
|
||||
// Single practice returns AdultFeeSingle regardless of month.
|
||||
// Single practice returns AdultFeeSingle, capped at the month's rate.
|
||||
{"single practice", 1, "2026-05", AdultFeeSingle},
|
||||
{"single ignores monthKey", 1, "unknown", AdultFeeSingle},
|
||||
{"single unknown month", 1, "unknown", AdultFeeSingle},
|
||||
{"single in free month is free", 1, "2026-07", 0},
|
||||
{"two in free month is free", 2, "2026-08", 0},
|
||||
// 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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"fuj-management/go/internal/domain/synch"
|
||||
"fuj-management/go/internal/io/fio"
|
||||
"fuj-management/go/internal/io/sheets"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -35,6 +36,18 @@ type SyncOpts struct {
|
||||
PrintFioTable bool // with DryRun: print every fetched Fio txn with NEW/DUP status
|
||||
}
|
||||
|
||||
// DefaultWindow is the window used by the dashboard sync: the whole current
|
||||
// calendar year plus the last two months of the previous one, so payments
|
||||
// dated around New Year are still picked up in January. Re-fetching is safe
|
||||
// because rows are deduplicated by Sync ID.
|
||||
// Mirrors default_sync_window in legacy/scripts/sync_fio_to_sheets.py.
|
||||
func DefaultWindow(now time.Time) (from, to time.Time) {
|
||||
yr := now.Year()
|
||||
from = time.Date(yr-1, time.November, 1, 0, 0, 0, 0, time.UTC)
|
||||
to = time.Date(yr, time.December, 31, 23, 59, 59, 0, time.UTC)
|
||||
return from, to
|
||||
}
|
||||
|
||||
// SyncToSheets fetches Fio transactions from all provided clients and appends
|
||||
// new ones to the payments sheet. Returns the number of rows appended.
|
||||
// Ports legacy/scripts/sync_fio_to_sheets.py sync_to_sheets.
|
||||
@@ -130,7 +143,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 +154,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) {
|
||||
@@ -189,3 +193,23 @@ func syncIDFor(tx fio.Transaction) string {
|
||||
Sender: tx.Sender, VS: tx.VS, Message: tx.Message, BankID: tx.BankID,
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultWindow(t *testing.T) {
|
||||
cases := []struct {
|
||||
now time.Time
|
||||
from, to string
|
||||
}{
|
||||
{time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC), "2025-11-01", "2026-12-31"},
|
||||
{time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), "2026-11-01", "2027-12-31"},
|
||||
{time.Date(2027, 12, 31, 23, 0, 0, 0, time.UTC), "2026-11-01", "2027-12-31"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
from, to := DefaultWindow(tc.now)
|
||||
if got := from.Format("2006-01-02"); got != tc.from {
|
||||
t.Errorf("DefaultWindow(%s) from: want %s, got %s", tc.now.Format("2006-01-02"), tc.from, got)
|
||||
}
|
||||
if got := to.Format("2006-01-02"); got != tc.to {
|
||||
t.Errorf("DefaultWindow(%s) to: want %s, got %s", tc.now.Format("2006-01-02"), tc.to, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"case": "one_session_free_july",
|
||||
"func": "scripts.attendance.calculate_fee",
|
||||
"captured_at": "2026-10-08",
|
||||
"input": {
|
||||
"attendance_count": 1,
|
||||
"month_key": "2026-07"
|
||||
},
|
||||
"output": {
|
||||
"fee": 0
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"case": "two_sessions_default_fallback",
|
||||
"func": "scripts.attendance.calculate_fee",
|
||||
"captured_at": "2026-05-06",
|
||||
"captured_at": "2026-10-08",
|
||||
"input": {
|
||||
"attendance_count": 2,
|
||||
"month_key": "2099-01"
|
||||
},
|
||||
"output": {
|
||||
"fee": 700
|
||||
"fee": 600
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"case": "two_sessions_free_july",
|
||||
"func": "scripts.attendance.calculate_fee",
|
||||
"captured_at": "2026-10-08",
|
||||
"input": {
|
||||
"attendance_count": 2,
|
||||
"month_key": "2026-07"
|
||||
},
|
||||
"output": {
|
||||
"fee": 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"case": "two_sessions_october",
|
||||
"func": "scripts.attendance.calculate_fee",
|
||||
"captured_at": "2026-10-08",
|
||||
"input": {
|
||||
"attendance_count": 2,
|
||||
"month_key": "2026-10"
|
||||
},
|
||||
"output": {
|
||||
"fee": 600
|
||||
}
|
||||
}
|
||||
+12
-7
@@ -30,7 +30,7 @@ from views import (
|
||||
adapt_junior_members,
|
||||
)
|
||||
from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED, flush_cache
|
||||
from sync_fio_to_sheets import sync_to_sheets
|
||||
from sync_fio_to_sheets import sync_to_sheets, default_sync_window
|
||||
from infer_payments import infer_payments
|
||||
|
||||
def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
|
||||
@@ -128,13 +128,14 @@ def sync_bank():
|
||||
success = True
|
||||
try:
|
||||
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
|
||||
# sync_to_sheets: equivalent of make sync-2026
|
||||
output.write("=== Syncing Fio transactions (2026) ===\n")
|
||||
# sync_to_sheets: equivalent of make sync-year (same default window as Go)
|
||||
date_from, date_to = default_sync_window()
|
||||
output.write(f"=== Syncing Fio transactions ({date_from} to {date_to}) ===\n")
|
||||
sync_to_sheets(
|
||||
spreadsheet_id=PAYMENTS_SHEET_ID,
|
||||
credentials_path=CREDENTIALS_PATH,
|
||||
date_from_str="2026-01-01",
|
||||
date_to_str="2026-12-31",
|
||||
date_from_str=date_from,
|
||||
date_to_str=date_to,
|
||||
sort_by_date=True,
|
||||
)
|
||||
output.write("\n=== Inferring payment details ===\n")
|
||||
@@ -179,7 +180,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 +206,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():
|
||||
|
||||
@@ -115,6 +115,15 @@ SEEDS[("calculate_fee", "two_sessions_reduced_march")] = {
|
||||
SEEDS[("calculate_fee", "two_sessions_default_fallback")] = {
|
||||
"attendance_count": 2, "month_key": "2099-01"
|
||||
}
|
||||
SEEDS[("calculate_fee", "two_sessions_free_july")] = {
|
||||
"attendance_count": 2, "month_key": "2026-07"
|
||||
}
|
||||
SEEDS[("calculate_fee", "one_session_free_july")] = {
|
||||
"attendance_count": 1, "month_key": "2026-07"
|
||||
}
|
||||
SEEDS[("calculate_fee", "two_sessions_october")] = {
|
||||
"attendance_count": 2, "month_key": "2026-10"
|
||||
}
|
||||
|
||||
|
||||
# --- calculate_junior_fee ---
|
||||
|
||||
@@ -10,7 +10,7 @@ from config import ATTENDANCE_SHEET_ID as SHEET_ID, JUNIOR_SHEET_GID
|
||||
EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid=0"
|
||||
JUNIOR_EXPORT_URL = f"https://docs.google.com/spreadsheets/d/{SHEET_ID}/export?format=csv&gid={JUNIOR_SHEET_GID}"
|
||||
|
||||
ADULT_FEE_DEFAULT = 700 # CZK, for 2+ practices in a month
|
||||
ADULT_FEE_DEFAULT = 600 # CZK, for 2+ practices in a month
|
||||
ADULT_FEE_SINGLE = 200 # CZK, for exactly 1 practice in a month
|
||||
ADULT_FEE_MONTHLY_RATE = {
|
||||
"2025-09": 750,
|
||||
@@ -23,8 +23,12 @@ ADULT_FEE_MONTHLY_RATE = {
|
||||
"2026-04": 700,
|
||||
"2026-05": 450,
|
||||
"2026-06": 600,
|
||||
"2026-07": 600,
|
||||
"2026-08": 600,
|
||||
"2026-07": 0,
|
||||
"2026-08": 0,
|
||||
"2026-09": 600,
|
||||
"2026-10": 600,
|
||||
"2026-11": 600,
|
||||
"2026-12": 600,
|
||||
}
|
||||
|
||||
JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices
|
||||
@@ -91,12 +95,13 @@ def group_by_month(dates: list[tuple[int, datetime]], merged_months: dict[str, s
|
||||
|
||||
|
||||
def calculate_fee(attendance_count: int, month_key: str) -> int:
|
||||
"""Apply fee rules: 0 → 0, 1 → 200, 2+ → configured rate (default 750)."""
|
||||
"""Apply fee rules: 0 → 0, 1 → 200 (capped at the month's rate), 2+ → configured rate (default 600)."""
|
||||
if attendance_count == 0:
|
||||
return 0
|
||||
rate = ADULT_FEE_MONTHLY_RATE.get(month_key, ADULT_FEE_DEFAULT)
|
||||
if attendance_count == 1:
|
||||
return ADULT_FEE_SINGLE
|
||||
return ADULT_FEE_MONTHLY_RATE.get(month_key, ADULT_FEE_DEFAULT)
|
||||
return min(ADULT_FEE_SINGLE, rate)
|
||||
return rate
|
||||
|
||||
|
||||
def calculate_junior_fee(attendance_count: int, month_key: str) -> str | int:
|
||||
|
||||
@@ -59,6 +59,28 @@ def get_sheets_service(credentials_path: str):
|
||||
return _SHEETS_SERVICE
|
||||
|
||||
|
||||
def default_sync_window(today=None) -> tuple[str, str]:
|
||||
"""Window used by the dashboard sync, as (from, to) in YYYY-MM-DD.
|
||||
|
||||
The whole current calendar year plus the last two months of the previous
|
||||
one, so payments dated around New Year are still picked up in January.
|
||||
Re-fetching is safe because rows are deduplicated by Sync ID.
|
||||
Mirrors DefaultWindow in go/internal/services/banksync/sync.go.
|
||||
"""
|
||||
year = (today or datetime.now()).year
|
||||
return f"{year - 1}-11-01", f"{year}-12-31"
|
||||
|
||||
|
||||
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 +231,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,
|
||||
|
||||
@@ -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"}
|
||||
@@ -23,6 +23,20 @@ class TestWebApp(unittest.TestCase):
|
||||
app.config['TESTING'] = True
|
||||
self.client = app.test_client()
|
||||
|
||||
@patch('app.flush_cache', return_value=0)
|
||||
@patch('app.infer_payments')
|
||||
@patch('app.sync_to_sheets')
|
||||
def test_sync_bank_uses_default_window(self, mock_sync, mock_infer, mock_flush):
|
||||
"""/sync-bank syncs the current year plus Nov-Dec of the previous one, not a hardcoded year."""
|
||||
from datetime import datetime
|
||||
year = datetime.now().year
|
||||
response = self.client.get('/sync-bank')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
kwargs = mock_sync.call_args.kwargs
|
||||
self.assertEqual(kwargs['date_from_str'], f"{year - 1}-11-01")
|
||||
self.assertEqual(kwargs['date_to_str'], f"{year}-12-31")
|
||||
mock_infer.assert_called_once()
|
||||
|
||||
def test_index_page(self):
|
||||
"""Test that / returns the refresh meta tag"""
|
||||
response = self.client.get('/')
|
||||
@@ -139,6 +153,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 +181,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')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from scripts.match_payments import canonical_member_key, reconcile
|
||||
from scripts.match_payments import canonical_member_key, fetch_sheet_data, reconcile
|
||||
|
||||
|
||||
class TestCanonicalMemberKey(unittest.TestCase):
|
||||
@@ -67,3 +68,23 @@ class TestReconcileTolerantPersonMatching(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestFetchSheetDataVS(unittest.TestCase):
|
||||
"""Numeric VS cells arrive as floats (UNFORMATTED_VALUE) and must stay plain digit strings."""
|
||||
|
||||
def _fetch(self, vs_cell):
|
||||
service = MagicMock()
|
||||
service.spreadsheets().values().get().execute.return_value = {"values": [
|
||||
["Date", "Amount", "manual fix", "Person", "Purpose", "Inferred Amount",
|
||||
"Sender", "VS", "Message", "Bank ID", "Sync ID"],
|
||||
["2026-04-15", 750, "", "", "", "", "Test Sender", vs_cell, "fee", "", "abc"],
|
||||
]}
|
||||
with patch("scripts.match_payments.get_sheets_service", return_value=service):
|
||||
return fetch_sheet_data("sheet-id", "creds.json")
|
||||
|
||||
def test_numeric_vs_is_plain_digit_string(self):
|
||||
self.assertEqual(self._fetch(2609767878.0)[0]["vs"], "2609767878")
|
||||
|
||||
def test_string_vs_is_kept(self):
|
||||
self.assertEqual(self._fetch("0308")[0]["vs"], "0308")
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import contextlib
|
||||
import io
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from scripts.sync_fio_to_sheets import COLUMN_LABELS, as_text_cell, default_sync_window, sync_to_sheets
|
||||
|
||||
|
||||
class TestDefaultSyncWindow(unittest.TestCase):
|
||||
def test_current_year_plus_last_two_months_of_previous(self):
|
||||
self.assertEqual(default_sync_window(datetime(2026, 10, 8)), ("2025-11-01", "2026-12-31"))
|
||||
self.assertEqual(default_sync_window(datetime(2027, 1, 1)), ("2026-11-01", "2027-12-31"))
|
||||
self.assertEqual(default_sync_window(datetime(2027, 12, 31)), ("2026-11-01", "2027-12-31"))
|
||||
|
||||
|
||||
class TestAsTextCell(unittest.TestCase):
|
||||
def test_digits_get_text_marker(self):
|
||||
self.assertEqual(as_text_cell("0308"), "'0308")
|
||||
self.assertEqual(as_text_cell(123), "'123")
|
||||
|
||||
def test_empty_stays_empty(self):
|
||||
self.assertEqual(as_text_cell(""), "")
|
||||
self.assertEqual(as_text_cell(None), "")
|
||||
|
||||
|
||||
class TestSyncWritesVSAsText(unittest.TestCase):
|
||||
def test_appended_row_has_text_vs(self):
|
||||
service = MagicMock()
|
||||
sheet = service.spreadsheets.return_value
|
||||
sheet.values.return_value.get.return_value.execute.return_value = {"values": [COLUMN_LABELS]}
|
||||
tx = {"date": "2026-04-10", "amount": 750.0, "sender": "Test Sender",
|
||||
"vs": "0308", "message": "fee", "bank_id": "111"}
|
||||
with patch("scripts.sync_fio_to_sheets.get_sheets_service", return_value=service), \
|
||||
patch("scripts.sync_fio_to_sheets.fetch_transactions_all", return_value=[tx]), \
|
||||
contextlib.redirect_stdout(io.StringIO()):
|
||||
sync_to_sheets("sheet-id", "creds.json", days=30)
|
||||
|
||||
body = sheet.values.return_value.append.call_args.kwargs["body"]
|
||||
self.assertEqual(len(body["values"]), 1)
|
||||
self.assertEqual(body["values"][0][7], "'0308")
|
||||
Reference in New Issue
Block a user