Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d55eca189 | ||
|
|
bbad19d822 | ||
|
|
294c40fa42 | ||
|
|
d5da993690 | ||
|
|
2c2db60fe7 | ||
|
|
1d5a1c95d2 | ||
|
|
62c6dc2ca0 | ||
|
|
23633f8cb2 | ||
|
|
2d8aada8d6 | ||
|
|
14cdbeac21 | ||
|
|
5b162d0030 | ||
|
|
cfdc5cbb2a | ||
|
|
e2ae5a7ec4 | ||
|
|
82406935ed | ||
|
|
e7c8101c3b | ||
|
|
97f94afa02 | ||
|
|
022facc957 | ||
|
|
96c14f0b22 | ||
|
|
6d7dbfa624 | ||
|
|
c00111cff1 | ||
|
|
d263d8a534 | ||
|
|
af030c8255 | ||
|
|
ad127d36ea | ||
|
|
29938d7a0c | ||
|
|
1df1863725 | ||
|
|
995abfacb2 | ||
|
|
f047150004 | ||
|
|
6f2994b8ad |
@@ -31,11 +31,11 @@ jobs:
|
|||||||
TAG=${{ inputs.tag }}
|
TAG=${{ inputs.tag }}
|
||||||
fi
|
fi
|
||||||
IMAGE=gitea.home.hrajfrisbee.cz/${{ github.repository }}:$TAG
|
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_TAG=$TAG \
|
||||||
--build-arg GIT_COMMIT=${{ github.sha }} \
|
--build-arg GIT_COMMIT=${{ github.sha }} \
|
||||||
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
--build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
||||||
-t $IMAGE .
|
-t $IMAGE legacy/
|
||||||
docker push $IMAGE
|
docker push $IMAGE
|
||||||
|
|
||||||
build-go:
|
build-go:
|
||||||
|
|||||||
@@ -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,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/
|
|
||||||
Vendored
+5
-3
@@ -2,14 +2,16 @@
|
|||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "Python Debugger: Flask",
|
"name": "Python Debugger: Flask (legacy)",
|
||||||
"type": "debugpy",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"module": "flask",
|
"module": "flask",
|
||||||
"python": "${workspaceFolder}/.venv/bin/python",
|
"python": "${workspaceFolder}/legacy/.venv/bin/python",
|
||||||
|
"cwd": "${workspaceFolder}/legacy",
|
||||||
"env": {
|
"env": {
|
||||||
"FLASK_APP": "app.py",
|
"FLASK_APP": "app.py",
|
||||||
"FLASK_DEBUG": "1"
|
"FLASK_DEBUG": "1",
|
||||||
|
"CREDENTIALS_PATH": "${workspaceFolder}/.secret/fuj-management-bot-credentials.json"
|
||||||
},
|
},
|
||||||
"args": [
|
"args": [
|
||||||
"run",
|
"run",
|
||||||
|
|||||||
@@ -1,5 +1,68 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-10-08 12:37 CEST — feat(infer): messages starting with "other:" are not monthly fees
|
||||||
|
|
||||||
|
- When the bank message starts with `other:` (case-insensitive), inference no longer looks for a month. It copies the trimmed message into Purpose, so the payment is linked to the person as an "other" transaction and not counted as a monthly fee. Messages that contain `other:` elsewhere are inferred as before.
|
||||||
|
- If no person is found, Purpose is still written and the row shows under unmatched for a manual fix.
|
||||||
|
- Reconcile applies the same rule to rows not yet processed by inference.
|
||||||
|
- Existing rows are not re-inferred; clear Person and Purpose on a row to reprocess it.
|
||||||
|
- Go and Python: `matching.IsOtherPurpose` / `is_other_purpose`, `InferTransactionDetails` / `infer_transaction_details`, reconcile fallback; four new parity fixtures.
|
||||||
|
- Also fixed (Go): junior cell tooltip showed `Expected: 0` instead of `Expected: ?` when a month with unknown expected fee had a payment.
|
||||||
|
|
||||||
|
## 2026-10-08 12:16 CEST — feat(go): scheduled bank sync (Go only)
|
||||||
|
|
||||||
|
- The Go server now runs the bank sync (sync → infer → flush cache) by itself at the times in `SYNC_SCHEDULE` (comma-separated `HH:MM`, default `04:00`, `off` disables), read in `SYNC_TZ` (default `Europe/Prague`). Nothing runs at startup; an invalid value stops the server at startup with an error.
|
||||||
|
- Manual `/sync-bank` and scheduled runs share one lock, so two syncs can no longer overlap; a second request gets "a sync is already running".
|
||||||
|
- The page footer shows the last sync since start (time, trigger, ok/FAILED) and the next scheduled run.
|
||||||
|
- The binary embeds the time zone database (`time/tzdata`), since the Alpine image has none.
|
||||||
|
- Deliberately not implemented in the legacy Python app, which is being phased out; recorded in `CLAUDE.md` under "Deliberately Go-only features".
|
||||||
|
- Key files: `go/internal/scheduler/`, `go/internal/services/banksync/runner.go`, `go/cmd/fuj/main.go`, `go/internal/config/config.go`, `go/internal/web/`.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- `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-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).
|
- 2026-05: 700 → 450 CZK; 2026-06/07/08: 600 CZK (new months added).
|
||||||
|
|||||||
@@ -4,41 +4,89 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## Project Status
|
## 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).
|
||||||
|
|
||||||
|
### Deliberately Go-only features
|
||||||
|
|
||||||
|
These exist only in the Go app on purpose. Do not port them to the legacy Python app, which is being phased out:
|
||||||
|
|
||||||
|
- **Scheduled bank sync** — the Go server runs sync → infer → flush-cache by itself at the times in `SYNC_SCHEDULE`. Python's multi-worker gunicorn setup would need a cross-process lock for this, which is not worth building in code that is going away. In Python the sync still runs only from `/sync-bank` or the CLI.
|
||||||
|
|
||||||
## Key Constraints
|
## Key Constraints
|
||||||
|
|
||||||
- **PII separation**: Member data (names, emails, payment info) must never be committed to git. Enforce config/data separation from day one.
|
- **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
|
## Development Setup
|
||||||
|
|
||||||
This project uses `uv` for dependency management.
|
Go (primary): a recent Go toolchain (see `go/go.mod`) and `golangci-lint`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv venv && uv sync
|
make go-build # builds bin/fuj
|
||||||
source .venv/bin/activate
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## Commands
|
||||||
|
|
||||||
|
Go (primary):
|
||||||
|
|
||||||
```bash
|
```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 web-debug # Same with FLASK_DEBUG=1
|
||||||
make test # Run all tests (unittest discover)
|
make test # Run all tests (unittest discover)
|
||||||
make test-v # Tests with verbose output
|
make test-v # Tests with verbose output
|
||||||
make fees # Print fee report from attendance sheet
|
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 infer # Auto-fill Person/Purpose/Amount columns in payments sheet
|
||||||
make reconcile # Print balance report from Google Sheets data
|
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:
|
Run a single test:
|
||||||
```bash
|
```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
|
## Architecture
|
||||||
@@ -46,47 +94,77 @@ PYTHONPATH=scripts:. python -m unittest tests.test_app.TestWebApp.test_adults_ro
|
|||||||
### Data flow
|
### Data flow
|
||||||
|
|
||||||
```
|
```
|
||||||
Google Sheets (attendance) ──► attendance.py ──► reconcile() ──► Flask routes ──► templates/
|
Google Sheets (attendance) ──► attendance loader ──► reconcile ──► HTTP handlers ──► templates
|
||||||
Google Sheets (payments) ──► match_payments.py ──┘
|
Google Sheets (payments) ──► payments loader ──┘
|
||||||
Fio Bank API ──► sync_fio_to_sheets.py ──► Google Sheets (payments)
|
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`
|
- `go/` — primary implementation (single binary `fuj`, templates and static assets embedded).
|
||||||
- `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/` — legacy Python implementation (`app.py`, `scripts/`, `templates/`, `tests/`, `pyproject.toml`, `build/Dockerfile`).
|
||||||
- `scripts/match_payments.py` — `reconcile()` matches transactions to members/months. `fetch_sheet_data()` reads the payments sheet. `fetch_exceptions()` reads the `exceptions` tab.
|
- `docs/` — documentation; `docs/plans/` holds plans and task lists.
|
||||||
- `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/`.
|
- `.gitea/workflows/` — CI: image builds on tag push, gitops image-update PR.
|
||||||
- `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.
|
### Key modules — Go (primary)
|
||||||
- `scripts/config.py` — All external IDs, paths, and tunable TTLs. Override via env vars (`CREDENTIALS_PATH`, `BANK_ACCOUNT`, `CACHE_TTL_SECONDS`).
|
|
||||||
|
- `go/cmd/fuj` — CLI entry point; subcommands `server`, `fees`, `reconcile`, `sync`, `infer`, `version`.
|
||||||
|
- `go/cmd/parity` — diffs the JSON API of the Go and Python servers.
|
||||||
|
- `go/internal/config` — sheet IDs, bank accounts, env-driven settings (`CREDENTIALS_PATH`, `CACHE_DIR`, `CACHE_TTL_SECONDS`, `CACHE_API_CHECK_TTL_SECONDS`, `MONTHS_TO_SHOW`, `SERVER_ADDR`, `LOG_LEVEL`, `FIO_API_TOKEN_NEW` / `FIO_API_TOKEN_OLD`, `SYNC_SCHEDULE`, `SYNC_TZ`).
|
||||||
|
- `go/internal/domain` — pure logic: `fees` (rates, `CalculateFee`, `CalculateJuniorFee`), `reconcile`, `matching` (name matching, inference), `czech` (normalize, month parsing), `money`, `synch` (sync IDs).
|
||||||
|
- `go/internal/io` — external systems: `attendance` (CSV export), `sheets`, `drive`, `fio` (API + transparent-page scraper), `cache` (file cache). Each has a fake for tests.
|
||||||
|
- `go/internal/services` — `membership` (loading, merged months, fee and reconcile reports) and `banksync` (Fio sync + inference; `Runner` serialises manual and scheduled runs).
|
||||||
|
- `go/internal/scheduler` — runs a job at fixed times of day; used by `fuj server` for the scheduled bank sync.
|
||||||
|
- `go/internal/web` — routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/flush-cache`, `/qr`, `/version`, `/api/*`; `templates/` and `static/` are embedded.
|
||||||
|
- `go/tests` — parity tests and the scrubbed fixture corpus captured from Python.
|
||||||
|
|
||||||
|
### Key modules — Python (legacy)
|
||||||
|
|
||||||
|
- `legacy/app.py` — Flask app; routes for `/adults`, `/juniors`, `/payments`, `/sync-bank`, `/qr`, `/flush-cache`, `/api/*`
|
||||||
|
- `legacy/scripts/attendance.py` — Fetches attendance CSV from Google Sheets, computes per-member per-month fees. Contains fee rate constants (`ADULT_FEE_DEFAULT`, `JUNIOR_FEE_DEFAULT`) and `ADULT_MERGED_MONTHS` / `JUNIOR_MERGED_MONTHS` dicts.
|
||||||
|
- `legacy/scripts/match_payments.py` — `reconcile()` matches transactions to members/months. `fetch_sheet_data()` reads the payments sheet. `fetch_exceptions()` reads the `exceptions` tab.
|
||||||
|
- `legacy/scripts/cache_utils.py` — Invalidation via Google Drive API `modifiedTime`; falls back to 5-minute TTL buckets when Drive API is unavailable. Cache files live in `legacy/tmp/`.
|
||||||
|
- `legacy/scripts/sync_fio_to_sheets.py` — Pulls Fio bank transactions and appends them to the payments Google Sheet.
|
||||||
|
- `legacy/scripts/infer_payments.py` — Fills in Person/Purpose/Inferred Amount columns using name-matching heuristics.
|
||||||
|
- `legacy/scripts/views.py` — View models shared by the HTML routes and the JSON API.
|
||||||
|
- `legacy/scripts/config.py` — All external IDs, paths, and tunable TTLs. Override via env vars (`CREDENTIALS_PATH`, `CACHE_TTL_SECONDS`, `MONTHS_TO_SHOW`).
|
||||||
|
- `legacy/scripts/capture_fixtures.py`, `scrub_fixtures.py`, `_fixture_seeds.py` — produce the scrubbed parity fixtures for Go.
|
||||||
|
|
||||||
### Member tiers
|
### Member tiers
|
||||||
|
|
||||||
Tiers are set in column B of the attendance sheet:
|
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 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
|
- `J` — Junior attending adult practices; their attendance is merged with the junior sheet
|
||||||
- `X` — Excluded from junior fee calculation (coaches, etc.)
|
- `X` — Excluded from junior fee calculation (coaches, etc.)
|
||||||
|
|
||||||
### Fee calculation
|
### 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)
|
- 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))`.
|
- Per-member per-month overrides live in the `exceptions` tab of the payments sheet (columns: Name, Period YYYY-MM, Amount, Note). Exceptions are keyed by `(normalize(name), normalize(period))`.
|
||||||
|
|
||||||
### Merged months
|
### 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
|
### 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).
|
||||||
|
|
||||||
|
### Bank sync
|
||||||
|
|
||||||
|
- Manual: open `/sync-bank` (both apps) or run `fuj sync` + `fuj infer` / `make sync-year` + `make infer`.
|
||||||
|
- Scheduled (Go server only): at each time in `SYNC_SCHEDULE` (comma-separated `HH:MM`, default `04:00`; `off` disables) read in `SYNC_TZ` (default `Europe/Prague`). Nothing runs at startup. Manual and scheduled runs share one lock and never overlap. The page footer shows the last run since start and the next one.
|
||||||
|
- Both use the same window: 1 November of the previous year to 31 December of the current year; rows are de-duplicated by Sync ID.
|
||||||
|
- The schedule runs per process: keep the deployment at one replica, and note that a local `make web-go` with real credentials also syncs at the scheduled time unless `SYNC_SCHEDULE=off`.
|
||||||
|
|
||||||
### Payments sheet columns
|
### Payments sheet columns
|
||||||
|
|
||||||
`Date | Amount | manual fix | Person | Purpose | Inferred Amount | Sender | VS | Message | Bank ID | Sync ID`
|
`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.
|
||||||
|
|
||||||
|
**Non-fee payments ("other")**: a Purpose that starts with `other:` (case-insensitive) is attached to the person's "other transactions" and is not counted against any month. Inference produces this by itself when the bank message starts with `other:`: it copies the trimmed message into Purpose and does not look for a month (neither in the text nor from the date). A message that only contains `other:` later in the text is inferred the normal way. The shared check is `matching.IsOtherPurpose` / `is_other_purpose`.
|
||||||
|
|
||||||
### QR codes
|
### QR codes
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
.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
|
||||||
|
|
||||||
export PYTHONPATH := scripts:$(PYTHONPATH)
|
# Legacy Python implementation lives in legacy/ (Go in go/ is primary).
|
||||||
VENV := .venv
|
LEGACY := legacy
|
||||||
PYTHON := $(VENV)/bin/python3
|
export PYTHONPATH := $(CURDIR)/$(LEGACY)/scripts:$(CURDIR)/$(LEGACY):$(PYTHONPATH)
|
||||||
CREDENTIALS := .secret/fuj-management-bot-credentials.json
|
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_SRC := go
|
||||||
GO_BIN := bin/fuj
|
GO_BIN := bin/fuj
|
||||||
|
|
||||||
$(PYTHON): .venv/.last_sync
|
$(PYTHON): $(VENV)/.last_sync
|
||||||
|
|
||||||
.venv/.last_sync: pyproject.toml
|
$(VENV)/.last_sync: $(LEGACY)/pyproject.toml
|
||||||
uv sync
|
uv sync --project $(LEGACY)
|
||||||
touch .venv/.last_sync
|
touch $(VENV)/.last_sync
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Available targets:"
|
@echo "Available targets:"
|
||||||
@echo " make fees - Calculate monthly fees from the attendance sheet"
|
@echo " make fees - Calculate monthly fees from the attendance sheet"
|
||||||
@echo " make match - Match Fio bank payments against expected attendance fees"
|
@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 - Start legacy Python dashboard (alias for web-py)"
|
||||||
@echo " make web-py - Start Python dashboard on :5001"
|
@echo " make web-py - Start legacy Python dashboard on :5001"
|
||||||
@echo " make web-go - Build and start Go dashboard on :8080"
|
@echo " make web-go - Build and start Go dashboard on :8080 (primary)"
|
||||||
@echo " make web-debug - Start Python dashboard in debug mode"
|
@echo " make web-debug - Start Python dashboard in debug mode"
|
||||||
@echo " make go-build - Build Go binary to bin/fuj"
|
@echo " make go-build - Build Go binary to bin/fuj"
|
||||||
@echo " make go-test - Run Go unit tests"
|
@echo " make go-test - Run Go unit tests"
|
||||||
@@ -35,6 +39,7 @@ help:
|
|||||||
@echo " make sync - Sync Fio transactions to Google Sheets"
|
@echo " make sync - Sync Fio transactions to Google Sheets"
|
||||||
@echo " make sync-2025 - Sync Fio transactions for Q4 2025 (Oct-Dec)"
|
@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-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 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 infer - Infer payment details (Person, Purpose, Amount) in the sheet"
|
||||||
@echo " make reconcile - Show balance report using Google Sheets data"
|
@echo " make reconcile - Show balance report using Google Sheets data"
|
||||||
@@ -44,21 +49,21 @@ help:
|
|||||||
@echo " make docs - Serve documentation in a browser"
|
@echo " make docs - Serve documentation in a browser"
|
||||||
|
|
||||||
venv:
|
venv:
|
||||||
uv sync
|
uv sync --project $(LEGACY)
|
||||||
|
|
||||||
fees: $(PYTHON)
|
fees: $(PYTHON)
|
||||||
$(PYTHON) scripts/calculate_fees.py
|
$(PYTHON) $(LEGACY)/scripts/calculate_fees.py
|
||||||
|
|
||||||
match: $(PYTHON)
|
match: $(PYTHON)
|
||||||
$(PYTHON) scripts/match_payments.py
|
$(PYTHON) $(LEGACY)/scripts/match_payments.py
|
||||||
|
|
||||||
web: web-py
|
web: web-py
|
||||||
|
|
||||||
web-py: $(PYTHON)
|
web-py: $(PYTHON)
|
||||||
$(PYTHON) app.py
|
$(PYTHON) $(LEGACY)/app.py
|
||||||
|
|
||||||
web-debug: $(PYTHON)
|
web-debug: $(PYTHON)
|
||||||
FLASK_DEBUG=1 $(PYTHON) app.py
|
FLASK_DEBUG=1 $(PYTHON) $(LEGACY)/app.py
|
||||||
|
|
||||||
go-build:
|
go-build:
|
||||||
cd $(GO_SRC) && go build -trimpath \
|
cd $(GO_SRC) && go build -trimpath \
|
||||||
@@ -82,10 +87,10 @@ capture-fixtures: $(PYTHON)
|
|||||||
infer_transaction_details format_date reconcile; do \
|
infer_transaction_details format_date reconcile; do \
|
||||||
dir="go/tests/fixtures/$$([[ $$func == reconcile ]] && echo reconcile || echo pure/$$func)"; \
|
dir="go/tests/fixtures/$$([[ $$func == reconcile ]] && echo reconcile || echo pure/$$func)"; \
|
||||||
mkdir -p "$$dir"; \
|
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 \
|
| while IFS= read -r line; do \
|
||||||
case_id=$$(echo "$$line" | $(PYTHON) -c "import sys,json; print(json.load(sys.stdin)['case'])"); \
|
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; \
|
done; \
|
||||||
echo " $$func done"; \
|
echo " $$func done"; \
|
||||||
done
|
done
|
||||||
@@ -112,34 +117,38 @@ image:
|
|||||||
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \
|
--build-arg GIT_TAG=$$(git describe --tags --always 2>/dev/null || echo "untagged") \
|
||||||
--build-arg GIT_COMMIT=$$(git rev-parse --short HEAD) \
|
--build-arg GIT_COMMIT=$$(git rev-parse --short HEAD) \
|
||||||
--build-arg BUILD_DATE=$$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
--build-arg BUILD_DATE=$$(date -u +%Y-%m-%dT%H:%M:%SZ) \
|
||||||
-f build/Dockerfile .
|
-f $(LEGACY)/build/Dockerfile $(LEGACY)
|
||||||
|
|
||||||
run:
|
run:
|
||||||
docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest
|
docker run -it --rm -p 5001:5001 -v $(CURDIR)/.secret:/app/.secret:ro fuj-management:latest
|
||||||
|
|
||||||
sync: $(PYTHON)
|
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)
|
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)
|
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
|
||||||
|
|
||||||
|
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)
|
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)
|
infer: $(PYTHON)
|
||||||
$(PYTHON) scripts/infer_payments.py --credentials $(CREDENTIALS)
|
$(PYTHON) $(LEGACY)/scripts/infer_payments.py --credentials $(CREDENTIALS)
|
||||||
|
|
||||||
reconcile: ## Match payments against attendance
|
reconcile: $(PYTHON) ## Match payments against attendance
|
||||||
export PYTHONPATH=$(PYTHONPATH):$(CURDIR)/scripts && $(PYTHON) scripts/match_payments.py --credentials $(CREDENTIALS)
|
$(PYTHON) $(LEGACY)/scripts/match_payments.py --credentials $(CREDENTIALS)
|
||||||
|
|
||||||
test: $(PYTHON) ## Run web application tests
|
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
|
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
|
docs: ## Serve documentation locally
|
||||||
@echo "Starting documentation server at http://localhost:8000"
|
@echo "Starting documentation server at http://localhost:8000"
|
||||||
|
|||||||
@@ -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.
|
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
|
## Navigation
|
||||||
|
|
||||||
Use the sidebar to explore the documentation:
|
Use the sidebar to explore the documentation:
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ fuj-management/
|
|||||||
│
|
│
|
||||||
├── .gitea/workflows/
|
├── .gitea/workflows/
|
||||||
│ ├── build.yaml # CI: build + push Docker image
|
│ ├── 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
|
├── .secret/ # (gitignored) API credentials
|
||||||
├── docs/ # Project documentation
|
├── 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.
|
**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**:
|
**Triggers**:
|
||||||
- Push to any branch
|
- Completion of a successful "Build and Push" run
|
||||||
- Manual dispatch
|
- Manual dispatch (with `tag`, `dry_run` and `uh_cli_version` inputs)
|
||||||
|
|
||||||
**Steps**:
|
**Steps**:
|
||||||
1. Checkout code
|
1. Install `tea` and `uh-cli`
|
||||||
2. Install kubectl
|
2. Resolve the image tag of the Go image that was just built
|
||||||
3. Retrieve Kanidm token from HashiCorp Vault:
|
3. Open a PR in `kacerr/home-kubernetes` bumping the `fuj-management` Deployment (namespace `fuj`) to that image
|
||||||
- Authenticate to Vault via AppRole (`VAULT_ROLE_ID` / `VAULT_SECRET_ID`)
|
|
||||||
- Fetch API token from `secret/data/gitea/gitea-ci`
|
The cluster picks the change up once that PR is merged. CI does not talk to the cluster directly.
|
||||||
4. Exchange API token for K8s OIDC token via Kanidm:
|
|
||||||
- POST to `https://idm.home.hrajfrisbee.cz/oauth2/token`
|
> 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.
|
||||||
- Token exchange using `urn:ietf:params:oauth:grant-type:token-exchange`
|
|
||||||
5. Configure kubectl with the OIDC token
|
|
||||||
6. Run `kubectl auth whoami` and `kubectl get ns` (deploy commands are commented out — WIP)
|
|
||||||
|
|
||||||
**Required secrets**:
|
**Required secrets**:
|
||||||
|
|
||||||
| Secret | Purpose |
|
| Secret | Purpose |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `REGISTRY_TOKEN` | Docker registry authentication |
|
| `REGISTRY_TOKEN` | Docker registry authentication |
|
||||||
| `VAULT_ROLE_ID` | HashiCorp Vault AppRole role ID |
|
| `GITOPS_TOKEN` | Gitea token with write and PR access to `home-kubernetes` |
|
||||||
| `VAULT_SECRET_ID` | HashiCorp Vault AppRole secret ID |
|
|
||||||
| `K8S_CA_CERT` | Kubernetes cluster CA certificate |
|
|
||||||
|
|
||||||
### Infrastructure Topology
|
### Infrastructure Topology
|
||||||
|
|
||||||
@@ -164,8 +159,7 @@ Gitea (git push / tag)
|
|||||||
├── build.yaml → Docker Build → Gitea Container Registry
|
├── build.yaml → Docker Build → Gitea Container Registry
|
||||||
│ (gitea.home.hrajfrisbee.cz)
|
│ (gitea.home.hrajfrisbee.cz)
|
||||||
│
|
│
|
||||||
└── kubernetes-deploy.yaml → Vault → Kanidm → K8s Cluster
|
└── gitops-update.yaml → PR in home-kubernetes → K8s Cluster
|
||||||
(192.168.0.31:6443)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This is a self-hosted infrastructure stack:
|
This is a self-hosted infrastructure stack:
|
||||||
|
|||||||
@@ -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,174 @@
|
|||||||
|
# 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. Partly addressed
|
||||||
|
2026-10-08: the binary now embeds the zone database and the scheduled sync
|
||||||
|
uses `SYNC_TZ`; the "current month" logic below is still open.
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Features added since the audit
|
||||||
|
|
||||||
|
- 2026-10-08 — Scheduled bank sync in the Go server, deliberately Go-only.
|
||||||
|
Plan: [2026-10-08-1216-scheduled-bank-sync.md](2026-10-08-1216-scheduled-bank-sync.md).
|
||||||
|
It also added a lock so two syncs cannot overlap (relevant to T8).
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# Scheduled bank sync in the Go server (Go only)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Bank synchronization only runs when someone opens `/sync-bank`; the payments
|
||||||
|
sheet is as fresh as the last click. The Go server should run the same
|
||||||
|
sync → infer → flush-cache sequence by itself on a configured schedule,
|
||||||
|
defaulting to once a day.
|
||||||
|
|
||||||
|
Decisions already made:
|
||||||
|
|
||||||
|
- **Go only.** Deliberately not implemented in the legacy Python app, which is
|
||||||
|
being phased out (gunicorn's multi-worker model would also need a
|
||||||
|
cross-process lock). This is recorded as an explicit exception to the dual
|
||||||
|
implementation rule.
|
||||||
|
- **Schedule format:** one or more times of day. On by default, once a day.
|
||||||
|
|
||||||
|
## Behaviour
|
||||||
|
|
||||||
|
- `SYNC_SCHEDULE` — comma-separated `HH:MM` times, default `04:00`.
|
||||||
|
`off` disables the scheduler. An invalid value makes `fuj server` exit at
|
||||||
|
startup with a clear error.
|
||||||
|
- `SYNC_TZ` — IANA zone the times are read in, default `Europe/Prague`.
|
||||||
|
The binary embeds the zone database (`import _ "time/tzdata"`), because the
|
||||||
|
Alpine image has none.
|
||||||
|
- At each scheduled time the server runs exactly what `/sync-bank` runs
|
||||||
|
(`banksync.DefaultWindow`, `SyncToSheets`, `InferPayments`, cache flush),
|
||||||
|
with a 10-minute timeout, and logs start, result and row counts via `slog`.
|
||||||
|
- Nothing runs at startup; a restart only recomputes the next run time.
|
||||||
|
- A scheduled run and a manual `/sync-bank` can no longer overlap: both go
|
||||||
|
through one mutex. If a sync is already running, the manual page shows
|
||||||
|
"a sync is already running" and a scheduled run is skipped with a log line.
|
||||||
|
(This also closes today's gap where two overlapping manual runs could append
|
||||||
|
the same rows.)
|
||||||
|
- The footer of every page shows the last sync since the server started:
|
||||||
|
time, trigger (scheduled / manual), ok or failed, and the next scheduled
|
||||||
|
run. Status is in memory only and resets on restart.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
Branch `feat/scheduled-bank-sync` off `main`; MR via `tea`.
|
||||||
|
|
||||||
|
1. **Config** — `go/internal/config/config.go`
|
||||||
|
- Add `SyncSchedule []TimeOfDay` and `SyncLocation *time.Location` to
|
||||||
|
`Config`.
|
||||||
|
- Add `ParseSyncSchedule(s string) ([]TimeOfDay, error)` (`"off"` → empty,
|
||||||
|
sorted, de-duplicated, rejects anything not `HH:MM`) and a
|
||||||
|
`LoadSyncSchedule() (…, error)` reading `SYNC_SCHEDULE` / `SYNC_TZ`.
|
||||||
|
`Load()` keeps its signature; `serverCmd` calls the new loader and exits
|
||||||
|
on error.
|
||||||
|
- New `config_test.go` for the parser (first tests in this package).
|
||||||
|
|
||||||
|
2. **Scheduler** — new package `go/internal/scheduler`
|
||||||
|
- `Next(now time.Time, times []TimeOfDay, loc *time.Location) time.Time` —
|
||||||
|
pure; builds candidates with `time.Date` in `loc` for today and tomorrow
|
||||||
|
and returns the earliest one strictly after `now` (handles DST days).
|
||||||
|
- `Run(ctx, times, loc, logger, job func(context.Context))` — loop:
|
||||||
|
compute `Next`, wait on a timer or `ctx.Done()`, call `job`.
|
||||||
|
- Tests: `Next` table test (before/after today's time, multiple times,
|
||||||
|
midnight rollover, the two DST switch days in Europe/Prague); `Run` with
|
||||||
|
a cancelled context returns.
|
||||||
|
|
||||||
|
3. **Sync runner with lock and status** — new
|
||||||
|
`go/internal/services/banksync/runner.go`
|
||||||
|
- `Runner` wraps the existing sync+infer function (moved out of the
|
||||||
|
closure in `go/cmd/fuj/main.go`) with a `sync.Mutex` (`TryLock`) and
|
||||||
|
records `Status{Started, Finished, Trigger, Err}`.
|
||||||
|
- `Run(ctx, trigger string, out io.Writer) error` returns `ErrBusy` when a
|
||||||
|
run is in progress. `Last() (Status, bool)` for display.
|
||||||
|
- Tests with a fake job: status recorded, error recorded, second
|
||||||
|
concurrent call gets `ErrBusy`.
|
||||||
|
|
||||||
|
4. **Wiring** — `go/cmd/fuj/main.go`
|
||||||
|
- Add `import _ "time/tzdata"`.
|
||||||
|
- Build one `Runner`; `ActionHandlers.BankSync` calls
|
||||||
|
`runner.Run(ctx, "manual", out)` (signature unchanged, so
|
||||||
|
`TestServeSync` keeps working).
|
||||||
|
- If the schedule is non-empty, start `go scheduler.Run(...)` whose job
|
||||||
|
calls `runner.Run(ctx, "scheduled", &buf)` with a 10-minute timeout, then
|
||||||
|
`sources.FlushCache()`, and logs the outcome. Log the schedule and next
|
||||||
|
run at startup, or "scheduled sync disabled".
|
||||||
|
|
||||||
|
5. **Display** — `go/internal/web`
|
||||||
|
- `ActionHandlers` gains optional `SyncInfo func() string` (last + next run
|
||||||
|
as one line). `PageData` gains `SyncInfo string`; add a small
|
||||||
|
`h.page(active)` helper in `html_handler.go` and use it where
|
||||||
|
`PageData{Active: …, Build: h.build}` is built today.
|
||||||
|
- `templates/partials/footer.tmpl`: append the line when non-empty.
|
||||||
|
- `templates/sync.tmpl`: fix the description, which still says "current
|
||||||
|
year" (stale since the window change).
|
||||||
|
- Extend `html_handler_test.go`: footer shows the info when provided,
|
||||||
|
nothing extra when not.
|
||||||
|
|
||||||
|
6. **Docs and notes**
|
||||||
|
- `CLAUDE.md`: document `SYNC_SCHEDULE` / `SYNC_TZ` under the Go config
|
||||||
|
list and the commands/architecture text; under "Dual implementation
|
||||||
|
rule" add a "Deliberately Go-only" list with this feature and the reason.
|
||||||
|
- `legacy/app.py`: one comment above `sync_bank()` stating that scheduled
|
||||||
|
sync exists only in Go, on purpose.
|
||||||
|
- `CHANGELOG.md` entry; copy this plan to
|
||||||
|
`docs/plans/2026-10-08-HHMM-scheduled-bank-sync.md`.
|
||||||
|
- Add a note to the cleanup task list: T18 (time zone) is addressed for
|
||||||
|
the scheduler only; the "current month" logic still uses the process
|
||||||
|
clock.
|
||||||
|
|
||||||
|
No new dependencies. No Dockerfile change. No change to parity fixtures or
|
||||||
|
the JSON API.
|
||||||
|
|
||||||
|
## Deployment notes (for the MR description)
|
||||||
|
|
||||||
|
- On by default at 04:00 Europe/Prague. Set `SYNC_SCHEDULE=off` to disable.
|
||||||
|
- A locally running `make web-go` with real credentials will also sync at
|
||||||
|
that time.
|
||||||
|
- With more than one replica each pod would run its own schedule; the
|
||||||
|
deployment must stay at one replica (or disable the schedule on extras).
|
||||||
|
- Without `FIO_API_TOKEN_*` the sync scrapes the transparent-account pages,
|
||||||
|
as today.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `make go-test-all` and `make go-lint` equivalent (`go vet`, `gofmt -l`);
|
||||||
|
`make test` to confirm Python is untouched.
|
||||||
|
2. Start `fuj server` with `SYNC_SCHEDULE=off` → log says disabled, footer
|
||||||
|
shows no schedule.
|
||||||
|
3. Start with an invalid value (`SYNC_SCHEDULE=25:00`) → exits with an error.
|
||||||
|
4. Live check: start with `SYNC_SCHEDULE` set two minutes ahead and watch the
|
||||||
|
log for the scheduled run and the footer for the status line. **This
|
||||||
|
performs one real sync** against the payments sheet (currently one pending
|
||||||
|
transaction would be appended) — the same thing a click on `/sync-bank`
|
||||||
|
does.
|
||||||
|
5. While that run is in progress, or with a test job, confirm a second
|
||||||
|
trigger reports "already running".
|
||||||
|
6. Build the Go image and confirm the schedule log line shows the Prague
|
||||||
|
zone inside the container (proves the embedded zone data works).
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Inference: messages starting with "other:" are not monthly fees
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Some payments are not monthly fees (tournament fees, jerseys, …). Reconcile
|
||||||
|
already supports them: a row whose **Purpose** starts with `other:` is attached
|
||||||
|
to the person's "other transactions" and is not counted against any month.
|
||||||
|
Today that only happens when someone types the purpose by hand, because
|
||||||
|
inference always tries to find a month — from the message, or failing that
|
||||||
|
from the payment date.
|
||||||
|
|
||||||
|
New rule: when the bank message for the recipient **starts with `other:`**,
|
||||||
|
inference must not look for a month. It copies the message into Purpose, so
|
||||||
|
the payment is linked to the person but not counted as a monthly fee. Messages
|
||||||
|
that merely contain `other:` somewhere else are handled the old way.
|
||||||
|
|
||||||
|
Implemented in both Go and legacy Python (this is inference behaviour, so the
|
||||||
|
dual implementation rule applies).
|
||||||
|
|
||||||
|
## Behaviour
|
||||||
|
|
||||||
|
- Trigger: message, after trimming whitespace, starts with `other:`
|
||||||
|
(case-insensitive, same test reconcile already uses for Purpose).
|
||||||
|
- Purpose written to the sheet: the trimmed message, unchanged
|
||||||
|
(e.g. `other: turnaj Praha`). No month parsing, no date fallback.
|
||||||
|
- Person: inferred exactly as today (sender + message + VS, then sender
|
||||||
|
alone), including the `[?]` review marker.
|
||||||
|
- Inferred Amount: the full amount, as today.
|
||||||
|
- No person found: Purpose is still written, Person stays empty. The row then
|
||||||
|
shows up under "unmatched" for a manual fix instead of being guessed again
|
||||||
|
on every run.
|
||||||
|
- Rows not yet processed by inference (Person and Purpose both empty) are
|
||||||
|
inferred on the fly by reconcile; that path follows the same rule, so such a
|
||||||
|
payment is never briefly counted as a monthly fee.
|
||||||
|
- Existing rows are untouched: inference only fills rows where manual fix,
|
||||||
|
Person and Purpose are all empty. To reprocess an old row, clear its Person
|
||||||
|
and Purpose cells.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
Branch `feat/infer-other-purpose` off `main`; MR via `tea`.
|
||||||
|
|
||||||
|
The output shape of the inference function does not change: for an "other"
|
||||||
|
message the months list holds the single purpose string. That is the same
|
||||||
|
convention reconcile already uses internally (`matchedMonths = [purpose]`),
|
||||||
|
and it means the sheet-writing code needs no change in either language.
|
||||||
|
|
||||||
|
1. **Shared predicate**
|
||||||
|
- Go: `matching.IsOtherPurpose(s string) bool` in
|
||||||
|
`go/internal/domain/matching/infer.go`
|
||||||
|
(`strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "other:")`).
|
||||||
|
- Python: `is_other_purpose(s)` in `legacy/scripts/match_payments.py`.
|
||||||
|
- Replace the two existing inline checks in reconcile with it
|
||||||
|
(`go/internal/domain/reconcile/reconcile.go`, `reconcile()` in
|
||||||
|
`match_payments.py`).
|
||||||
|
|
||||||
|
2. **Inference**
|
||||||
|
- Go `matching.InferTransactionDetails` and Python
|
||||||
|
`infer_transaction_details`: after member matching, if the message is an
|
||||||
|
other-purpose, return `months = [trimmed message]` and skip
|
||||||
|
`ParseMonthReferences` and the date fallback. Update the doc comments
|
||||||
|
(months may hold one `other:…` purpose).
|
||||||
|
- `banksync.InferPayments` (`go/internal/services/banksync/infer.go`) and
|
||||||
|
`infer_payments.py`: no logic change — they already join months into
|
||||||
|
Purpose and write the row when either members or months are non-empty.
|
||||||
|
|
||||||
|
3. **Reconcile fallback** (rows with empty Person/Purpose)
|
||||||
|
- In the inference-fallback branch of both reconcile implementations: if
|
||||||
|
the inferred months are a single other-purpose, set the row's purpose to
|
||||||
|
it and mark it as other, so it goes to the member's other-transactions
|
||||||
|
ledger instead of the month allocation.
|
||||||
|
|
||||||
|
4. **Tests and fixtures**
|
||||||
|
- Seeds in `legacy/scripts/_fixture_seeds.py`, captured with the existing
|
||||||
|
capture/scrub scripts for these two functions only:
|
||||||
|
- `infer_transaction_details`: `other_prefix` (message
|
||||||
|
`other: tournament`, member in sender → that purpose, no month),
|
||||||
|
`other_prefix_uppercase` (`Other: …`), `other_not_at_start`
|
||||||
|
(`leden other: x` → old behaviour, month parsed).
|
||||||
|
- `reconcile`: `11_other_message_fallback` (empty Person/Purpose, message
|
||||||
|
starts with `other:` → lands in `other_transactions`, month stays
|
||||||
|
unpaid).
|
||||||
|
- Go unit tests: `IsOtherPurpose` table; cases in
|
||||||
|
`matching/infer_test.go`; a case in `banksync/infer_test.go` asserting
|
||||||
|
the written row (`Person`, `Purpose = "other: …"`, amount) and one with
|
||||||
|
no matching member; a fallback case in `reconcile/reconcile_test.go`.
|
||||||
|
- Python: cases in `legacy/tests/test_match_payments.py` for
|
||||||
|
`infer_transaction_details` and the reconcile fallback.
|
||||||
|
- Existing fixtures must stay byte-identical apart from the new files.
|
||||||
|
|
||||||
|
5. **Docs**
|
||||||
|
- `CLAUDE.md`: add the rule to the payments-sheet / inference description.
|
||||||
|
- `CHANGELOG.md` entry; copy this plan to
|
||||||
|
`docs/plans/2026-10-08-HHMM-infer-other-purpose.md`.
|
||||||
|
|
||||||
|
No change to the JSON API, schemas, templates or the sheet layout.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `make go-test-all` (unit + parity, including the new fixtures) and
|
||||||
|
`make test`.
|
||||||
|
2. `git status go/tests/fixtures` shows only the four new fixture files.
|
||||||
|
3. Dry run against the live sheet in both implementations
|
||||||
|
(`fuj infer --dry-run`, Python `infer_payments.py --dry-run`): confirm it
|
||||||
|
runs cleanly and proposes nothing unexpected. The live sheet probably has
|
||||||
|
no un-inferred row starting with `other:`, so the new rule itself is
|
||||||
|
proven by the tests rather than by live data.
|
||||||
|
4. `make parity` between the two servers (or the two-container run used
|
||||||
|
earlier) still reports 3/3 routes matching.
|
||||||
+81
-5
@@ -1,20 +1,25 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"fuj-management/go/internal/config"
|
"fuj-management/go/internal/config"
|
||||||
"fuj-management/go/internal/io/fio"
|
"fuj-management/go/internal/io/fio"
|
||||||
"fuj-management/go/internal/io/sheets"
|
"fuj-management/go/internal/io/sheets"
|
||||||
"fuj-management/go/internal/logging"
|
"fuj-management/go/internal/logging"
|
||||||
|
"fuj-management/go/internal/scheduler"
|
||||||
"fuj-management/go/internal/services/banksync"
|
"fuj-management/go/internal/services/banksync"
|
||||||
"fuj-management/go/internal/services/membership"
|
"fuj-management/go/internal/services/membership"
|
||||||
"fuj-management/go/internal/web"
|
"fuj-management/go/internal/web"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
_ "time/tzdata" // the container image has no zone database; SYNC_TZ needs one
|
||||||
)
|
)
|
||||||
|
|
||||||
// Injected at build time via -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=..."
|
// Injected at build time via -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=..."
|
||||||
@@ -90,11 +95,17 @@ func serverCmd(args []string) {
|
|||||||
}
|
}
|
||||||
fioClients := buildFioClients(cfg)
|
fioClients := buildFioClients(cfg)
|
||||||
|
|
||||||
actions := web.ActionHandlers{
|
syncTimes, syncLoc, err := config.LoadSyncSchedule()
|
||||||
BankSync: func(ctx context.Context, out io.Writer) error {
|
if err != nil {
|
||||||
yr := time.Now().Year()
|
fmt.Fprintf(os.Stderr, "fuj server: %v\n", err)
|
||||||
from := time.Date(yr, 1, 1, 0, 0, 0, 0, time.UTC)
|
os.Exit(2)
|
||||||
to := time.Date(yr, 12, 31, 23, 59, 59, 0, time.UTC)
|
}
|
||||||
|
|
||||||
|
// One runner serves both the /sync-bank page and the scheduler, so the two
|
||||||
|
// can never run at the same time.
|
||||||
|
runner := banksync.NewRunner(
|
||||||
|
func(ctx context.Context, out io.Writer) error {
|
||||||
|
from, to := banksync.DefaultWindow(time.Now())
|
||||||
|
|
||||||
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
|
fmt.Fprintln(out, "=== Sync Fio Transactions ===")
|
||||||
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioClients, sheetsCli,
|
n, err := banksync.SyncToSheets(ctx, config.PaymentsSheetID, fioClients, sheetsCli,
|
||||||
@@ -112,6 +123,22 @@ func serverCmd(args []string) {
|
|||||||
fmt.Fprintf(out, "Inferred %d row(s).\n", n)
|
fmt.Fprintf(out, "Inferred %d row(s).\n", n)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
actions := web.ActionHandlers{
|
||||||
|
BankSync: func(ctx context.Context, out io.Writer) error {
|
||||||
|
return runner.Run(ctx, "manual", out)
|
||||||
|
},
|
||||||
|
SyncInfo: func() string { return syncInfo(runner, syncTimes, syncLoc, time.Now()) },
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(syncTimes) == 0 {
|
||||||
|
logger.Info("scheduled bank sync disabled (SYNC_SCHEDULE=off)")
|
||||||
|
} else {
|
||||||
|
logger.Info("scheduled bank sync enabled", "times", fmt.Sprint(syncTimes), "tz", syncLoc.String())
|
||||||
|
go scheduler.Run(ctx, syncTimes, syncLoc, logger, func(ctx context.Context) {
|
||||||
|
scheduledSync(ctx, logger, runner, sources)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
|
build := web.BuildInfo{Version: version, Commit: commit, BuildDate: buildDate}
|
||||||
@@ -122,6 +149,55 @@ func serverCmd(args []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scheduledSyncTimeout bounds one scheduled run (two bank fetches plus sheet writes).
|
||||||
|
const scheduledSyncTimeout = 10 * time.Minute
|
||||||
|
|
||||||
|
// scheduledSync is the scheduler's job: the same sync → infer → flush-cache
|
||||||
|
// sequence as the /sync-bank page, with the outcome written to the log.
|
||||||
|
func scheduledSync(ctx context.Context, logger *slog.Logger, runner *banksync.Runner, sources membership.Sources) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, scheduledSyncTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
logger.Info("scheduled bank sync starting")
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := runner.Run(ctx, "scheduled", &buf)
|
||||||
|
if errors.Is(err, banksync.ErrBusy) {
|
||||||
|
logger.Warn("scheduled bank sync skipped: a sync is already running")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
output := strings.Join(strings.Fields(buf.String()), " ")
|
||||||
|
if _, ferr := sources.FlushCache(); ferr != nil {
|
||||||
|
logger.Error("scheduled bank sync: cache flush failed", "err", ferr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("scheduled bank sync failed", "err", err, "output", output)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Info("scheduled bank sync finished", "output", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncInfo renders the footer line: the last run since the server started
|
||||||
|
// and the next scheduled one.
|
||||||
|
func syncInfo(runner *banksync.Runner, times []scheduler.TimeOfDay, loc *time.Location, now time.Time) string {
|
||||||
|
const layout = "2006-01-02 15:04"
|
||||||
|
var parts []string
|
||||||
|
if st, ok := runner.Last(); ok {
|
||||||
|
result := "ok"
|
||||||
|
if st.Err != nil {
|
||||||
|
result = "FAILED"
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("last sync %s (%s) %s", st.Finished.In(loc).Format(layout), st.Trigger, result))
|
||||||
|
} else {
|
||||||
|
parts = append(parts, "no sync since start")
|
||||||
|
}
|
||||||
|
if next := scheduler.Next(now, times, loc); !next.IsZero() {
|
||||||
|
parts = append(parts, "next "+next.Format(layout))
|
||||||
|
} else {
|
||||||
|
parts = append(parts, "scheduled sync off")
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " | ")
|
||||||
|
}
|
||||||
|
|
||||||
func feesCmd(args []string) {
|
func feesCmd(args []string) {
|
||||||
fs := flag.NewFlagSet("fees", flag.ExitOnError)
|
fs := flag.NewFlagSet("fees", flag.ExitOnError)
|
||||||
fs.Usage = func() {
|
fs.Usage = func() {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fuj-management/go/internal/scheduler"
|
||||||
|
"fuj-management/go/internal/services/banksync"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSyncInfo(t *testing.T) {
|
||||||
|
now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
|
||||||
|
four := []scheduler.TimeOfDay{{Hour: 4}}
|
||||||
|
|
||||||
|
ok := banksync.NewRunner(func(context.Context, io.Writer) error { return nil })
|
||||||
|
if got := syncInfo(ok, four, time.UTC, now); got != "no sync since start | next 2026-10-09 04:00" {
|
||||||
|
t.Errorf("before any run: %q", got)
|
||||||
|
}
|
||||||
|
if err := ok.Run(context.Background(), "scheduled", io.Discard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := syncInfo(ok, nil, time.UTC, now)
|
||||||
|
if !strings.HasPrefix(got, "last sync ") || !strings.HasSuffix(got, "(scheduled) ok | scheduled sync off") {
|
||||||
|
t.Errorf("after ok run, schedule off: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
bad := banksync.NewRunner(func(context.Context, io.Writer) error { return errors.New("boom") })
|
||||||
|
_ = bad.Run(context.Background(), "manual", io.Discard)
|
||||||
|
if got := syncInfo(bad, four, time.UTC, now); !strings.Contains(got, "(manual) FAILED | next 2026-10-09 04:00") {
|
||||||
|
t.Errorf("after failed run: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ var allRoutes = []string{"/api/adults", "/api/juniors", "/api/payments"}
|
|||||||
|
|
||||||
// defaultAllowlist holds dotted key paths to strip before diffing.
|
// defaultAllowlist holds dotted key paths to strip before diffing.
|
||||||
// render_time.total is forward-compatible insurance: today it lives in the Jinja
|
// 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
|
// 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.
|
// side ever surfaces it under a render_time envelope, the scrubber handles it.
|
||||||
var defaultAllowlist = []string{"render_time.total"}
|
var defaultAllowlist = []string{"render_time.total"}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"fuj-management/go/internal/scheduler"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -42,7 +44,7 @@ const (
|
|||||||
JuniorSheetGID = "1213318614" // gid=1213318614 — junior practices tab (IMPORTRANGE'd)
|
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.
|
// 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.
|
// Both attendance keys map to the same spreadsheet — different tabs, one Drive file.
|
||||||
var CacheSheetMap = map[string]string{
|
var CacheSheetMap = map[string]string{
|
||||||
@@ -53,7 +55,7 @@ var CacheSheetMap = map[string]string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Config holds all runtime configuration loaded from environment variables.
|
// Config holds all runtime configuration loaded from environment variables.
|
||||||
// Mirrors scripts/config.py.
|
// Mirrors legacy/scripts/config.py.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
CredentialsPath string
|
CredentialsPath string
|
||||||
QRAccount string // IBAN of the primary account used for QR codes
|
QRAccount string // IBAN of the primary account used for QR codes
|
||||||
@@ -92,6 +94,28 @@ func Load() Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scheduled bank sync defaults (Go server only; the legacy Python app has no scheduler).
|
||||||
|
const (
|
||||||
|
DefaultSyncSchedule = "04:00" // SYNC_SCHEDULE: comma-separated HH:MM times, or "off"
|
||||||
|
DefaultSyncTimezone = "Europe/Prague" // SYNC_TZ: IANA zone the schedule is read in
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadSyncSchedule reads SYNC_SCHEDULE and SYNC_TZ. An empty schedule means
|
||||||
|
// the scheduled sync is disabled. Resolving the zone needs a zone database:
|
||||||
|
// the server binary embeds one via time/tzdata.
|
||||||
|
func LoadSyncSchedule() ([]scheduler.TimeOfDay, *time.Location, error) {
|
||||||
|
times, err := scheduler.ParseTimes(env("SYNC_SCHEDULE", DefaultSyncSchedule))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("SYNC_SCHEDULE: %w", err)
|
||||||
|
}
|
||||||
|
tz := env("SYNC_TZ", DefaultSyncTimezone)
|
||||||
|
loc, err := time.LoadLocation(tz)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("SYNC_TZ: unknown time zone %q", tz)
|
||||||
|
}
|
||||||
|
return times, loc, nil
|
||||||
|
}
|
||||||
|
|
||||||
// IBANAccountNum extracts the bare account number from a Czech IBAN.
|
// IBANAccountNum extracts the bare account number from a Czech IBAN.
|
||||||
// "CZ8520100000002800359168" → "2800359168"
|
// "CZ8520100000002800359168" → "2800359168"
|
||||||
// Structure: CZ(2 check)(4 bank code)(16 zero-padded account).
|
// Structure: CZ(2 check)(4 bank code)(16 zero-padded account).
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadSyncSchedule(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name, schedule, tz string
|
||||||
|
wantTimes int
|
||||||
|
wantZone string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"defaults", "", "", 1, "Europe/Prague", false},
|
||||||
|
{"two times", "06:00,18:00", "", 2, "Europe/Prague", false},
|
||||||
|
{"off", "off", "", 0, "Europe/Prague", false},
|
||||||
|
{"other zone", "04:00", "UTC", 1, "UTC", false},
|
||||||
|
{"bad time", "25:00", "", 0, "", true},
|
||||||
|
{"bad zone", "04:00", "Mars/Olympus", 0, "", true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Setenv("SYNC_SCHEDULE", tc.schedule)
|
||||||
|
t.Setenv("SYNC_TZ", tc.tz)
|
||||||
|
times, loc, err := LoadSyncSchedule()
|
||||||
|
if (err != nil) != tc.wantErr {
|
||||||
|
t.Fatalf("err = %v, wantErr %v", err, tc.wantErr)
|
||||||
|
}
|
||||||
|
if tc.wantErr {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(times) != tc.wantTimes {
|
||||||
|
t.Errorf("times = %v, want %d entries", times, tc.wantTimes)
|
||||||
|
}
|
||||||
|
if loc.String() != tc.wantZone {
|
||||||
|
t.Errorf("zone = %s, want %s", loc, tc.wantZone)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Setenv("SYNC_SCHEDULE", "")
|
||||||
|
t.Setenv("SYNC_TZ", "")
|
||||||
|
if times, _, _ := LoadSyncSchedule(); len(times) != 1 || times[0].String() != DefaultSyncSchedule {
|
||||||
|
t.Errorf("default schedule = %v, want [%s]", times, DefaultSyncSchedule)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ func TestParseMonthReferences(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// All expected outputs verified against live Python implementation on 2026-05-05:
|
// 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 {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
|
|||||||
@@ -1,34 +1,37 @@
|
|||||||
// Package fees ports fee calculation from scripts/attendance.py.
|
// Package fees ports fee calculation from legacy/scripts/attendance.py.
|
||||||
package fees
|
package fees
|
||||||
|
|
||||||
const (
|
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
|
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.
|
// Months absent from this map fall back to AdultFeeDefault.
|
||||||
var AdultFeeMonthlyRate = map[string]int{
|
var AdultFeeMonthlyRate = map[string]int{
|
||||||
"2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750,
|
"2025-09": 750, "2025-10": 750, "2025-11": 750, "2025-12": 750,
|
||||||
"2026-01": 750, "2026-02": 750, "2026-03": 350,
|
"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
|
// CalculateFee returns the adult fee in CZK for attendanceCount practices in
|
||||||
// the given monthKey (format "YYYY-MM").
|
// the given monthKey (format "YYYY-MM").
|
||||||
//
|
//
|
||||||
// 0 practices → 0
|
// 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
|
// 2+ → AdultFeeMonthlyRate[monthKey] or AdultFeeDefault
|
||||||
func CalculateFee(attendanceCount int, monthKey string) int {
|
func CalculateFee(attendanceCount int, monthKey string) int {
|
||||||
if attendanceCount == 0 {
|
if attendanceCount == 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
rate, ok := AdultFeeMonthlyRate[monthKey]
|
||||||
|
if !ok {
|
||||||
|
rate = AdultFeeDefault
|
||||||
|
}
|
||||||
if attendanceCount == 1 {
|
if attendanceCount == 1 {
|
||||||
return AdultFeeSingle
|
return min(AdultFeeSingle, rate)
|
||||||
}
|
}
|
||||||
if rate, ok := AdultFeeMonthlyRate[monthKey]; ok {
|
return rate
|
||||||
return rate
|
|
||||||
}
|
|
||||||
return AdultFeeDefault
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,24 +5,42 @@ import "testing"
|
|||||||
func TestCalculateFee(t *testing.T) {
|
func TestCalculateFee(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// All expected outputs verified against live Python implementation on 2026-05-06:
|
// mustRate returns the configured rate for a month that must be in the map.
|
||||||
// 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,"")]])'
|
// 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 {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
count int
|
count int
|
||||||
month string
|
month string
|
||||||
want int
|
want int
|
||||||
}{
|
}{
|
||||||
|
// Zero attendance always returns 0.
|
||||||
{"zero short-circuits", 0, "2026-05", 0},
|
{"zero short-circuits", 0, "2026-05", 0},
|
||||||
{"zero empty month", 0, "", 0},
|
{"zero empty month", 0, "", 0},
|
||||||
{"single practice", 1, "2026-05", 200},
|
// Single practice returns AdultFeeSingle, capped at the month's rate.
|
||||||
{"single ignores monthKey", 1, "unknown", 200},
|
{"single practice", 1, "2026-05", AdultFeeSingle},
|
||||||
{"two practices configured month", 2, "2026-05", 700},
|
{"single unknown month", 1, "unknown", AdultFeeSingle},
|
||||||
{"two practices reduced march", 2, "2026-03", 350},
|
{"single in free month is free", 1, "2026-07", 0},
|
||||||
{"two practices early season", 2, "2025-09", 750},
|
{"two in free month is free", 2, "2026-08", 0},
|
||||||
{"high count same as two", 5, "2026-05", 700},
|
// Two+ practices for a configured month: must use the map value, not the default.
|
||||||
{"unknown future month falls back", 2, "2027-01", 700},
|
// Expected values are read from AdultFeeMonthlyRate so this test stays correct
|
||||||
{"empty month falls back", 2, "", 700},
|
// 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 {
|
for _, tc := range tests {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ package fees
|
|||||||
|
|
||||||
const JuniorFeeDefault = 500 // CZK fallback for 2+ practices when month not in JuniorFeeMonthlyRate
|
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.
|
// Months absent from this map fall back to JuniorFeeDefault.
|
||||||
var JuniorFeeMonthlyRate = map[string]int{
|
var JuniorFeeMonthlyRate = map[string]int{
|
||||||
"2025-09": 250,
|
"2025-09": 250,
|
||||||
|
|||||||
@@ -5,24 +5,37 @@ import "testing"
|
|||||||
func TestCalculateJuniorFee(t *testing.T) {
|
func TestCalculateJuniorFee(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// All expected outputs verified against live Python implementation on 2026-05-06:
|
// mustRate returns the configured rate for a month that must be in the map.
|
||||||
// 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,"")]])'
|
// 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 {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
count int
|
count int
|
||||||
month string
|
month string
|
||||||
want Expected
|
want Expected
|
||||||
}{
|
}{
|
||||||
|
// Zero attendance always returns 0.
|
||||||
{"zero short-circuits", 0, "2026-05", Expected{Value: 0}},
|
{"zero short-circuits", 0, "2026-05", Expected{Value: 0}},
|
||||||
{"zero empty month", 0, "", 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 practice sentinel", 1, "2026-05", Expected{Unknown: true}},
|
||||||
{"single ignores monthKey", 1, "unknown", Expected{Unknown: true}},
|
{"single ignores monthKey", 1, "unknown", Expected{Unknown: true}},
|
||||||
{"two practices default month", 2, "2026-05", Expected{Value: 500}},
|
// Two+ practices for a configured month: must use the map value, not the default.
|
||||||
{"two practices reduced sept", 2, "2025-09", Expected{Value: 250}},
|
{"two practices unconfigured month", 2, "2026-05", Expected{Value: JuniorFeeDefault}},
|
||||||
{"two practices reduced march", 2, "2026-03", Expected{Value: 250}},
|
{"two practices reduced sept", 2, "2025-09", mustRate("2025-09")},
|
||||||
{"high count same as two", 5, "2025-09", Expected{Value: 250}},
|
{"two practices reduced march", 2, "2026-03", mustRate("2026-03")},
|
||||||
{"unknown future month falls back", 2, "2027-01", Expected{Value: 500}},
|
{"high count same as two", 5, "2025-09", mustRate("2025-09")},
|
||||||
{"empty month falls back", 2, "", Expected{Value: 500}},
|
// 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 {
|
for _, tc := range tests {
|
||||||
|
|||||||
@@ -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
|
package matching
|
||||||
|
|||||||
@@ -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
|
// a pre-formatted "YYYY-MM-DD" string (returned as-is), or any other value
|
||||||
// (returned as fmt.Sprint(v).TrimSpace). Never returns an error.
|
// (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 {
|
func FormatDate(val any) string {
|
||||||
if val == nil {
|
if val == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package matching
|
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
|
// from match_payments import format_date
|
||||||
// for v in [None, "", 44197, 44197.5, "2026-04-15", "garbage", " 2026-04-15 "]:
|
// for v in [None, "", 44197, 44197.5, "2026-04-15", "garbage", " 2026-04-15 "]:
|
||||||
// print(repr(format_date(v)))
|
// print(repr(format_date(v)))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package matching
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"fuj-management/go/internal/domain/czech"
|
"fuj-management/go/internal/domain/czech"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,12 +18,22 @@ type Transaction struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// InferredDetails is the result of InferTransactionDetails.
|
// InferredDetails is the result of InferTransactionDetails.
|
||||||
|
// Months holds "YYYY-MM" keys, or exactly one "other:…" purpose when the
|
||||||
|
// payment is not a monthly fee (see IsOtherPurpose).
|
||||||
type InferredDetails struct {
|
type InferredDetails struct {
|
||||||
Members []Match
|
Members []Match
|
||||||
Months []string
|
Months []string
|
||||||
SearchText string
|
SearchText string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsOtherPurpose reports whether s marks a payment that is not a monthly fee:
|
||||||
|
// it starts with "other:" (case-insensitive, leading whitespace ignored).
|
||||||
|
// Used for both the Purpose column and the bank message.
|
||||||
|
// Ports legacy/scripts/match_payments.py is_other_purpose.
|
||||||
|
func IsOtherPurpose(s string) bool {
|
||||||
|
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), "other:")
|
||||||
|
}
|
||||||
|
|
||||||
// InferTransactionDetails infers which member(s) and month(s) a transaction belongs to.
|
// InferTransactionDetails infers which member(s) and month(s) a transaction belongs to.
|
||||||
//
|
//
|
||||||
// Search text for member matching: sender + message + user_id.
|
// Search text for member matching: sender + message + user_id.
|
||||||
@@ -30,20 +41,32 @@ type InferredDetails struct {
|
|||||||
// Fallback 1: if no members found, retry match on sender alone.
|
// Fallback 1: if no members found, retry match on sender alone.
|
||||||
// Fallback 2: if no months found, derive from tx.Date (Sheets serial or YYYY-MM-DD).
|
// Fallback 2: if no months found, derive from tx.Date (Sheets serial or YYYY-MM-DD).
|
||||||
//
|
//
|
||||||
|
// A message that starts with "other:" is not a monthly fee: no month is
|
||||||
|
// inferred (neither from the text nor from the date) and Months holds the
|
||||||
|
// trimmed message as the single purpose.
|
||||||
|
//
|
||||||
// defaultYear seeds czech.ParseMonthReferences (Python defaulted to the current year;
|
// defaultYear seeds czech.ParseMonthReferences (Python defaulted to the current year;
|
||||||
// callers should pass time.Now().Year() or a fixed year for deterministic tests).
|
// 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 {
|
func InferTransactionDetails(tx Transaction, memberNames []string, defaultYear int) InferredDetails {
|
||||||
searchText := fmt.Sprintf("%s %s %s", tx.Sender, tx.Message, tx.UserID)
|
searchText := fmt.Sprintf("%s %s %s", tx.Sender, tx.Message, tx.UserID)
|
||||||
|
|
||||||
members := MatchMembers(searchText, memberNames)
|
members := MatchMembers(searchText, memberNames)
|
||||||
months := czech.ParseMonthReferences(tx.Message+" "+tx.UserID, defaultYear)
|
|
||||||
|
|
||||||
if len(members) == 0 {
|
if len(members) == 0 {
|
||||||
members = MatchMembers(tx.Sender, memberNames)
|
members = MatchMembers(tx.Sender, memberNames)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if IsOtherPurpose(tx.Message) {
|
||||||
|
return InferredDetails{
|
||||||
|
Members: members,
|
||||||
|
Months: []string{strings.TrimSpace(tx.Message)},
|
||||||
|
SearchText: searchText,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
months := czech.ParseMonthReferences(tx.Message+" "+tx.UserID, defaultYear)
|
||||||
|
|
||||||
if len(months) == 0 && tx.Date != nil && tx.Date != "" {
|
if len(months) == 0 && tx.Date != nil && tx.Date != "" {
|
||||||
if ym := inferMonthFromDate(tx.Date); ym != "" {
|
if ym := inferMonthFromDate(tx.Date); ym != "" {
|
||||||
months = []string{ym}
|
months = []string{ym}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package matching
|
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
|
// from match_payments import infer_transaction_details
|
||||||
// MEMBERS = ["Tomáš Němeček (Tov)", "Jana Nováková"]
|
// MEMBERS = ["Tomáš Němeček (Tov)", "Jana Nováková"]
|
||||||
// cases = [
|
// cases = [
|
||||||
@@ -32,6 +32,74 @@ import (
|
|||||||
|
|
||||||
var inferMembers = []string{"Tomáš Němeček (Tov)", "Jana Nováková"}
|
var inferMembers = []string{"Tomáš Němeček (Tov)", "Jana Nováková"}
|
||||||
|
|
||||||
|
func TestIsOtherPurpose(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := map[string]bool{
|
||||||
|
"other: tournament": true,
|
||||||
|
"other:shirt": true,
|
||||||
|
" Other: Tournament": true,
|
||||||
|
"OTHER:x": true,
|
||||||
|
"leden other: x": false,
|
||||||
|
"other tournament": false,
|
||||||
|
"another: x": false,
|
||||||
|
"2026-01": false,
|
||||||
|
"": false,
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := IsOtherPurpose(in); got != want {
|
||||||
|
t.Errorf("IsOtherPurpose(%q) = %v, want %v", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInferTransactionDetails_OtherMessage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
jana := []Match{{Name: "Jana Nováková", Confidence: ConfidenceAuto}}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
tx Transaction
|
||||||
|
wantMembers []Match
|
||||||
|
wantMonths []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// Month words and the date must both be ignored.
|
||||||
|
name: "other prefix: message becomes the purpose, no month",
|
||||||
|
tx: Transaction{Sender: "Jana Novakova", Message: " other: turnaj leden 2026 ", Date: "2026-04-15"},
|
||||||
|
wantMembers: jana,
|
||||||
|
wantMonths: []string{"other: turnaj leden 2026"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "other prefix is case-insensitive and keeps the original text",
|
||||||
|
tx: Transaction{Sender: "Jana Novakova", Message: "Other: Dres", Date: "2026-04-15"},
|
||||||
|
wantMembers: jana,
|
||||||
|
wantMonths: []string{"Other: Dres"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "other prefix with no known member still yields the purpose",
|
||||||
|
tx: Transaction{Sender: "Unknown Payer", Message: "other: dres", Date: "2026-04-15"},
|
||||||
|
wantMembers: nil,
|
||||||
|
wantMonths: []string{"other: dres"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "other: not at the start is handled the old way",
|
||||||
|
tx: Transaction{Sender: "Jana Novakova", Message: "leden other: dres", Date: "2026-04-15"},
|
||||||
|
wantMembers: jana,
|
||||||
|
wantMonths: []string{"2026-01"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := InferTransactionDetails(tc.tx, inferMembers, 2026)
|
||||||
|
if len(got.Members) != len(tc.wantMembers) || (len(got.Members) > 0 && !reflect.DeepEqual(got.Members, tc.wantMembers)) {
|
||||||
|
t.Errorf("Members = %v, want %v", got.Members, tc.wantMembers)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got.Months, tc.wantMonths) {
|
||||||
|
t.Errorf("Months = %v, want %v", got.Months, tc.wantMonths)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInferTransactionDetails(t *testing.T) {
|
func TestInferTransactionDetails(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ var commonSurnames = map[string]bool{
|
|||||||
// MatchMembers finds members mentioned in text and returns them with a
|
// MatchMembers finds members mentioned in text and returns them with a
|
||||||
// confidence level of "auto" (reliable) or "review" (needs human verification).
|
// 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
|
// 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
|
// words in normalize(text), return ONLY those matches as auto. This prevents
|
||||||
// nickname "tov" from matching inside surname "ottova".
|
// nickname "tov" from matching inside surname "ottova".
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package matching
|
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:
|
// 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
|
// from match_payments import match_members
|
||||||
// MEMBERS = ["Henrietta Ottová", "Tomáš Němeček (Tov)", "František Vrbík (Štrúdl)", "Jana Nováková"]
|
// MEMBERS = ["Henrietta Ottová", "Tomáš Němeček (Tov)", "František Vrbík (Štrúdl)", "Jana Nováková"]
|
||||||
// cases = [
|
// cases = [
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ var (
|
|||||||
// this invariant for the exact short-circuit pass. Variants shorter than 3 characters are
|
// this invariant for the exact short-circuit pass. Variants shorter than 3 characters are
|
||||||
// dropped.
|
// dropped.
|
||||||
//
|
//
|
||||||
// Ports scripts/match_payments.py _build_name_variants.
|
// Ports legacy/scripts/match_payments.py _build_name_variants.
|
||||||
func BuildNameVariants(name string) []string {
|
func BuildNameVariants(name string) []string {
|
||||||
var nickname string
|
var nickname string
|
||||||
if m := nicknameRe.FindStringSubmatch(name); m != nil {
|
if m := nicknameRe.FindStringSubmatch(name); m != nil {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package matching
|
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
|
// from match_payments import _build_name_variants
|
||||||
// for n in ["František Vrbík (Štrúdl)", "Tov (St)", "Jana", " Petr Novák ( Jenda ) "]:
|
// for n in ["František Vrbík (Štrúdl)", "Tov (St)", "Jana", " Petr Novák ( Jenda ) "]:
|
||||||
// print(repr(n), "->", _build_name_variants(n))
|
// print(repr(n), "->", _build_name_variants(n))
|
||||||
|
|||||||
@@ -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
|
package money
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
var ErrInvalidAmount = errors.New("money: invalid CZK amount")
|
var ErrInvalidAmount = errors.New("money: invalid CZK amount")
|
||||||
|
|
||||||
// ParseCZK parses a Czech-locale amount string and returns the value in CZK
|
// 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)
|
// - empty input → (0, nil)
|
||||||
// - "Kč"/"CZK" suffixes stripped (case-sensitive, like Python)
|
// - "Kč"/"CZK" suffixes stripped (case-sensitive, like Python)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ func TestParseCZK(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
// All expected outputs verified against live Python implementation on 2026-05-06:
|
// 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
|
// from infer_payments import parse_czk_amount
|
||||||
// for v in [None, "", "0", "500", "500 Kč", "500 CZK",
|
// for v in [None, "", "0", "500", "500 Kč", "500 CZK",
|
||||||
// "1 500", "1500.00", "1 500.00",
|
// "1 500", "1500.00", "1 500.00",
|
||||||
|
|||||||
@@ -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
|
package reconcile
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -105,7 +105,7 @@ var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
|
|||||||
|
|
||||||
// canonicalMemberKey returns a diacritic-, case-, and whitespace-insensitive key
|
// canonicalMemberKey returns a diacritic-, case-, and whitespace-insensitive key
|
||||||
// used to resolve Person-column values that drift from canonical attendance-sheet names.
|
// 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 {
|
func canonicalMemberKey(name string) string {
|
||||||
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
|
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ type monthExpected struct {
|
|||||||
// defaultYear seeds czech.ParseMonthReferences in the inference fallback.
|
// defaultYear seeds czech.ParseMonthReferences in the inference fallback.
|
||||||
// Pass time.Now().Year() in production; pass a fixed year in tests.
|
// 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(
|
func Reconcile(
|
||||||
members []Member,
|
members []Member,
|
||||||
sortedMonths []string,
|
sortedMonths []string,
|
||||||
@@ -203,7 +203,7 @@ func Reconcile(
|
|||||||
personStr := strings.TrimSpace(tx.Person)
|
personStr := strings.TrimSpace(tx.Person)
|
||||||
purposeStr := strings.TrimSpace(tx.Purpose)
|
purposeStr := strings.TrimSpace(tx.Purpose)
|
||||||
personStr = questionMarkRe.ReplaceAllString(personStr, "")
|
personStr = questionMarkRe.ReplaceAllString(personStr, "")
|
||||||
isOther := strings.HasPrefix(strings.ToLower(purposeStr), "other:")
|
isOther := matching.IsOtherPurpose(purposeStr)
|
||||||
|
|
||||||
var matchedMembers []matching.Match
|
var matchedMembers []matching.Match
|
||||||
var matchedMonths []string
|
var matchedMonths []string
|
||||||
@@ -249,6 +249,11 @@ func Reconcile(
|
|||||||
matchedMembers = inferred.Members
|
matchedMembers = inferred.Members
|
||||||
matchedMonths = inferred.Months
|
matchedMonths = inferred.Months
|
||||||
amount = tx.Amount
|
amount = tx.Amount
|
||||||
|
// A message starting with "other:" is inferred as a non-fee payment.
|
||||||
|
if len(matchedMonths) == 1 && matching.IsOtherPurpose(matchedMonths[0]) {
|
||||||
|
isOther = true
|
||||||
|
purposeStr = matchedMonths[0]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(matchedMembers) == 0 || len(matchedMonths) == 0 {
|
if len(matchedMembers) == 0 || len(matchedMonths) == 0 {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package reconcile
|
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].
|
// All Python test cases are ported below. Additional Go-only cases are marked with [Go].
|
||||||
|
|
||||||
@@ -257,6 +257,33 @@ func TestReconcileQuestionMarkMarkerStripped(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// [Go] Purpose "other:shirt" puts payment in OtherTransactions, not in month ledger.
|
// [Go] Purpose "other:shirt" puts payment in OtherTransactions, not in month ledger.
|
||||||
|
// A row not yet processed by inference (empty Person/Purpose) whose message
|
||||||
|
// starts with "other:" must land in the other ledger, not in a month.
|
||||||
|
func TestReconcileOtherMessageFallback(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||||
|
txs := []Transaction{{
|
||||||
|
Date: "2026-01-10", Amount: 300,
|
||||||
|
Sender: "Alice", Message: "other: shirt",
|
||||||
|
}}
|
||||||
|
|
||||||
|
result := Reconcile(members, []string{"2026-01"}, txs, nil, 2026)
|
||||||
|
|
||||||
|
alice := result.Members["Alice"]
|
||||||
|
if paid := alice.Months["2026-01"].Paid; paid != 0 {
|
||||||
|
t.Errorf("2026-01 paid = %v, want 0", paid)
|
||||||
|
}
|
||||||
|
if len(alice.OtherTransactions) != 1 {
|
||||||
|
t.Fatalf("other transactions = %d, want 1", len(alice.OtherTransactions))
|
||||||
|
}
|
||||||
|
if got := alice.OtherTransactions[0]; got.Amount != 300 || got.Purpose != "other: shirt" {
|
||||||
|
t.Errorf("other entry = %+v", got)
|
||||||
|
}
|
||||||
|
if len(result.Unmatched) != 0 {
|
||||||
|
t.Errorf("unmatched = %d, want 0", len(result.Unmatched))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReconcileOtherPurpose(t *testing.T) {
|
func TestReconcileOtherPurpose(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
members := []Member{{Name: "Alice", Tier: "A", Fees: map[string]FeeData{"2026-01": {Expected: 750, Attendance: 3}}}}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package synch ports the bank-sync deduplication helper from
|
// Package synch ports the bank-sync deduplication helper from
|
||||||
// scripts/sync_fio_to_sheets.py.
|
// legacy/scripts/sync_fio_to_sheets.py.
|
||||||
package synch
|
package synch
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -30,7 +30,7 @@ type Transaction struct {
|
|||||||
// "date|amount|currency|sender|vs|message|bank_id" (lower-cased), used
|
// "date|amount|currency|sender|vs|message|bank_id" (lower-cased), used
|
||||||
// as the dedup key in column K of the payments sheet.
|
// 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 {
|
func GenerateSyncID(tx Transaction) string {
|
||||||
currency := tx.Currency
|
currency := tx.Currency
|
||||||
if currency == "" {
|
if currency == "" {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
// All expected digests verified against the live Python implementation on 2026-05-06:
|
// 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
|
// from sync_fio_to_sheets import generate_sync_id
|
||||||
// cases = [
|
// cases = [
|
||||||
// {"date":"2026-01-15","amount":500.0,"currency":"CZK","sender":"Jan Novak","vs":"123","message":"clenske 1/2026","bank_id":"abc123"},
|
// {"date":"2026-01-15","amount":500.0,"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:
|
// 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]:
|
// 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)))
|
// print(repr(v), "->", repr(str(v)))
|
||||||
// '
|
// '
|
||||||
|
|||||||
Vendored
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
// Package cache implements a Drive-modifiedTime-gated JSON file cache,
|
// Package cache implements a Drive-modifiedTime-gated JSON file cache,
|
||||||
// mirroring scripts/cache_utils.py.
|
// mirroring legacy/scripts/cache_utils.py.
|
||||||
package cache
|
package cache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -26,7 +26,7 @@ type cacheFile struct {
|
|||||||
|
|
||||||
// FileCache wraps a Drive client to gate JSON file caching on sheet modifiedTime.
|
// 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.
|
// - 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.
|
// - apiCheckTTL: debounces in-memory Drive API calls per sheet ID.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type httpDoer interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// apiClient fetches transactions from the Fio REST API (JSON).
|
// 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 {
|
type apiClient struct {
|
||||||
token string
|
token string
|
||||||
hc httpDoer
|
hc httpDoer
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// transparentClient fetches transactions from the Fio transparent account page (HTML).
|
// 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 {
|
type transparentClient struct {
|
||||||
accountNum string
|
accountNum string
|
||||||
hc httpDoer
|
hc httpDoer
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Package scheduler runs a job at fixed times of day.
|
||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TimeOfDay is a wall-clock time without a date.
|
||||||
|
type TimeOfDay struct {
|
||||||
|
Hour, Minute int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t TimeOfDay) String() string { return fmt.Sprintf("%02d:%02d", t.Hour, t.Minute) }
|
||||||
|
|
||||||
|
// ParseTimes parses a comma-separated list of HH:MM times, e.g. "04:00" or
|
||||||
|
// "06:00,18:00". "off" yields an empty schedule. The result is sorted and
|
||||||
|
// de-duplicated.
|
||||||
|
func ParseTimes(s string) ([]TimeOfDay, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if strings.EqualFold(s, "off") {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
seen := map[TimeOfDay]bool{}
|
||||||
|
var times []TimeOfDay
|
||||||
|
for _, part := range strings.Split(s, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
parsed, err := time.Parse("15:04", part)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid time %q: want HH:MM, a comma-separated list of them, or \"off\"", part)
|
||||||
|
}
|
||||||
|
t := TimeOfDay{Hour: parsed.Hour(), Minute: parsed.Minute()}
|
||||||
|
if !seen[t] {
|
||||||
|
seen[t] = true
|
||||||
|
times = append(times, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(times, func(i, j int) bool {
|
||||||
|
if times[i].Hour != times[j].Hour {
|
||||||
|
return times[i].Hour < times[j].Hour
|
||||||
|
}
|
||||||
|
return times[i].Minute < times[j].Minute
|
||||||
|
})
|
||||||
|
return times, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next returns the earliest scheduled instant strictly after now, with the
|
||||||
|
// times read in loc. It returns the zero time when times is empty.
|
||||||
|
func Next(now time.Time, times []TimeOfDay, loc *time.Location) time.Time {
|
||||||
|
local := now.In(loc)
|
||||||
|
var best time.Time
|
||||||
|
for dayOffset := 0; dayOffset <= 1; dayOffset++ {
|
||||||
|
for _, t := range times {
|
||||||
|
c := time.Date(local.Year(), local.Month(), local.Day()+dayOffset, t.Hour, t.Minute, 0, 0, loc)
|
||||||
|
if c.After(now) && (best.IsZero() || c.Before(best)) {
|
||||||
|
best = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// pollInterval bounds each wait so the loop follows the wall clock across
|
||||||
|
// clock adjustments and host suspend instead of relying on one long timer.
|
||||||
|
const pollInterval = time.Minute
|
||||||
|
|
||||||
|
// Run calls job at every scheduled time until ctx is cancelled. Nothing runs
|
||||||
|
// at startup. Runs do not overlap: the next time is computed after job returns.
|
||||||
|
func Run(ctx context.Context, times []TimeOfDay, loc *time.Location, logger *slog.Logger, job func(context.Context)) {
|
||||||
|
for {
|
||||||
|
next := Next(time.Now(), times, loc)
|
||||||
|
if next.IsZero() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Info("next scheduled run", "at", next.Format(time.RFC3339))
|
||||||
|
for {
|
||||||
|
wait := time.Until(next)
|
||||||
|
if wait <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(min(wait, pollInterval))
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
job(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package scheduler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseTimes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want []TimeOfDay
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"04:00", []TimeOfDay{{4, 0}}, false},
|
||||||
|
{" 18:30 , 06:00,06:00 ", []TimeOfDay{{6, 0}, {18, 30}}, false},
|
||||||
|
{"off", nil, false},
|
||||||
|
{"OFF", nil, false},
|
||||||
|
{"", nil, true},
|
||||||
|
{"25:00", nil, true},
|
||||||
|
{"04:60", nil, true},
|
||||||
|
{"daily", nil, true},
|
||||||
|
{"04:00,", nil, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got, err := ParseTimes(tc.in)
|
||||||
|
if (err != nil) != tc.wantErr {
|
||||||
|
t.Errorf("ParseTimes(%q): err = %v, wantErr %v", tc.in, err, tc.wantErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, tc.want) {
|
||||||
|
t.Errorf("ParseTimes(%q) = %v, want %v", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNext(t *testing.T) {
|
||||||
|
prague, err := time.LoadLocation("Europe/Prague")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
at := func(s string) time.Time {
|
||||||
|
v, err := time.ParseInLocation("2006-01-02 15:04:05", s, prague)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
four := []TimeOfDay{{4, 0}}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
now time.Time
|
||||||
|
times []TimeOfDay
|
||||||
|
want time.Time
|
||||||
|
}{
|
||||||
|
{"before today's time", at("2026-10-08 03:59:59"), four, at("2026-10-08 04:00:00")},
|
||||||
|
{"exactly at the time moves to tomorrow", at("2026-10-08 04:00:00"), four, at("2026-10-09 04:00:00")},
|
||||||
|
{"after today's time", at("2026-10-08 12:00:00"), four, at("2026-10-09 04:00:00")},
|
||||||
|
{"picks the next of several", at("2026-10-08 12:00:00"), []TimeOfDay{{6, 0}, {18, 0}}, at("2026-10-08 18:00:00")},
|
||||||
|
{"year rollover", at("2026-12-31 23:00:00"), four, at("2027-01-01 04:00:00")},
|
||||||
|
{"now given in UTC", at("2026-10-08 03:00:00").UTC(), four, at("2026-10-08 04:00:00")},
|
||||||
|
// Spring forward (2026-03-29 02:00 → 03:00): still 04:00 local, 23h after the previous run.
|
||||||
|
{"spring forward", at("2026-03-28 04:00:00"), four, at("2026-03-29 04:00:00")},
|
||||||
|
// Fall back (2026-10-25 03:00 → 02:00): still 04:00 local, 25h after the previous run.
|
||||||
|
{"fall back", at("2026-10-24 04:00:00"), four, at("2026-10-25 04:00:00")},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := Next(tc.now, tc.times, prague)
|
||||||
|
if !got.Equal(tc.want) {
|
||||||
|
t.Errorf("Next = %s, want %s", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if got := Next(at("2026-10-08 12:00:00"), nil, prague); !got.IsZero() {
|
||||||
|
t.Errorf("Next with empty schedule = %s, want zero", got)
|
||||||
|
}
|
||||||
|
if d := at("2026-03-29 04:00:00").Sub(at("2026-03-28 04:00:00")); d != 23*time.Hour {
|
||||||
|
t.Errorf("spring-forward gap = %s, want 23h", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunStopsOnCancelAndEmptySchedule(t *testing.T) {
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
called := false
|
||||||
|
job := func(context.Context) { called = true }
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { Run(context.Background(), nil, time.UTC, logger, job); close(done) }()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Run with empty schedule did not return")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done = make(chan struct{})
|
||||||
|
far := time.Now().UTC().Add(12 * time.Hour)
|
||||||
|
go func() {
|
||||||
|
Run(ctx, []TimeOfDay{{far.Hour(), far.Minute()}}, time.UTC, logger, job)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("Run did not return after cancel")
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Error("job must not run before its scheduled time")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ type sheetReadWriter interface {
|
|||||||
// InferPayments fills empty Person/Purpose/Inferred Amount cells in the payments
|
// InferPayments fills empty Person/Purpose/Inferred Amount cells in the payments
|
||||||
// sheet using name and month matching against the member list.
|
// sheet using name and month matching against the member list.
|
||||||
// Returns the number of rows updated (or that would be updated on dry-run).
|
// 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(
|
func InferPayments(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
spreadsheetID string,
|
spreadsheetID string,
|
||||||
@@ -94,7 +94,7 @@ func InferPayments(
|
|||||||
if idx < 0 || idx >= len(row) {
|
if idx < 0 || idx >= len(row) {
|
||||||
return ""
|
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
|
// Skip rule: any of manual fix / Person / Purpose non-empty → leave alone
|
||||||
|
|||||||
@@ -53,6 +53,42 @@ func TestInferPayments_BasicMatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInferPayments_OtherMessage(t *testing.T) {
|
||||||
|
sh := &sheets.Fake{Values: map[string][][]any{
|
||||||
|
"SHEETID/A1:Z": {
|
||||||
|
paymentsHeader,
|
||||||
|
// Known member: Person filled, message copied to Purpose, no month.
|
||||||
|
{"2026-04-10", 300.0, "", "", "", "", "Jana Novakova", "", "other: dres", "", ""},
|
||||||
|
// Unknown sender: Purpose is still written, Person stays empty.
|
||||||
|
{"2026-04-11", 500.0, "", "", "", "", "Unknown Payer", "", "Other: turnaj", "", ""},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
att := &fakeAttendance{
|
||||||
|
adults: []reconcile.Member{{Name: "Jana Novakova", Tier: "A"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := InferPayments(context.Background(), "SHEETID", sh, att, InferOpts{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 2 {
|
||||||
|
t.Fatalf("want 2 rows updated, got %d", n)
|
||||||
|
}
|
||||||
|
updates := sh.BatchUpdated[0].Updates
|
||||||
|
want := [][]any{
|
||||||
|
{"Jana Novakova", "other: dres", "300"},
|
||||||
|
{"", "Other: turnaj", "500"},
|
||||||
|
}
|
||||||
|
for i, w := range want {
|
||||||
|
got := updates[i].Values[0]
|
||||||
|
for j := range w {
|
||||||
|
if got[j] != w[j] {
|
||||||
|
t.Errorf("row %d col %d: want %q, got %q", i, j, w[j], got[j])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInferPayments_SkipRule_ManualFix(t *testing.T) {
|
func TestInferPayments_SkipRule_ManualFix(t *testing.T) {
|
||||||
sh := &sheets.Fake{Values: map[string][][]any{
|
sh := &sheets.Fake{Values: map[string][][]any{
|
||||||
"SHEETID/A1:Z": {
|
"SHEETID/A1:Z": {
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package banksync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrBusy is returned by Runner.Run when another run is still in progress.
|
||||||
|
var ErrBusy = errors.New("a sync is already running")
|
||||||
|
|
||||||
|
// RunStatus describes one finished run of a Runner.
|
||||||
|
type RunStatus struct {
|
||||||
|
Started, Finished time.Time
|
||||||
|
Trigger string // "manual" or "scheduled"
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner serialises bank sync runs so a scheduled run and a manual one never
|
||||||
|
// overlap (overlapping runs could append the same rows twice), and remembers
|
||||||
|
// the outcome of the last run for display.
|
||||||
|
type Runner struct {
|
||||||
|
job func(ctx context.Context, out io.Writer) error
|
||||||
|
|
||||||
|
running sync.Mutex
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
last RunStatus
|
||||||
|
hasLast bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRunner wraps job, which performs one full sync and writes a
|
||||||
|
// human-readable log to out.
|
||||||
|
func NewRunner(job func(ctx context.Context, out io.Writer) error) *Runner {
|
||||||
|
return &Runner{job: job}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes the job unless one is already running, in which case it
|
||||||
|
// returns ErrBusy without waiting.
|
||||||
|
func (r *Runner) Run(ctx context.Context, trigger string, out io.Writer) error {
|
||||||
|
if !r.running.TryLock() {
|
||||||
|
return ErrBusy
|
||||||
|
}
|
||||||
|
defer r.running.Unlock()
|
||||||
|
|
||||||
|
st := RunStatus{Started: time.Now(), Trigger: trigger}
|
||||||
|
st.Err = r.job(ctx, out)
|
||||||
|
st.Finished = time.Now()
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
r.last, r.hasLast = st, true
|
||||||
|
r.mu.Unlock()
|
||||||
|
return st.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last returns the outcome of the most recent finished run, if any.
|
||||||
|
func (r *Runner) Last() (RunStatus, bool) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return r.last, r.hasLast
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package banksync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunner_RecordsStatus(t *testing.T) {
|
||||||
|
r := NewRunner(func(context.Context, io.Writer) error { return nil })
|
||||||
|
if _, ok := r.Last(); ok {
|
||||||
|
t.Fatal("Last before any run: want ok=false")
|
||||||
|
}
|
||||||
|
if err := r.Run(context.Background(), "scheduled", io.Discard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
st, ok := r.Last()
|
||||||
|
if !ok || st.Trigger != "scheduled" || st.Err != nil || st.Finished.Before(st.Started) {
|
||||||
|
t.Errorf("unexpected status %+v ok=%v", st, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunner_RecordsError(t *testing.T) {
|
||||||
|
boom := errors.New("boom")
|
||||||
|
r := NewRunner(func(context.Context, io.Writer) error { return boom })
|
||||||
|
if err := r.Run(context.Background(), "manual", io.Discard); !errors.Is(err, boom) {
|
||||||
|
t.Fatalf("Run err = %v, want boom", err)
|
||||||
|
}
|
||||||
|
if st, _ := r.Last(); !errors.Is(st.Err, boom) || st.Trigger != "manual" {
|
||||||
|
t.Errorf("unexpected status %+v", st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunner_BusyWhileRunning(t *testing.T) {
|
||||||
|
started := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
first := true
|
||||||
|
r := NewRunner(func(context.Context, io.Writer) error {
|
||||||
|
if first { // runs are serialised by Runner, so no extra locking is needed
|
||||||
|
first = false
|
||||||
|
close(started)
|
||||||
|
<-release
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- r.Run(context.Background(), "scheduled", io.Discard) }()
|
||||||
|
<-started
|
||||||
|
|
||||||
|
if err := r.Run(context.Background(), "manual", io.Discard); !errors.Is(err, ErrBusy) {
|
||||||
|
t.Errorf("second Run err = %v, want ErrBusy", err)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
if err := <-done; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// The skipped run must not overwrite the status of the real one.
|
||||||
|
if st, _ := r.Last(); st.Trigger != "scheduled" {
|
||||||
|
t.Errorf("Last trigger = %q, want scheduled", st.Trigger)
|
||||||
|
}
|
||||||
|
if err := r.Run(context.Background(), "manual", io.Discard); err != nil {
|
||||||
|
t.Errorf("Run after release: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,13 +6,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"fuj-management/go/internal/domain/synch"
|
"fuj-management/go/internal/domain/synch"
|
||||||
"fuj-management/go/internal/io/fio"
|
"fuj-management/go/internal/io/fio"
|
||||||
|
"fuj-management/go/internal/io/sheets"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// columnLabels is the canonical header for the payments sheet.
|
// 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{
|
var columnLabels = []string{
|
||||||
"Date", "Amount", "manual fix", "Person", "Purpose",
|
"Date", "Amount", "manual fix", "Person", "Purpose",
|
||||||
"Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID",
|
"Inferred Amount", "Sender", "VS", "Message", "Bank ID", "Sync ID",
|
||||||
@@ -35,9 +36,21 @@ type SyncOpts struct {
|
|||||||
PrintFioTable bool // with DryRun: print every fetched Fio txn with NEW/DUP status
|
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
|
// SyncToSheets fetches Fio transactions from all provided clients and appends
|
||||||
// new ones to the payments sheet. Returns the number of rows appended.
|
// 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(
|
func SyncToSheets(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
spreadsheetID string,
|
spreadsheetID string,
|
||||||
@@ -130,7 +143,7 @@ func SyncToSheets(
|
|||||||
newRows = append(newRows, []any{
|
newRows = append(newRows, []any{
|
||||||
tx.Date, tx.Amount,
|
tx.Date, tx.Amount,
|
||||||
"", "", "", "", // manual fix, Person, Purpose, Inferred 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 {
|
if opts.DryRun {
|
||||||
for _, row := range newRows {
|
for _, row := range newRows {
|
||||||
fmt.Printf("Dry run: would append date=%v amount=%v sender=%v vs=%v message=%v\n",
|
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 {
|
if opts.Sort {
|
||||||
fmt.Println("Dry run: would sort by date")
|
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 {
|
if syncID, ok := rows[0][10].(string); !ok || len(syncID) != 64 {
|
||||||
t.Errorf("expected 64-char hex sync ID, got %v", rows[0][10])
|
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) {
|
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,
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// printFeesTable writes a fixed-width adult-fees table to w.
|
// 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:
|
// 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) {
|
func printFeesTable(w io.Writer, members []reconcile.Member, sortedMonths []string) {
|
||||||
type row struct {
|
type row struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package membership
|
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)
|
// (feed equivalent fixture data via attendance sheet or local CSV)
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// printReconcileReport writes the full balance report to w.
|
// 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:
|
// Verify with:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python -c '
|
// PYTHONPATH=legacy/scripts:legacy python -c '
|
||||||
// from match_payments import print_report, reconcile, fetch_sheet_data, fetch_exceptions
|
// from match_payments import print_report, reconcile, fetch_sheet_data, fetch_exceptions
|
||||||
// ...'
|
// ...'
|
||||||
func printReconcileReport(w io.Writer, result reconcile.Result, sortedMonths []string) {
|
func printReconcileReport(w io.Writer, result reconcile.Result, sortedMonths []string) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package membership
|
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
|
// from match_payments import print_report
|
||||||
// result = {
|
// result = {
|
||||||
// "members": {
|
// "members": {
|
||||||
|
|||||||
@@ -18,20 +18,20 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Attendance CSV column indices (mirrors COL_* in scripts/attendance.py)
|
// Attendance CSV column indices (mirrors COL_* in legacy/scripts/attendance.py)
|
||||||
const (
|
const (
|
||||||
colName = 0
|
colName = 0
|
||||||
colTier = 1
|
colTier = 1
|
||||||
firstDateCol = 3
|
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).
|
// Source month → target month (source attendance accumulated into target).
|
||||||
var AdultMergedMonths = map[string]string{
|
var AdultMergedMonths = map[string]string{
|
||||||
"2025-09": "2025-10",
|
"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{
|
var JuniorMergedMonths = map[string]string{
|
||||||
"2025-12": "2026-01",
|
"2025-12": "2026-01",
|
||||||
"2025-09": "2025-10",
|
"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.
|
// 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 {
|
func parseDates(header []string) []struct {
|
||||||
col int
|
col int
|
||||||
month string
|
month string
|
||||||
@@ -200,7 +200,7 @@ func countTrue(row []string, cols []int) int {
|
|||||||
|
|
||||||
// parseAdultRows converts raw CSV rows to []reconcile.Member.
|
// parseAdultRows converts raw CSV rows to []reconcile.Member.
|
||||||
// Includes all tiers; fee is 0 for non-A tiers (reconcile filters downstream).
|
// 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) {
|
func parseAdultRows(rows [][]string) ([]reconcile.Member, []string, error) {
|
||||||
if len(rows) < 2 {
|
if len(rows) < 2 {
|
||||||
return nil, nil, nil
|
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
|
// parseJuniorRows builds junior members by merging tier-J rows from the adult tab
|
||||||
// with the junior sheet, then calling CalculateJuniorFee.
|
// 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) {
|
func parseJuniorRows(adultRows, juniorRows [][]string) ([]reconcile.Member, []string, error) {
|
||||||
if len(adultRows) < 2 || len(juniorRows) < 2 {
|
if len(adultRows) < 2 || len(juniorRows) < 2 {
|
||||||
return nil, nil, nil
|
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) {
|
func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
||||||
@@ -402,7 +402,7 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
|||||||
if i < 0 || i >= len(row) {
|
if i < 0 || i >= len(row) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return fmt.Sprint(row[i])
|
return sheets.CellString(row[i])
|
||||||
}
|
}
|
||||||
|
|
||||||
// getRaw returns row[i] without stringifying — needed for FormatDate to
|
// getRaw returns row[i] without stringifying — needed for FormatDate to
|
||||||
@@ -425,7 +425,7 @@ func parseTransactionRows(rows [][]any) ([]reconcile.Transaction, error) {
|
|||||||
amount := parseFloat(amountRaw)
|
amount := parseFloat(amountRaw)
|
||||||
|
|
||||||
var inferredAmount *float64
|
var inferredAmount *float64
|
||||||
if iv := getVal(row, idxInferred); iv != "" && iv != "<nil>" {
|
if iv := getVal(row, idxInferred); iv != "" {
|
||||||
if f := parseFloat(iv); f != 0 {
|
if f := parseFloat(iv); f != 0 {
|
||||||
inferredAmount = &f
|
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 {
|
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"},
|
{"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"},
|
{"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
|
{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)
|
s := buildSources(t, &attendance.Fake{}, sh)
|
||||||
@@ -131,8 +133,8 @@ func TestLoadTransactions(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(txns) != 2 {
|
if len(txns) != 3 {
|
||||||
t.Fatalf("want 2 transactions, got %d", len(txns))
|
t.Fatalf("want 3 transactions, got %d", len(txns))
|
||||||
}
|
}
|
||||||
if txns[0].Person != "Alice" {
|
if txns[0].Person != "Alice" {
|
||||||
t.Errorf("txn[0].Person: want Alice, got %q", txns[0].Person)
|
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" {
|
if txns[1].Date != "2026-05-05" {
|
||||||
t.Errorf("txn[1].Date (numeric serial-day): want 2026-05-05, got %q", txns[1].Date)
|
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) {
|
func TestLoadExceptions(t *testing.T) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
type monthSums struct{ expected, paid int }
|
type monthSums struct{ expected, paid int }
|
||||||
|
|
||||||
// buildAdultsResponse constructs the AdultsResponse wire type from reconcile output.
|
// 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(
|
func buildAdultsResponse(
|
||||||
members []domreconcile.Member,
|
members []domreconcile.Member,
|
||||||
sortedMonths []string,
|
sortedMonths []string,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// getMonthLabels builds display labels for sortedMonths, merging month names
|
// getMonthLabels builds display labels for sortedMonths, merging month names
|
||||||
// (e.g. "Dec+Jan 2026") when mergedMonths maps a source month into this target.
|
// (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 {
|
func getMonthLabels(sortedMonths []string, mergedMonths map[string]string) map[string]string {
|
||||||
labels := make(map[string]string, len(sortedMonths))
|
labels := make(map[string]string, len(sortedMonths))
|
||||||
for _, m := range sortedMonths {
|
for _, m := range sortedMonths {
|
||||||
@@ -72,14 +72,14 @@ func labelsForMonths(sortedMonths []string, labels map[string]string) []string {
|
|||||||
var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
|
var questionMarkRe = regexp.MustCompile(`\[\?\]\s*`)
|
||||||
|
|
||||||
// canonicalKey returns a normalized form of a person name used for deduplication.
|
// 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 {
|
func canonicalKey(name string) string {
|
||||||
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
|
return strings.Join(strings.Fields(czech.Normalize(name)), " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// groupRawPaymentsByPerson groups transactions by the "person" column,
|
// groupRawPaymentsByPerson groups transactions by the "person" column,
|
||||||
// canonicalizing names against memberNames where possible.
|
// 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).
|
// "Unmatched / Unknown" bucket that is payments-view-specific).
|
||||||
func groupRawPaymentsByPerson(txns []domreconcile.Transaction, memberNames []string) map[string][]RawTransaction {
|
func groupRawPaymentsByPerson(txns []domreconcile.Transaction, memberNames []string) map[string][]RawTransaction {
|
||||||
canonicalByKey := make(map[string]string, len(memberNames))
|
canonicalByKey := make(map[string]string, len(memberNames))
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// buildJuniorsResponse constructs the JuniorsResponse wire type from reconcile output.
|
// 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(
|
func buildJuniorsResponse(
|
||||||
members []domreconcile.Member,
|
members []domreconcile.Member,
|
||||||
sortedMonths []string,
|
sortedMonths []string,
|
||||||
@@ -242,7 +242,12 @@ func buildJuniorMemberRow(
|
|||||||
|
|
||||||
tooltip := ""
|
tooltip := ""
|
||||||
if (!md.IsUnknown && md.Expected > 0) || paid > 0 {
|
if (!md.IsUnknown && md.Expected > 0) || paid > 0 {
|
||||||
tooltip = fmt.Sprintf("Received: %d, Expected: %d", paid, md.Expected)
|
// Unknown expected (one session, manual review) shows as "?", as in Python.
|
||||||
|
expectedText := strconv.Itoa(md.Expected)
|
||||||
|
if md.IsUnknown {
|
||||||
|
expectedText = "?"
|
||||||
|
}
|
||||||
|
tooltip = fmt.Sprintf("Received: %d, Expected: %s", paid, expectedText)
|
||||||
}
|
}
|
||||||
|
|
||||||
row.Months = append(row.Months, MonthCell{
|
row.Months = append(row.Months, MonthCell{
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// buildPaymentsResponse constructs the PaymentsResponse wire type.
|
// 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(
|
func buildPaymentsResponse(
|
||||||
txns []domreconcile.Transaction,
|
txns []domreconcile.Transaction,
|
||||||
memberNames []string,
|
memberNames []string,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
// VersionResponse is the JSON contract for GET /api/version.
|
// 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 {
|
type VersionResponse struct {
|
||||||
Tag string `json:"tag"`
|
Tag string `json:"tag"`
|
||||||
Commit string `json:"commit"`
|
Commit string `json:"commit"`
|
||||||
|
|||||||
@@ -21,17 +21,26 @@ func NewHTMLHandler(r *Renderer, b BuildInfo, ah *api.Handler, actions ActionHan
|
|||||||
return &HTMLHandler{renderer: r, build: b, apiHandler: ah, actions: actions}
|
return &HTMLHandler{renderer: r, build: b, apiHandler: ah, actions: actions}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// page builds the data shared by every page (nav highlight, build info, sync summary).
|
||||||
|
func (h *HTMLHandler) page(active string) PageData {
|
||||||
|
pd := PageData{Active: active, Build: h.build}
|
||||||
|
if h.actions.SyncInfo != nil {
|
||||||
|
pd.SyncInfo = h.actions.SyncInfo()
|
||||||
|
}
|
||||||
|
return pd
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
func (h *HTMLHandler) ServeAdults(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := h.apiHandler.AssembleAdults(r.Context())
|
data, err := h.apiHandler.AssembleAdults(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderer.Render(w, "adults", AdultsPageData{
|
h.renderer.Render(w, "adults", AdultsPageData{
|
||||||
PageData: PageData{Active: "adults", Build: h.build},
|
PageData: h.page("adults"),
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.renderer.Render(w, "adults", AdultsPageData{
|
h.renderer.Render(w, "adults", AdultsPageData{
|
||||||
PageData: PageData{Active: "adults", Build: h.build},
|
PageData: h.page("adults"),
|
||||||
Data: data,
|
Data: data,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -40,13 +49,13 @@ func (h *HTMLHandler) ServeJuniors(w http.ResponseWriter, r *http.Request) {
|
|||||||
data, err := h.apiHandler.AssembleJuniors(r.Context())
|
data, err := h.apiHandler.AssembleJuniors(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderer.Render(w, "juniors", JuniorsPageData{
|
h.renderer.Render(w, "juniors", JuniorsPageData{
|
||||||
PageData: PageData{Active: "juniors", Build: h.build},
|
PageData: h.page("juniors"),
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.renderer.Render(w, "juniors", JuniorsPageData{
|
h.renderer.Render(w, "juniors", JuniorsPageData{
|
||||||
PageData: PageData{Active: "juniors", Build: h.build},
|
PageData: h.page("juniors"),
|
||||||
Data: data,
|
Data: data,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -55,20 +64,20 @@ func (h *HTMLHandler) ServePayments(w http.ResponseWriter, r *http.Request) {
|
|||||||
data, err := h.apiHandler.AssemblePayments(r.Context())
|
data, err := h.apiHandler.AssemblePayments(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.renderer.Render(w, "payments", PaymentsPageData{
|
h.renderer.Render(w, "payments", PaymentsPageData{
|
||||||
PageData: PageData{Active: "payments", Build: h.build},
|
PageData: h.page("payments"),
|
||||||
Error: err.Error(),
|
Error: err.Error(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.renderer.Render(w, "payments", PaymentsPageData{
|
h.renderer.Render(w, "payments", PaymentsPageData{
|
||||||
PageData: PageData{Active: "payments", Build: h.build},
|
PageData: h.page("payments"),
|
||||||
Data: data,
|
Data: data,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeSync handles GET /sync-bank: runs sync+infer+flush then renders the result.
|
// ServeSync handles GET /sync-bank: runs sync+infer+flush then renders the result.
|
||||||
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
|
func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
|
||||||
pd := PageData{Active: "sync", Build: h.build}
|
pd := h.page("sync")
|
||||||
|
|
||||||
if h.actions.BankSync == nil {
|
if h.actions.BankSync == nil {
|
||||||
h.renderer.Render(w, "sync", SyncPageData{
|
h.renderer.Render(w, "sync", SyncPageData{
|
||||||
@@ -96,7 +105,7 @@ func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.renderer.Render(w, "sync", SyncPageData{
|
h.renderer.Render(w, "sync", SyncPageData{
|
||||||
PageData: pd,
|
PageData: h.page("sync"), // rebuilt so the footer reflects this run
|
||||||
Output: buf.String(),
|
Output: buf.String(),
|
||||||
Success: success,
|
Success: success,
|
||||||
})
|
})
|
||||||
@@ -105,7 +114,7 @@ func (h *HTMLHandler) ServeSync(w http.ResponseWriter, r *http.Request) {
|
|||||||
// ServeFlushCacheGET handles GET /flush-cache: renders the confirmation form.
|
// ServeFlushCacheGET handles GET /flush-cache: renders the confirmation form.
|
||||||
func (h *HTMLHandler) ServeFlushCacheGET(w http.ResponseWriter, r *http.Request) {
|
func (h *HTMLHandler) ServeFlushCacheGET(w http.ResponseWriter, r *http.Request) {
|
||||||
h.renderer.Render(w, "flush_cache", FlushPageData{
|
h.renderer.Render(w, "flush_cache", FlushPageData{
|
||||||
PageData: PageData{Active: "flush", Build: h.build},
|
PageData: h.page("flush"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +122,7 @@ func (h *HTMLHandler) ServeFlushCacheGET(w http.ResponseWriter, r *http.Request)
|
|||||||
func (h *HTMLHandler) ServeFlushCachePOST(w http.ResponseWriter, r *http.Request) {
|
func (h *HTMLHandler) ServeFlushCachePOST(w http.ResponseWriter, r *http.Request) {
|
||||||
n, _ := h.apiHandler.FlushCache()
|
n, _ := h.apiHandler.FlushCache()
|
||||||
h.renderer.Render(w, "flush_cache", FlushPageData{
|
h.renderer.Render(w, "flush_cache", FlushPageData{
|
||||||
PageData: PageData{Active: "flush", Build: h.build},
|
PageData: h.page("flush"),
|
||||||
Flushed: true,
|
Flushed: true,
|
||||||
Deleted: n,
|
Deleted: n,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -314,6 +314,28 @@ func TestServeSync(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFooterSyncInfo(t *testing.T) {
|
||||||
|
renderer, err := web.NewRenderer()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRenderer: %v", err)
|
||||||
|
}
|
||||||
|
b := web.BuildInfo{Version: "v0", Commit: "abc1234", BuildDate: "2026-01-01"}
|
||||||
|
get := func(actions web.ActionHandlers) string {
|
||||||
|
h := web.NewHTMLHandler(renderer, b, fixtureHandler(t), actions)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeFlushCacheGET(w, httptest.NewRequest(http.MethodGet, "/flush-cache", nil))
|
||||||
|
return w.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = "last sync 2026-10-08 04:00 (scheduled) ok"
|
||||||
|
if body := get(web.ActionHandlers{SyncInfo: func() string { return info }}); !strings.Contains(body, "| "+info) {
|
||||||
|
t.Errorf("footer missing sync info %q", info)
|
||||||
|
}
|
||||||
|
if body := get(web.ActionHandlers{}); strings.Contains(body, "last sync") {
|
||||||
|
t.Errorf("footer shows sync info although none was provided")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPaymentQRMarkup(t *testing.T) {
|
func TestPaymentQRMarkup(t *testing.T) {
|
||||||
renderer, err := web.NewRenderer()
|
renderer, err := web.NewRenderer()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import (
|
|||||||
|
|
||||||
// PageData is the view model passed to every HTML template.
|
// PageData is the view model passed to every HTML template.
|
||||||
type PageData struct {
|
type PageData struct {
|
||||||
Active string
|
Active string
|
||||||
Build BuildInfo
|
Build BuildInfo
|
||||||
|
SyncInfo string // last/next bank sync summary shown in the footer; empty hides it
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdultsPageData is the view model for the /adults HTML page.
|
// AdultsPageData is the view model for the /adults HTML page.
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ type ActionHandlers struct {
|
|||||||
// BankSync runs sync+infer and writes a human-readable log to out.
|
// BankSync runs sync+infer and writes a human-readable log to out.
|
||||||
// nil disables the /sync-bank action (renders an error instead).
|
// nil disables the /sync-bank action (renders an error instead).
|
||||||
BankSync func(ctx context.Context, out io.Writer) error
|
BankSync func(ctx context.Context, out io.Writer) error
|
||||||
|
// SyncInfo returns a one-line summary of the last and next bank sync for
|
||||||
|
// the page footer. nil or an empty result shows nothing.
|
||||||
|
SyncInfo func() string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run registers routes and starts the HTTP server on addr.
|
// Run registers routes and starts the HTTP server on addr.
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{{define "footer"}}
|
{{define "footer"}}
|
||||||
<div class="footer">{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}</div>
|
<div class="footer">{{.Build.Version}}@{{.Build.Commit}} | built {{.Build.BuildDate}}{{if .SyncInfo}} | {{.SyncInfo}}{{end}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
<pre class="sync-log{{if not .Success}} sync-log--error{{end}}">{{.Output}}</pre>
|
<pre class="sync-log{{if not .Success}} sync-log--error{{end}}">{{.Output}}</pre>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="description">Fetches Fio transactions for the current year, infers payment details, and flushes the cache.</p>
|
<p class="description">Fetches Fio transactions for the current year and the last two months of the previous one, infers payment details, and flushes the cache.</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
<p><a href="/sync-bank" class="btn">{{if .Output}}Run Again{{else}}Run Sync{{end}}</a></p>
|
<p><a href="/sync-bank" class="btn">{{if .Output}}Run Again{{else}}Run Sync{{end}}</a></p>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
Vendored
+3
-3
@@ -85,10 +85,10 @@ git diff go/tests/fixtures/ # review changes before committing
|
|||||||
To refresh a single function:
|
To refresh a single function:
|
||||||
|
|
||||||
```bash
|
```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
|
| while IFS= read -r line; do
|
||||||
id=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin)['case'])")
|
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
|
> go/tests/fixtures/pure/normalize/${id}.json
|
||||||
done
|
done
|
||||||
```
|
```
|
||||||
@@ -120,7 +120,7 @@ verify that no real roster names appear in their `member_names` arrays.
|
|||||||
|
|
||||||
## Adding a new fixture
|
## 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
|
2. Add `In`/`Out` struct fields to `go/tests/parity/parityio.go` if the function
|
||||||
is new.
|
is new.
|
||||||
3. Run the single-file capture recipe above and review the diff.
|
3. Run the single-file capture recipe above and review the diff.
|
||||||
|
|||||||
@@ -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",
|
"case": "two_sessions_default_fallback",
|
||||||
"func": "scripts.attendance.calculate_fee",
|
"func": "scripts.attendance.calculate_fee",
|
||||||
"captured_at": "2026-05-06",
|
"captured_at": "2026-10-08",
|
||||||
"input": {
|
"input": {
|
||||||
"attendance_count": 2,
|
"attendance_count": 2,
|
||||||
"month_key": "2099-01"
|
"month_key": "2099-01"
|
||||||
},
|
},
|
||||||
"output": {
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"case": "other_not_at_start",
|
||||||
|
"func": "scripts.match_payments.infer_transaction_details",
|
||||||
|
"captured_at": "2026-10-08",
|
||||||
|
"input": {
|
||||||
|
"tx": {
|
||||||
|
"sender": "Jana Novakova",
|
||||||
|
"message": "leden other: dres",
|
||||||
|
"user_id": "",
|
||||||
|
"date": {
|
||||||
|
"type": "string",
|
||||||
|
"value": "2026-04-15"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"member_names": [
|
||||||
|
"Alžběta Testovická",
|
||||||
|
"Tomáš Fiktivný (Tov)",
|
||||||
|
"Pavel Smutný (Štrúdl)",
|
||||||
|
"Jana Nováková",
|
||||||
|
"Adam Novák"
|
||||||
|
],
|
||||||
|
"default_year": 2026
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"matches": [
|
||||||
|
{
|
||||||
|
"name": "Jana Nováková",
|
||||||
|
"confidence": "auto"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"months": [
|
||||||
|
"2026-01"
|
||||||
|
],
|
||||||
|
"search_text": "Jana Novakova leden other: dres "
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"case": "other_prefix",
|
||||||
|
"func": "scripts.match_payments.infer_transaction_details",
|
||||||
|
"captured_at": "2026-10-08",
|
||||||
|
"input": {
|
||||||
|
"tx": {
|
||||||
|
"sender": "Jana Novakova",
|
||||||
|
"message": "other: tournament leden 2026",
|
||||||
|
"user_id": "",
|
||||||
|
"date": {
|
||||||
|
"type": "string",
|
||||||
|
"value": "2026-04-15"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"member_names": [
|
||||||
|
"Alžběta Testovická",
|
||||||
|
"Tomáš Fiktivný (Tov)",
|
||||||
|
"Pavel Smutný (Štrúdl)",
|
||||||
|
"Jana Nováková",
|
||||||
|
"Adam Novák"
|
||||||
|
],
|
||||||
|
"default_year": 2026
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"matches": [
|
||||||
|
{
|
||||||
|
"name": "Jana Nováková",
|
||||||
|
"confidence": "auto"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"months": [
|
||||||
|
"other: tournament leden 2026"
|
||||||
|
],
|
||||||
|
"search_text": "Jana Novakova other: tournament leden 2026 "
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"case": "other_prefix_uppercase",
|
||||||
|
"func": "scripts.match_payments.infer_transaction_details",
|
||||||
|
"captured_at": "2026-10-08",
|
||||||
|
"input": {
|
||||||
|
"tx": {
|
||||||
|
"sender": "Test Payer",
|
||||||
|
"message": " Other: Dres ",
|
||||||
|
"user_id": "",
|
||||||
|
"date": {
|
||||||
|
"type": "string",
|
||||||
|
"value": "2026-04-15"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"member_names": [
|
||||||
|
"Alžběta Testovická",
|
||||||
|
"Tomáš Fiktivný (Tov)",
|
||||||
|
"Pavel Smutný (Štrúdl)",
|
||||||
|
"Jana Nováková",
|
||||||
|
"Adam Novák"
|
||||||
|
],
|
||||||
|
"default_year": 2026
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"matches": [],
|
||||||
|
"months": [
|
||||||
|
"Other: Dres"
|
||||||
|
],
|
||||||
|
"search_text": "Test Payer Other: Dres "
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"case": "11_other_message_fallback",
|
||||||
|
"func": "scripts.match_payments.reconcile",
|
||||||
|
"captured_at": "2026-10-08",
|
||||||
|
"input": {
|
||||||
|
"members": [
|
||||||
|
{
|
||||||
|
"name": "Member_d035d9f9",
|
||||||
|
"tier": "A",
|
||||||
|
"fees": {
|
||||||
|
"2026-01": [
|
||||||
|
750,
|
||||||
|
3
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sorted_months": [
|
||||||
|
"2026-01"
|
||||||
|
],
|
||||||
|
"transactions": [
|
||||||
|
{
|
||||||
|
"date": "2026-01-10",
|
||||||
|
"amount": 300,
|
||||||
|
"manual_fix": "",
|
||||||
|
"person": "",
|
||||||
|
"purpose": "",
|
||||||
|
"inferred_amount": 300,
|
||||||
|
"sender": "Member_d035d9f9",
|
||||||
|
"message": "other: tournament",
|
||||||
|
"bank_id": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"exceptions": [],
|
||||||
|
"default_year": 2026
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"members": {
|
||||||
|
"Member_d035d9f9": {
|
||||||
|
"tier": "A",
|
||||||
|
"months": {
|
||||||
|
"2026-01": {
|
||||||
|
"expected": 750,
|
||||||
|
"original_expected": 750,
|
||||||
|
"attendance_count": 3,
|
||||||
|
"exception": null,
|
||||||
|
"paid": 0.0,
|
||||||
|
"transactions": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"other_transactions": [
|
||||||
|
{
|
||||||
|
"amount": 300.0,
|
||||||
|
"date": "2026-01-10",
|
||||||
|
"sender": "Member_d035d9f9",
|
||||||
|
"message": "other: tournament",
|
||||||
|
"purpose": "other: tournament",
|
||||||
|
"confidence": "auto"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_balance": -750
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"unmatched": [],
|
||||||
|
"credits": {
|
||||||
|
"Member_d035d9f9": -750
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// 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) {
|
func TestBuildNameVariantsParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// 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) {
|
func TestCalculateFeeParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// 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) {
|
func TestCalculateJuniorFeeParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// 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) {
|
func TestFormatDateParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// 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
|
// 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'}))"
|
// print(generate_sync_id({'date':'2026-01-15','amount':750.0,'currency':'CZK','sender':'Test','vs':'123','message':'x','bank_id':'1'}))"
|
||||||
//
|
//
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "
|
||||||
// from match_payments import infer_transaction_details
|
// 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á']))"
|
// 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:
|
// 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) {
|
func TestMatchMembersParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// 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) {
|
func TestNormalizeParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// 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.
|
// 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)
|
// Callers should discard the error to match Python semantics: v, _ := money.ParseCZK(s)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
|
|
||||||
// Verify expected values against live Python:
|
// 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) {
|
func TestParseMonthReferencesParity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
//
|
//
|
||||||
// Verify expected values against live Python:
|
// Verify expected values against live Python:
|
||||||
//
|
//
|
||||||
// PYTHONPATH=scripts:. python3 -c "
|
// PYTHONPATH=legacy/scripts:legacy python3 -c "
|
||||||
// from match_payments import reconcile
|
// from match_payments import reconcile
|
||||||
// # ... build members/months/txs/exceptions...
|
// # ... build members/months/txs/exceptions...
|
||||||
// import json; print(json.dumps(reconcile(...)))"
|
// import json; print(json.dumps(reconcile(...)))"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
.git
|
.git
|
||||||
.venv
|
.venv
|
||||||
__pycache__
|
**/__pycache__
|
||||||
*.pyc
|
**/*.pyc
|
||||||
.claude
|
.claude
|
||||||
.gemini
|
.gemini
|
||||||
+14
-7
@@ -30,7 +30,7 @@ from views import (
|
|||||||
adapt_junior_members,
|
adapt_junior_members,
|
||||||
)
|
)
|
||||||
from cache_utils import get_sheet_modified_time, read_cache, write_cache, _LAST_CHECKED, flush_cache
|
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
|
from infer_payments import infer_payments
|
||||||
|
|
||||||
def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
|
def get_cached_data(cache_key, sheet_id, fetch_func, *args, serialize=None, deserialize=None, **kwargs):
|
||||||
@@ -121,6 +121,8 @@ def flush_cache_endpoint():
|
|||||||
deleted = flush_cache()
|
deleted = flush_cache()
|
||||||
return render_template("flush-cache.html", flushed=True, deleted=deleted)
|
return render_template("flush-cache.html", flushed=True, deleted=deleted)
|
||||||
|
|
||||||
|
# Scheduled (daily) bank sync exists only in the Go server, on purpose: this
|
||||||
|
# app is legacy and being phased out. Here the sync runs only on request.
|
||||||
@app.route("/sync-bank")
|
@app.route("/sync-bank")
|
||||||
def sync_bank():
|
def sync_bank():
|
||||||
import contextlib
|
import contextlib
|
||||||
@@ -128,13 +130,14 @@ def sync_bank():
|
|||||||
success = True
|
success = True
|
||||||
try:
|
try:
|
||||||
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
|
with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output):
|
||||||
# sync_to_sheets: equivalent of make sync-2026
|
# sync_to_sheets: equivalent of make sync-year (same default window as Go)
|
||||||
output.write("=== Syncing Fio transactions (2026) ===\n")
|
date_from, date_to = default_sync_window()
|
||||||
|
output.write(f"=== Syncing Fio transactions ({date_from} to {date_to}) ===\n")
|
||||||
sync_to_sheets(
|
sync_to_sheets(
|
||||||
spreadsheet_id=PAYMENTS_SHEET_ID,
|
spreadsheet_id=PAYMENTS_SHEET_ID,
|
||||||
credentials_path=CREDENTIALS_PATH,
|
credentials_path=CREDENTIALS_PATH,
|
||||||
date_from_str="2026-01-01",
|
date_from_str=date_from,
|
||||||
date_to_str="2026-12-31",
|
date_to_str=date_to,
|
||||||
sort_by_date=True,
|
sort_by_date=True,
|
||||||
)
|
)
|
||||||
output.write("\n=== Inferring payment details ===\n")
|
output.write("\n=== Inferring payment details ===\n")
|
||||||
@@ -179,7 +182,9 @@ def api_adults():
|
|||||||
datetime.now().strftime("%Y-%m"),
|
datetime.now().strftime("%Y-%m"),
|
||||||
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
|
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")
|
@app.route("/api/juniors")
|
||||||
def api_juniors():
|
def api_juniors():
|
||||||
@@ -203,7 +208,9 @@ def api_juniors():
|
|||||||
datetime.now().strftime("%Y-%m"),
|
datetime.now().strftime("%Y-%m"),
|
||||||
attendance_url=attendance_url, payments_url=payments_url, bank_account=BANK_ACCOUNT,
|
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")
|
@app.route("/api/payments")
|
||||||
def api_payments():
|
def api_payments():
|
||||||
@@ -17,7 +17,7 @@ RUN pip install --no-cache-dir \
|
|||||||
pillow \
|
pillow \
|
||||||
gunicorn
|
gunicorn
|
||||||
|
|
||||||
COPY app.py Makefile ./
|
COPY app.py ./
|
||||||
COPY scripts/ ./scripts/
|
COPY scripts/ ./scripts/
|
||||||
COPY templates/ ./templates/
|
COPY templates/ ./templates/
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fuj-management"
|
name = "fuj-management"
|
||||||
version = "0.10"
|
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 = [
|
dependencies = [
|
||||||
"flask>=3.1.3",
|
"flask>=3.1.3",
|
||||||
"google-api-python-client>=2.162.0",
|
"google-api-python-client>=2.162.0",
|
||||||
@@ -115,6 +115,15 @@ SEEDS[("calculate_fee", "two_sessions_reduced_march")] = {
|
|||||||
SEEDS[("calculate_fee", "two_sessions_default_fallback")] = {
|
SEEDS[("calculate_fee", "two_sessions_default_fallback")] = {
|
||||||
"attendance_count": 2, "month_key": "2099-01"
|
"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 ---
|
# --- calculate_junior_fee ---
|
||||||
@@ -349,6 +358,36 @@ SEEDS[("infer_transaction_details", "serial_date")] = {
|
|||||||
"member_names": _ROSTER,
|
"member_names": _ROSTER,
|
||||||
"default_year": 2026,
|
"default_year": 2026,
|
||||||
}
|
}
|
||||||
|
SEEDS[("infer_transaction_details", "other_prefix")] = {
|
||||||
|
"tx": {
|
||||||
|
"sender": "Jana Novakova",
|
||||||
|
"message": "other: tournament leden 2026",
|
||||||
|
"user_id": "",
|
||||||
|
"date": {"type": "string", "value": "2026-04-15"},
|
||||||
|
},
|
||||||
|
"member_names": _ROSTER,
|
||||||
|
"default_year": 2026,
|
||||||
|
}
|
||||||
|
SEEDS[("infer_transaction_details", "other_prefix_uppercase")] = {
|
||||||
|
"tx": {
|
||||||
|
"sender": "Test Payer",
|
||||||
|
"message": " Other: Dres ",
|
||||||
|
"user_id": "",
|
||||||
|
"date": {"type": "string", "value": "2026-04-15"},
|
||||||
|
},
|
||||||
|
"member_names": _ROSTER,
|
||||||
|
"default_year": 2026,
|
||||||
|
}
|
||||||
|
SEEDS[("infer_transaction_details", "other_not_at_start")] = {
|
||||||
|
"tx": {
|
||||||
|
"sender": "Jana Novakova",
|
||||||
|
"message": "leden other: dres",
|
||||||
|
"user_id": "",
|
||||||
|
"date": {"type": "string", "value": "2026-04-15"},
|
||||||
|
},
|
||||||
|
"member_names": _ROSTER,
|
||||||
|
"default_year": 2026,
|
||||||
|
}
|
||||||
SEEDS[("infer_transaction_details", "no_member_no_month")] = {
|
SEEDS[("infer_transaction_details", "no_member_no_month")] = {
|
||||||
"tx": {
|
"tx": {
|
||||||
"sender": "Unknown Person",
|
"sender": "Unknown Person",
|
||||||
@@ -467,6 +506,14 @@ SEEDS[("reconcile", "07_other_purpose_split")] = _reconcile_seed(
|
|||||||
transactions=[_tx("2026-01-10", 800, "Alice Dvořák, Bob Kratochvíl", "other:tournament", sender="Alice Dvořák")],
|
transactions=[_tx("2026-01-10", 800, "Alice Dvořák, Bob Kratochvíl", "other:tournament", sender="Alice Dvořák")],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 11 — message starts with "other:" on a row not yet inferred (empty Person/Purpose):
|
||||||
|
# goes to the other ledger, the month stays unpaid
|
||||||
|
SEEDS[("reconcile", "11_other_message_fallback")] = _reconcile_seed(
|
||||||
|
members=[_member("Alice Dvořák", "A", {"2026-01": (750, 3)})],
|
||||||
|
sorted_months=["2026-01"],
|
||||||
|
transactions=[_tx("2026-01-10", 300, "", "", sender="Alice Dvořák", message="other: tournament")],
|
||||||
|
)
|
||||||
|
|
||||||
# 08 — junior with attendance=1 (expected=0 in reconcile, unknown in UI)
|
# 08 — junior with attendance=1 (expected=0 in reconcile, unknown in UI)
|
||||||
SEEDS[("reconcile", "08_junior_question_mark")] = _reconcile_seed(
|
SEEDS[("reconcile", "08_junior_question_mark")] = _reconcile_seed(
|
||||||
members=[_member("Karel Junior", "A", {"2026-01": (0, 1)})],
|
members=[_member("Karel Junior", "A", {"2026-01": (0, 1)})],
|
||||||
@@ -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"
|
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}"
|
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_SINGLE = 200 # CZK, for exactly 1 practice in a month
|
||||||
ADULT_FEE_MONTHLY_RATE = {
|
ADULT_FEE_MONTHLY_RATE = {
|
||||||
"2025-09": 750,
|
"2025-09": 750,
|
||||||
@@ -23,8 +23,12 @@ ADULT_FEE_MONTHLY_RATE = {
|
|||||||
"2026-04": 700,
|
"2026-04": 700,
|
||||||
"2026-05": 450,
|
"2026-05": 450,
|
||||||
"2026-06": 600,
|
"2026-06": 600,
|
||||||
"2026-07": 600,
|
"2026-07": 0,
|
||||||
"2026-08": 600,
|
"2026-08": 0,
|
||||||
|
"2026-09": 600,
|
||||||
|
"2026-10": 600,
|
||||||
|
"2026-11": 600,
|
||||||
|
"2026-12": 600,
|
||||||
}
|
}
|
||||||
|
|
||||||
JUNIOR_FEE_DEFAULT = 500 # CZK for 2+ practices
|
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:
|
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:
|
if attendance_count == 0:
|
||||||
return 0
|
return 0
|
||||||
|
rate = ADULT_FEE_MONTHLY_RATE.get(month_key, ADULT_FEE_DEFAULT)
|
||||||
if attendance_count == 1:
|
if attendance_count == 1:
|
||||||
return ADULT_FEE_SINGLE
|
return min(ADULT_FEE_SINGLE, rate)
|
||||||
return ADULT_FEE_MONTHLY_RATE.get(month_key, ADULT_FEE_DEFAULT)
|
return rate
|
||||||
|
|
||||||
|
|
||||||
def calculate_junior_fee(attendance_count: int, month_key: str) -> str | int:
|
def calculate_junior_fee(attendance_count: int, month_key: str) -> str | int:
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Regenerate the full fixture corpus.
|
# Regenerate the full fixture corpus.
|
||||||
# Safe to re-run — always overwrites.
|
# 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
|
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"
|
FIXTURES="$REPO/go/tests/fixtures"
|
||||||
CAPTURE_CMD="PYTHONPATH=$REPO/scripts:. python3 $REPO/scripts/capture_fixtures.py"
|
CAPTURE_CMD="PYTHONPATH=$LEGACY/scripts:$LEGACY python3 $LEGACY/scripts/capture_fixtures.py"
|
||||||
SCRUB_CMD="python3 $REPO/scripts/scrub_fixtures.py"
|
SCRUB_CMD="python3 $LEGACY/scripts/scrub_fixtures.py"
|
||||||
|
|
||||||
run_func() {
|
run_func() {
|
||||||
local func="$1"
|
local func="$1"
|
||||||
@@ -18,7 +19,7 @@ run_func() {
|
|||||||
eval "$CAPTURE_CMD --func $func --all" | while IFS= read -r line; do
|
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 || \
|
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 -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
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ echo "==> Capturing reconcile fixtures..."
|
|||||||
mkdir -p "$FIXTURES/reconcile"
|
mkdir -p "$FIXTURES/reconcile"
|
||||||
eval "$CAPTURE_CMD --func reconcile --all" | while IFS= read -r line; do
|
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'])")"
|
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
|
done
|
||||||
|
|
||||||
echo "==> Done. Review with: git diff go/tests/fixtures/"
|
echo "==> Done. Review with: git diff go/tests/fixtures/"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user